Python: 3-сигма детектор «аномалий» количества заказов и строк

Я хочу проанализировать данные о транзакциях в контексте электронной коммерции, где основное внимание уделяется обнаружению нетипичных действий в шаблонах заказов. Данные группируются по идентификатору клиента SoldTo, и для каждой группы мы применяем простой статистический метод для обнаружения аномалий на основе количества заказов и количества строк. В частности, шаги включают в себя:

  1. Подготовка данных. Убедитесь, что столбец даты (Created_on) находится в правильный формат.
  2. Группировка данных: группировка данных по полю «Продано». изолировать транзакции для каждого клиента. Роллинг окно Расчеты: для каждой группы применяются расчеты скользящего окна. для расчета скользящих средних и стандартных отклонений для заказа количество и количество строк.
  3. Применение правила 3-х сигм: использование правила 3-х сигм для выявления транзакций, которые значительно отклоняются от нормы, отмечая их как нетипичные или подозрительные.
  4. Независимая обработка: обработка каждой группы клиентов (SoldTo) независимо от убедитесь, что на механизм обнаружения не влияют данные из другие клиенты. Объединение результатов: После обработки пишем вот это данные в файл .csv.

Моя проблема: в качестве теста я передаю ОДНО известному человеку SoldTo, имеющему аномальные строки, и код работает для обнаружения аномальных строк, как и предполагалось! Но когда я добавляю более одного SoldTo, включая известные аномальные линии, они больше не обнаруживаются. Почему это должно быть?

Вот что я пробовал использовать свой код вместе (надеюсь, так и будет) с двумя удобными для загрузки наборами данных (один имеет только один SoldTo, где мой код будет обнаруживать известные аномальные транзакции, и другой набор данных). наличие (2) SoldTo <- мой код больше не работает для обнаружения известных аномальных строк, когда вместе используются 2+ Soldto...

# Create DataFrame
df = pd.DataFrame(fraud)

df['Created_on'] = pd.to_datetime(df['Created_on'])

# Group by 'SoldTo' and 'Created_on'
grouped = df.groupby(['SoldTo', 'Created_on', 'Sales_Doc']).agg(
    total_quantity=('Order_Quantity', 'sum'),
    line_count=('Sales_Doc', 'count')    # Modified this line so the provided data sets can be used.   Thanks @Timus
).reset_index()

# Compute rolling statistics and 3-sigma for each SoldTo group
grouped['avg_line'] = grouped.groupby('SoldTo')['line_count'].transform(lambda x: x.rolling(3, min_periods=1).mean())
grouped['ma_qty'] = grouped.groupby('SoldTo')['total_quantity'].transform(lambda x: x.rolling(3, min_periods=1).mean())
grouped['stDev_of_qty'] = grouped.groupby('SoldTo')['total_quantity'].transform(lambda x: x.rolling(3, min_periods=1).std(ddof=0))
grouped['stDev_of_lines'] = grouped.groupby('SoldTo')['line_count'].transform(lambda x: x.rolling(3, min_periods=1).std(ddof=0))

# Compute the 3-sigma thresholds
grouped['avg_qty_sigma_trigger'] = ((3 * grouped['stDev_of_qty']) + grouped['ma_qty'])
grouped['avg_line_sigma_trigger'] = ((3 * grouped['stDev_of_lines']) + grouped['avg_line'])

# Function to identify atypical rows based on 3-sigma rule within each SoldTo group
def identify_atypical(df):
    atypical_indices = []

    for sold_to, group in df.groupby('SoldTo'):
#        group = group.reset_index(drop=True) # Removed this line. Thx @Timus
        
        for i in range(len(group) - 1):
            current_row = group.iloc[i]
            next_row = group.iloc[i + 1]

            if (next_row['line_count'] > current_row['avg_line_sigma_trigger'] or
                next_row['total_quantity'] > current_row['avg_qty_sigma_trigger']):
                atypical_indices.append(group.index[i + 1])

    # Mark atypical rows in the dataframe
    df['is_atypical'] = False
    df.loc[atypical_indices, 'is_atypical'] = True

    return df, atypical_indices


# Identify atypical rows
grouped, atypical_indices = identify_atypical(grouped)

# Print the dataframe and indices of atypical rows
print("Atypical rows indices:", atypical_indices)
print("")

print(grouped)

# Filter atypical rows within a specified date range
#check_these = grouped[(grouped['is_atypical'] == True) & (grouped['Created_on'] >= '2024-06-01')]
check_these = grouped[(grouped['is_atypical'] == True) & (grouped['total_quantity'] != 1) & (grouped['line_count'] != 1) ]
#check_these = grouped[(grouped['is_atypical'] == True)]

# Save the cleaned dataframe to a CSV file
check_these.sort_values(by='SoldTo', ascending=True).to_csv('order_behavior_analysis_3.csv', index=False)

При использовании этих данных и наличии только одного Soldto код возвращает результаты по мере необходимости: Индексы нетипичных строк: [5, 6, 11]

[['SoldTo', 'Created_on', 'Sales_Doc', 'Order_Quantity'],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 4],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 4],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 2],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 2],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 17],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 4],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 2],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 11],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 6],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 4],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110893468, 11],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110893468, 10],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902368, 33],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902525, 10],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902525, 4],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 8],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 6],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 16],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 4],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 1],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 8],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 3],
 ['59908158', Timestamp('2023-11-13 00:00:00'), 110966070, 52],
 ['59908158', Timestamp('2023-11-15 00:00:00'), 111035845, 15],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177113, 18],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177113, 5],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177887, 20],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177887, 11],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 4],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 8],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 22],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 4],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 16],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 12],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 18],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 3],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 18],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 7],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 22],
 ['59908158', Timestamp('2023-11-21 00:00:00'), 111446837, 9],
 ['59908158', Timestamp('2023-11-21 00:00:00'), 111446837, 13]]

Но используя эти данные и имея (2) значения SoldTo, код возвращает новые строки, но больше не «обнаруживает» известные нетипичные строки и возвращает разные индексы: Индексы нетипичных строк: [1, 6, 12, 14, 17, 5, 6, 11]

[['SoldTo', 'Created_on', 'Sales_Doc', 'Order_Quantity'],
 ['56619720', Timestamp('2023-01-13 00:00:00'), 108036530, 10],
 ['56619720', Timestamp('2023-01-13 00:00:00'), 108036530, 1],
 ['56619720', Timestamp('2023-03-03 00:00:00'), 108391209, 20],
 ['56619720', Timestamp('2023-03-03 00:00:00'), 108391209, 2],
 ['56619720', Timestamp('2023-04-13 00:00:00'), 108738953, 30],
 ['56619720', Timestamp('2023-07-24 00:00:00'), 109827151, 20],
 ['56619720', Timestamp('2023-09-20 00:00:00'), 110467726, 30],
 ['56619720', Timestamp('2023-10-11 00:00:00'), 110658107, 10],
 ['56619720', Timestamp('2023-11-10 00:00:00'), 110946376, 2],
 ['56619720', Timestamp('2023-11-10 00:00:00'), 110946376, 3],
 ['56619720', Timestamp('2023-11-10 00:00:00'), 110946376, 5],
 ['56619720', Timestamp('2023-12-13 00:00:00'), 111681360, 5],
 ['56619720', Timestamp('2023-12-19 00:00:00'), 111739909, 6],
 ['56619720', Timestamp('2023-12-19 00:00:00'), 111739909, 4],
 ['56619720', Timestamp('2023-12-19 00:00:00'), 111739909, 2],
 ['56619720', Timestamp('2023-12-19 00:00:00'), 111739909, 2],
 ['56619720', Timestamp('2024-01-25 00:00:00'), 112057996, 5],
 ['56619720', Timestamp('2024-02-23 00:00:00'), 112322261, 12],
 ['56619720', Timestamp('2024-03-07 00:00:00'), 112453024, 5],
 ['56619720', Timestamp('2024-03-25 00:00:00'), 112625572, 5],
 ['56619720', Timestamp('2024-03-25 00:00:00'), 112625572, 3],
 ['56619720', Timestamp('2024-03-27 00:00:00'), 112651496, 2],
 ['56619720', Timestamp('2024-04-26 00:00:00'), 112942567, 5],
 ['56619720', Timestamp('2024-04-26 00:00:00'), 112942567, 5],
 ['56619720', Timestamp('2024-04-26 00:00:00'), 112942567, 2],
 ['56619720', Timestamp('2024-04-26 00:00:00'), 112942567, 2],
 ['56619720', Timestamp('2024-04-26 00:00:00'), 112942567, 2],
 ['56619720', Timestamp('2024-05-09 00:00:00'), 113200232, 2],
 ['56619720', Timestamp('2024-05-22 00:00:00'), 113359192, 2],
 ['56619720', Timestamp('2024-06-10 00:00:00'), 113534221, 1],
 ['56619720', Timestamp('2024-06-10 00:00:00'), 113534221, 34],
 ['56619720', Timestamp('2024-06-10 00:00:00'), 113534221, 20],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 4],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 4],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 2],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 2],
 ['59908158', Timestamp('2023-11-02 00:00:00'), 110866572, 17],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 4],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 2],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 11],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 6],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110884032, 4],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110893468, 11],
 ['59908158', Timestamp('2023-11-06 00:00:00'), 110893468, 10],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902368, 33],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902525, 10],
 ['59908158', Timestamp('2023-11-07 00:00:00'), 110902525, 4],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 8],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110929917, 6],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 16],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 10],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 4],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 1],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 20],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 8],
 ['59908158', Timestamp('2023-11-09 00:00:00'), 110930046, 3],
 ['59908158', Timestamp('2023-11-13 00:00:00'), 110966070, 52],
 ['59908158', Timestamp('2023-11-15 00:00:00'), 111035845, 15],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177113, 18],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177113, 5],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177887, 20],
 ['59908158', Timestamp('2023-11-16 00:00:00'), 111177887, 11],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 4],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 8],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 22],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 4],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 16],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 12],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 18],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 3],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 18],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 20],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 7],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 10],
 ['59908158', Timestamp('2023-11-20 00:00:00'), 111430236, 22],
 ['59908158', Timestamp('2023-11-21 00:00:00'), 111446837, 9],
 ['59908158', Timestamp('2023-11-21 00:00:00'), 111446837, 13]]

Спасибо за помощь в интерпретации моего дальнейшего пути.

Трудно сказать, учитывая, что нет образцов данных, на которых можно было бы это проверить. Но что привлекло мое внимание, так это следующее созвездие в функции identify_atypical: df группируется, затем для каждой группы индекс сбрасывается group = group.reset_index(drop=True), затем собираются эти новые индексы atypical_indices.append(group.index[i + 1]) и, наконец, с помощью этих новых индексов корректируется исходный фрейм данных df.loc[atypical_indices, 'is_atypical'] = True. Это выглядит как рецепт катастрофы.

Timus 25.06.2024 12:59

Дополнительно: мне кажется, что задачу в identify_atypical лучше было бы выполнить с помощью собственных методов Pandas (используйте .shift) вместо циклического перебора строк (for i in range(len(group) - 1) ...).

Timus 25.06.2024 13:02

Спасибо, @Тимус. Я думаю, вы что-то поняли... в моем коде есть полоса Мебиуса для сброса индекса... Кроме того, можете ли вы загрузить примеры наборов данных, которые я предоставил в формате списка из моего сообщения? Есть ли лучший способ предоставить эти данные здесь? <- еще раз спасибо за помощь/комментарии.

CJB 25.06.2024 15:26

Да, я могу загрузить образцы, но их недостаточно для запуска вашего кода: столбец Material отсутствует, а это значит, что я не могу построить grouped, как показано, и, следовательно, не всю статистику можно посчитать, и поэтому identify_atypical можно' надо бежать.

Timus 25.06.2024 16:01

Если вы удалите эту строку group = group.reset_index(drop=True) из функции, результаты должны быть согласованными (они, по крайней мере, здесь, с уменьшенной формой фрейма данных).

Timus 25.06.2024 16:19

@ Тимус. Это очень проницательно. Ваше предложение удалить строку group = решило для меня проблему. Кроме того, я собираюсь обновить приведенный выше синтаксис, удалив поле «Материал» вместо другого поля в предоставленных наборах данных, которое будет отображать количество строк по мере необходимости. Спасибо также за указание на это. Кончик шляпы!

CJB 25.06.2024 17:28

@Timus: Я изменил свой синтаксис, чтобы отразить ваши предложения, и протестирую еще пару часов. Скоро вернусь, чтобы отметить ваши предложения как ответы.

CJB 25.06.2024 17:33
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
1
7
66
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Как я уже писал в комментариях, я почти уверен, что сброс индексов групп в функции identify_atypical портит окончательное обновление исходного кадра данных. Поэтому я бы посоветовал вам попробовать что-то вроде следующего:

def identify_atypical(df):
    atypical_idxs = set()
    for _, group in df.groupby('SoldTo'):
        m = (
            group['line_count'].gt(group['avg_line_sigma_trigger']).shift())
            | group['total_quantity'].gt(group['avg_qty_sigma_trigger'].shift())
        )
        atypical_idxs.update(group[m].index)
    return df.assign(is_atypical=df.index.isin(atypical_idxs)), atypical_idxs

(Я не могу провести полный тест, потому что образец неполный, но на уменьшенном фрейме данных все выглядело нормально.)

Спасибо @Timus. Я обновил синтаксис, чтобы он работал с примером на случай, если это решение также окажется полезным кому-то еще в будущем.

CJB 26.06.2024 00:47

Другие вопросы по теме

Как избежать цикла в Pandas, повторяющего уникальные значения?
Как исправить столбец с числовыми значениями, который воспринимается как строковое поле из-за пустых строк в фрейме данных Pandas?
Добавьте значения двух Dataframe на основе похожих значений строк
С помощью Python извлеките в файл Excel значение ячейки в строке, где ячейка в той же строке содержит строку символов из XML-файла
Обмен датой начала, датой окончания и другими столбцами с более ранней строкой, если даты больше 8 в фрейме данных pandas
Как я могу сравнить значение в одном столбце со всеми значениями, которые находятся ДО него в другом столбце, чтобы найти количество уникальных значений, которые меньше?
Присоединиться к фрейму данных с двойной записью
Получите минимум за счет итераций записи в фрейме данных pandas
Добавьте количество строк в виде списка в столбец, используя groupby
Сохраните данные на новой вкладке в файле .xlsx

Похожие вопросы