Практика · Пошук аномалій¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

Та сама дошка оголошень про вживані телефони, що й у темі 08 та темі 13: 1 100 оголошень із відомою ціною й сім числових ознак. Колонка шахрайське в таблиці є, але жоден метод її не побачить — дістанемо її аж наприкінці, щоб перевірити, що саме ми знайшли.

Що ми зробимо:

  1. зберемо ту саму таблицю й сім числових ознак;
  2. застосуємо одновимірні правила — z-оцінку й міжквартильний розмах — і порахуємо, скільки шахрайських оголошень вони ловлять;
  3. знайдемо оголошення, нормальне за кожною ознакою окремо й аномальне разом;
  4. порахуємо три багатовимірні методи: IsolationForest, LocalOutlierFactor і помилку відновлення після PCA — і зведемо їхні оцінки в одну таблицю;
  5. перевіримо помилку відновлення вручну на NumPy проти бібліотечної;
  6. подивимось, наскільки методи згодні між собою;
  7. візьмемо топ-20 кожного й порахуємо precision@20 проти шахрайське;
  8. прогонимо поріг від 0.5 % до 30 % і побачимо, як точність міняється на повноту.

Зерно генератора зафіксовано (np.random.default_rng(42)), тож числа збігатимуться з лекцією до цифри.

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.decomposition import PCA

# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
rng = np.random.default_rng(42)

pd.set_option("display.width", 140)
pd.set_option("display.max_columns", 16)
np.set_printoptions(suppress=True, linewidth=140)

print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3

1 · Збираємо ту саму дошку¶

Цей блок — код із практики теми 08 без змін. Ми його не пояснюємо повторно, а просто відтворюємо таблицю, щоб числа збіглися.

In [2]:
кількість = 1200

моделі = ["Alfa A5", "Alfa A7", "Beta 12", "Beta 12 Pro", "Gamma X", "Gamma X Ultra"]
ціна_нового = {"Alfa A5": 5200, "Alfa A7": 7400, "Beta 12": 12000,
               "Beta 12 Pro": 17500, "Gamma X": 24000, "Gamma X Ultra": 34000}
частки_моделей = [0.24, 0.22, 0.18, 0.16, 0.12, 0.08]

модель = rng.choice(моделі, size=кількість, p=частки_моделей)
рік = rng.integers(2017, 2025, size=кількість)
стан = rng.choice(["нове", "дуже добре", "добре", "задовільне"],
                  size=кількість, p=[0.08, 0.32, 0.42, 0.18])
памʼять = rng.choice([64, 128, 256, 512], size=кількість, p=[0.30, 0.38, 0.24, 0.08])
вік_акаунта = np.round(rng.exponential(420, size=кількість) + 3).astype(int)

базова = np.array([ціна_нового[m] for m in модель])
знос = 0.82 ** (2024 - рік)
коефіцієнт_стану = np.array(
    [{"нове": 1.0, "дуже добре": 0.88, "добре": 0.75, "задовільне": 0.58}[s] for s in стан])
коефіцієнт_памʼяті = np.array(
    [{64: 0.85, 128: 1.0, 256: 1.15, 512: 1.32}[m] for m in памʼять])

типова_ціна_за_паспортом = базова * знос * коефіцієнт_стану * коефіцієнт_памʼяті
ціна = типова_ціна_за_паспортом * rng.lognormal(0, 0.13, size=кількість)

print("перші пʼять цін:", ціна[:5].round(0))
перші пʼять цін: [ 3085.  2810. 10822.  8346.  1852.]
In [3]:
# шахрай тим імовірніший, чим молодший акаунт; ціну він або занижує (приманка),
# або завищує під велику передоплату
шанс_шахрайства = 0.10 + 0.30 * np.exp(-вік_акаунта / 120)
шахрайське = rng.random(кількість) < шанс_шахрайства

ставить_дешево = rng.random(кількість) < 0.74
дешева_приманка = шахрайське & ставить_дешево
дорога_приманка = шахрайське & ~ставить_дешево

ціна[дешева_приманка] = (типова_ціна_за_паспортом[дешева_приманка]
                         * rng.uniform(0.20, 0.45, дешева_приманка.sum()))
ціна[дорога_приманка] = (типова_ціна_за_паспортом[дорога_приманка]
                         * rng.uniform(2.6, 3.8, дорога_приманка.sum()))
ціна = np.round(ціна, -1)

скарг = np.where(шахрайське, 1 + rng.poisson(3.0, кількість), rng.poisson(0.03, кількість))

дошка = pd.DataFrame({
    "модель": модель, "рік": рік, "стан": стан, "памʼять_гб": памʼять,
    "вік_акаунта": вік_акаунта, "скарг": скарг, "ціна": ціна,
    "шахрайське": шахрайське.astype(int),
})
print("шахрайських оголошень:", int(дошка["шахрайське"].sum()), "з", кількість)
шахрайських оголошень: 196 з 1200
In [4]:
# ті самі шість неприємностей із теми 08 — колекційні, одруки, памʼять текстом,
# пропуски в ціні та стані, дублікати
колекційні = дошка.index[дошка["модель"] == "Gamma X"][:4]
дошка.loc[колекційні, ["рік", "стан", "памʼять_гб"]] = [2017, "нове", 512]
дошка.loc[колекційні, "ціна"] = [82000.0, 88000.0, 91000.0, 95000.0]
дошка.loc[колекційні, ["шахрайське", "скарг"]] = 0

одруки = дошка.index[(дошка["ціна"] > 7000) & (дошка["ціна"] < 9600)
                     & (дошка["шахрайське"] == 0)][:2]
дошка.loc[одруки, "ціна"] = дошка.loc[одруки, "ціна"] * 10

памʼять_текстом = дошка["памʼять_гб"].astype(str)
із_одиницями = rng.random(len(дошка)) < 0.18
памʼять_текстом[із_одиницями] = памʼять_текстом[із_одиницями] + " ГБ"
дошка["памʼять_гб"] = памʼять_текстом

ймовірність_пропуску = np.where(дошка["шахрайське"] == 1, 0.25, 0.03)
дошка.loc[rng.random(len(дошка)) < ймовірність_пропуску, "ціна"] = np.nan
дошка.loc[rng.random(len(дошка)) < 0.04, "стан"] = np.nan

повтори = rng.choice(дошка.index, size=12, replace=False)
дошка = pd.concat([дошка, дошка.loc[повтори]], ignore_index=True)

assert дошка.shape == (1212, 8), "форма розійшлась із темою 22"
print("таблиця як у темі 08:", дошка.shape)
таблиця як у темі 08: (1212, 8)

2 · Сім числових ознак¶

Ті самі сім колонок, що і в темі 13: чистимо памʼять від суфікса «ГБ», перетворюємо стан на бал від 1 до 4, додаємо дві оцінки вартості (медіану реальних цін для пари «модель + рік» і оцінку з каталогу) і лишаємо оголошення з відомою ціною.

Колонку скарг не беремо: скарги зʼявляються вже після публікації, це витік, знайдений ще в темі 08.

In [5]:
дошка["памʼять_гб"] = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
дошка = дошка.drop_duplicates().reset_index(drop=True)

# стан — порядкова шкала: чим більше, тим кращий апарат
бали_стану = {"задовільне": 1, "добре": 2, "дуже добре": 3, "нове": 4}
дошка["стан_бал"] = дошка["стан"].map(бали_стану).fillna(2).astype(int)

# без ціни неможливо порахувати жодну з цінових ознак, тож ці рядки відкладаємо
оголошення = дошка.dropna(subset=["ціна"]).reset_index(drop=True)

оголошення["типова_ціна"] = (оголошення.groupby(["модель", "рік"])["ціна"]
                             .transform("median"))
оголошення["оцінка_каталогу"] = (оголошення["модель"].map(ціна_нового)
                                 * 0.82 ** (2024 - оголошення["рік"])).round(0)

назви_ознак = ["ціна", "типова_ціна", "оцінка_каталогу", "рік",
               "памʼять_гб", "стан_бал", "вік_акаунта"]
X = оголошення[назви_ознак].to_numpy(float)

# правильна відповідь, якої жоден метод нижче не побачить
шахрайське_насправді = оголошення["шахрайське"].to_numpy()

# усі методи цієї теми міряють відстані між ознаками разом,
# тож без стандартизації вони міряли б одиниці вимірювання, а не дані
Z = StandardScaler().fit_transform(X)

print("матриця ознак:", X.shape)
print("шахрайських оголошень:", int(шахрайське_насправді.sum()),
      f"({шахрайське_насправді.mean() * 100:.1f} %)")
print()
print("базова точність «тицьнути навмання»:",
      f"{шахрайське_насправді.mean() * 100:.1f} %")
print("accuracy моделі «усе чесне»:",
      f"{(1 - шахрайське_насправді.mean()) * 100:.1f} % — і вона нічого не робить")
матриця ознак: (1100, 7)
шахрайських оголошень: 139 (12.6 %)

базова точність «тицьнути навмання»: 12.6 %
accuracy моделі «усе чесне»: 87.4 % — і вона нічого не робить

3 · Одновимірні правила: скільки вони ловлять¶

Два звичні правила з теми 08, застосовані до кожної з семи колонок окремо:

  • z-оцінка — скільки стандартних відхилень від середнього; позначаємо |z| > 3;
  • міжквартильний розмах — за межами [Q1 − 1.5·IQR, Q3 + 1.5·IQR].

Оголошення вважаємо позначеним, якщо воно вилетіло хоч за одним правилом хоч в одній колонці. Порівнювати результат треба не з нулем, а з часткою шахрайських на дошці — 12.6 %: саме стільки дало б тикання навмання.

In [6]:
# межі 1.5·IQR рахуємо окремо для кожної колонки
за_межею_iqr = np.zeros_like(X, dtype=bool)
for номер, назва in enumerate(назви_ознак):
    перший_квартиль, третій_квартиль = np.percentile(X[:, номер], [25, 75])
    розмах = третій_квартиль - перший_квартиль
    нижня = перший_квартиль - 1.5 * розмах
    верхня = третій_квартиль + 1.5 * розмах
    за_межею_iqr[:, номер] = (X[:, номер] < нижня) | (X[:, номер] > верхня)

за_межею_z = np.abs(Z) > 3

звіт = pd.DataFrame({
    "колонка": назви_ознак,
    "|z| > 3": за_межею_z.sum(axis=0),
    "поза 1.5·IQR": за_межею_iqr.sum(axis=0),
})
print(звіт.to_string(index=False))
        колонка  |z| > 3  поза 1.5·IQR
           ціна       17            75
    типова_ціна       19            38
оцінка_каталогу       19            70
            рік        0             0
     памʼять_гб        0             0
       стан_бал        0             0
    вік_акаунта       21            58
In [7]:
# «позначене» = вилетіло хоч за одним правилом хоч в одній колонці
позначене_z = за_межею_z.any(axis=1)
позначене_iqr = за_межею_iqr.any(axis=1)

def точність_правила(маска, підпис):
    """Друкує, скільки рядків позначено і яка частка з них справді шахрайська."""
    скільки = int(маска.sum())
    шахраїв = int(шахрайське_насправді[маска].sum())
    точність = шахраїв / скільки
    повнота = шахраїв / шахрайське_насправді.sum()
    print(f"{підпис:16s} позначено {скільки:4d} · шахраїв {шахраїв:3d} · "
          f"точність {точність * 100:5.1f} % · повнота {повнота * 100:5.1f} %")

точність_правила(позначене_z, "|z| > 3")
точність_правила(позначене_iqr, "1.5·IQR")
print()
print(f"для порівняння: навмання дало б {шахрайське_насправді.mean() * 100:.1f} %")
|z| > 3          позначено   51 · шахраїв   6 · точність  11.8 % · повнота   4.3 %
1.5·IQR          позначено  156 · шахраїв  26 · точність  16.7 % · повнота  18.7 %

для порівняння: навмання дало б 12.6 %

Правило z-оцінки спрацювало гірше за випадковий вибір: 11.8 % проти 12.6 %. Правило розмаху трохи краще — 16.7 %. Обидва відповідають на питання «чи велике це число», а шахрайське оголошення видає не величина, а поєднання колонок.

4 · Оголошення, нормальне за кожною ознакою окремо¶

Тепер знайдімо рядки, які жодне одновимірне правило не позначило, і подивімось, чи є серед них дивні. Спершу порахуємо багатовимірні оцінки, а потім відфільтруємо.

In [8]:
# Isolation Forest: глибина, на якій точку відрізають випадкові розрізи.
# contamination впливає лише на межу «аномалія / не аномалія», а не на порядок оцінок.
ліс = IsolationForest(n_estimators=200, contamination=0.05, random_state=42)
ліс.fit(Z)
# score_samples дає тим менше, чим аномальніша точка — міняємо знак,
# щоб усі три оцінки читались однаково: більше = аномальніше
оцінка_лісу = -ліс.score_samples(Z)

# LOF: відношення щільності навколо точки до щільності навколо її сусідів.
# fit_predict обовʼязковий — без нього negative_outlier_factor_ не порахується.
лоф = LocalOutlierFactor(n_neighbors=20)
лоф.fit_predict(Z)
оцінка_лоф = -лоф.negative_outlier_factor_

# Помилка відновлення: стискаємо до трьох компонент і збираємо сім ознак назад
стиснення = PCA(n_components=3, random_state=42).fit(Z)
відновлене = стиснення.inverse_transform(стиснення.transform(Z))
оцінка_відновлення = np.sqrt(((Z - відновлене) ** 2).sum(axis=1))

частка_розкиду = стиснення.explained_variance_ratio_.sum() * 100
print(f"три компоненти тримають {частка_розкиду:.1f} % розкиду")
print()
print("медіана помилки відновлення:", round(float(np.median(оцінка_відновлення)), 2))
print("найбільша помилка:          ", round(float(оцінка_відновлення.max()), 2))
три компоненти тримають 71.1 % розкиду

медіана помилки відновлення: 1.1
найбільша помилка:           6.98
In [9]:
# ранг 1 — найаномальніше оголошення за цією оцінкою
def ранги(оцінка):
    """Перетворює оцінку на місце в рейтингу: 1 у найаномальнішого."""
    порядок = np.argsort(-оцінка)
    місця = np.empty(len(оцінка), dtype=int)
    місця[порядок] = np.arange(1, len(оцінка) + 1)
    return місця

ранг_лісу = ранги(оцінка_лісу)
ранг_лоф = ранги(оцінка_лоф)
ранг_відновлення = ранги(оцінка_відновлення)

# рядки, які не позначило жодне одновимірне правило
непомічені_осями = ~позначене_iqr & ~позначене_z

таблиця = оголошення.loc[непомічені_осями,
                         ["модель", "рік", "стан", "памʼять_гб",
                          "вік_акаунта", "ціна", "типова_ціна"]].copy()
таблиця["LOF"] = ранг_лоф[непомічені_осями]
таблиця["ліс"] = ранг_лісу[непомічені_осями]
таблиця["відновлення"] = ранг_відновлення[непомічені_осями]
таблиця["найбільший |z|"] = np.abs(Z[непомічені_осями]).max(axis=1).round(2)

print("найдивніші серед тих, кого одновимірні правила не бачать:")
print(таблиця.sort_values("LOF").head(5).to_string())
найдивніші серед тих, кого одновимірні правила не бачать:
          модель   рік        стан  памʼять_гб  вік_акаунта     ціна  типова_ціна  LOF  ліс  відновлення  найбільший |z|
328      Beta 12  2021       добре          64           93  15160.0       4310.0   14  568          278            1.00
321  Beta 12 Pro  2020  задовільне         128           23  15370.0       5190.0   24  221          292            1.54
109  Beta 12 Pro  2017  дуже добре         128            7  13130.0       3630.0   25  319          127            1.50
90       Alfa A5  2017        нове          64          515    940.0       1020.0   28  168          364            1.98
478      Gamma X  2017        нове         256         1022   7060.0       6530.0   33   96          521            1.98
In [10]:
# розглянемо переможця цього списку повністю
підозріле = таблиця.sort_values("LOF").index[0]

print("оголошення №", підозріле)
for номер, назва in enumerate(назви_ознак):
    частка_нижче = (X[:, номер] <= X[підозріле, номер]).mean() * 100
    print(f"  {назва:18s} {X[підозріле, номер]:10.1f}   "
          f"z = {Z[підозріле, номер]:+.2f}   нижче за нього {частка_нижче:5.1f} % дошки")

відношення = оголошення.loc[підозріле, "ціна"] / оголошення.loc[підозріле, "типова_ціна"]
print()
print("ціна поділити на типову:", round(float(відношення), 2))
print("таких оголошень на дошці:",
      f"{((оголошення['ціна'] / оголошення['типова_ціна']) > відношення).mean() * 100:.1f} %")
print("місце за LOF:", ранг_лоф[підозріле], "з", len(оголошення))
print("шахрайське насправді:", int(шахрайське_насправді[підозріле]))
оголошення № 328
  ціна                  15160.0   z = +1.00   нижче за нього  92.3 % дошки
  типова_ціна            4310.0   z = -0.29   нижче за нього  56.5 % дошки
  оцінка_каталогу        6616.0   z = -0.13   нижче за нього  63.7 % дошки
  рік                    2021.0   z = +0.23   нижче за нього  63.2 % дошки
  памʼять_гб               64.0   z = -0.85   нижче за нього  30.6 % дошки
  стан_бал                  2.0   z = -0.37   нижче за нього  60.8 % дошки
  вік_акаунта              93.0   z = -0.77   нижче за нього  19.7 % дошки

ціна поділити на типову: 3.52
таких оголошень на дошці: 2.4 %
місце за LOF: 14 з 1100
шахрайське насправді: 1

Жодна ознака не виходить навіть за одну стандартну відстань від середнього — і саме тому одновимірні правила його не бачать. А відношення ціни до типової робить рядок неможливим, і LOF ставить його в перші двадцять із 1 100.

5 · Перевірка: помилка відновлення руками¶

inverse_transform виглядає як магія, а насправді це два множення на матрицю. Порахуймо те саме на чистому NumPy й переконаймось, що числа збігаються.

Стиснення — це проєкція центрованих даних на матрицю компонент; відновлення — множення назад і додавання середнього.

In [11]:
# компоненти PCA лежать рядками: матриця (3 × 7)
компоненти = стиснення.components_
середнє_по_ознаках = стиснення.mean_

# крок 1: центруємо й проєктуємо на три компоненти → три числа на оголошення
центроване = Z - середнє_по_ознаках
стиснене_вручну = центроване @ компоненти.T

# крок 2: збираємо сім ознак назад із трьох чисел і повертаємо середнє на місце
відновлене_вручну = стиснене_вручну @ компоненти + середнє_по_ознаках

# крок 3: помилка — евклідова відстань між оригіналом і копією
оцінка_вручну = np.sqrt(((Z - відновлене_вручну) ** 2).sum(axis=1))

assert np.allclose(відновлене_вручну, відновлене), "відновлення розійшлось!"
assert np.allclose(оцінка_вручну, оцінка_відновлення), "помилка відновлення розійшлась!"
print("✅ збігається")
print("перші пʼять помилок:", оцінка_вручну[:5].round(3))
✅ збігається
перші пʼять помилок: [0.946 0.435 5.788 6.849 0.453]

6 · Три методи поруч¶

Зведімо всі три оцінки в одну таблицю й подивімось на верхівку кожного списку.

In [12]:
рейтинг = оголошення[["модель", "рік", "стан", "памʼять_гб", "вік_акаунта",
                      "ціна", "типова_ціна"]].copy()
рейтинг["ліс"] = ранг_лісу
рейтинг["LOF"] = ранг_лоф
рейтинг["відновлення"] = ранг_відновлення

print("топ-8 за LOF:")
print(рейтинг.sort_values("LOF").head(8).to_string())
print()
print("топ-8 за Isolation Forest:")
print(рейтинг.sort_values("ліс").head(8).to_string())
топ-8 за LOF:
            модель   рік        стан  памʼять_гб  вік_акаунта     ціна  типова_ціна  ліс  LOF  відновлення
20         Gamma X  2017        нове         512           50  95000.0       6530.0    2    1            1
15         Gamma X  2017        нове         512          580  91000.0       6530.0    5    2            3
10         Gamma X  2017        нове         512           87  88000.0       6530.0    6    3            5
2          Gamma X  2017        нове         512           44  82000.0       6530.0    7    4            6
8    Gamma X Ultra  2017       добре         256          556  76000.0       6350.0   27    5            4
3      Beta 12 Pro  2021       добре         256          777  83500.0       7990.0   30    6            2
940        Alfa A7  2024  дуже добре         128         3519   7080.0       5860.0   48    7            7
280    Beta 12 Pro  2024       добре         512          708  52620.0      13255.0   17    8           22

топ-8 за Isolation Forest:
            модель   рік        стан  памʼять_гб  вік_акаунта     ціна  типова_ціна  ліс  LOF  відновлення
128  Gamma X Ultra  2024         NaN         512          878  33050.0      31915.0    1   26           35
20         Gamma X  2017        нове         512           50  95000.0       6530.0    2    1            1
91   Gamma X Ultra  2024        нове          64           36  32670.0      31915.0    3   49           38
874  Gamma X Ultra  2024  задовільне          64          831  16500.0      31915.0    4   41           31
15         Gamma X  2017        нове         512          580  91000.0       6530.0    5    2            3
10         Gamma X  2017        нове         512           87  88000.0       6530.0    6    3            5
2          Gamma X  2017        нове         512           44  82000.0       6530.0    7    4            6
811  Gamma X Ultra  2024  дуже добре         256          576  33550.0      31915.0    8  121          236
In [13]:
# наскільки методи згодні між собою: перетини топ-50
топ_лісу = set(np.argsort(-оцінка_лісу)[:50])
топ_лоф = set(np.argsort(-оцінка_лоф)[:50])
топ_відновлення = set(np.argsort(-оцінка_відновлення)[:50])

print("спільних у топ-50, ліс і LOF:         ", len(топ_лісу & топ_лоф))
print("спільних у топ-50, ліс і відновлення: ", len(топ_лісу & топ_відновлення))
print("спільних у топ-50, LOF і відновлення: ", len(топ_лоф & топ_відновлення))
print("спільних усім трьом:                  ", len(топ_лісу & топ_лоф & топ_відновлення))
print()
# скільки рядків знайшов лише цей метод і жоден інший
print("лише ліс знайшов:        ", len(топ_лісу - топ_лоф - топ_відновлення))
print("лише LOF знайшов:        ", len(топ_лоф - топ_лісу - топ_відновлення))
print("лише відновлення знайшло:", len(топ_відновлення - топ_лісу - топ_лоф))
спільних у топ-50, ліс і LOF:          21
спільних у топ-50, ліс і відновлення:  19
спільних у топ-50, LOF і відновлення:  30
спільних усім трьом:                   19

лише ліс знайшов:         29
лише LOF знайшов:         18
лише відновлення знайшло: 20

7 · Момент істини: precision@20¶

Тепер дістаємо колонку шахрайське, якої жоден метод не бачив, і рахуємо, скільки справжніх шахрайських оголошень у перших двадцяти рядках кожного списку.

Порівнювати треба з 12.6 % — стільки дав би випадковий вибір двадцяти рядків.

In [14]:
def precision_at_k(оцінка, k=20):
    """Частка справді шахрайських серед k найаномальніших за цією оцінкою."""
    топ = np.argsort(-оцінка)[:k]
    return шахрайське_насправді[топ].sum(), шахрайське_насправді[топ].mean()

методи = [("LOF", оцінка_лоф),
          ("Помилка відновлення", оцінка_відновлення),
          ("Isolation Forest", оцінка_лісу)]

рядки = []
for назва, оцінка in методи:
    знайдено, частка = precision_at_k(оцінка, 20)
    рядки.append({"метод": назва,
                  "шахрайських у топ-20": int(знайдено),
                  "precision@20": f"{частка * 100:.1f} %",
                  "проти випадковості": f"×{частка / шахрайське_насправді.mean():.1f}"})

print(pd.DataFrame(рядки).to_string(index=False))
print()
print("випадковий вибір 20 рядків дав би:",
      f"{шахрайське_насправді.mean() * 20:.1f} шахрайських із 20")
              метод  шахрайських у топ-20 precision@20 проти випадковості
                LOF                     9       45.0 %               ×3.6
Помилка відновлення                     2       10.0 %               ×0.8
   Isolation Forest                     1        5.0 %               ×0.4

випадковий вибір 20 рядків дав би: 2.5 шахрайських із 20
In [15]:
# що саме потрапило в перетин усіх трьох топ-20 — найупевненіші знахідки
топ20 = [set(np.argsort(-оцінка)[:20]) for _, оцінка in методи]
одностайні = sorted(топ20[0] & топ20[1] & топ20[2])

print("оголошення, які всі три методи назвали найдивнішими:")
print(оголошення.loc[одностайні,
                     ["модель", "рік", "стан", "памʼять_гб", "вік_акаунта",
                      "ціна", "типова_ціна", "шахрайське"]].to_string())
оголошення, які всі три методи назвали найдивнішими:
     модель   рік  стан  памʼять_гб  вік_акаунта     ціна  типова_ціна  шахрайське
2   Gamma X  2017  нове         512           44  82000.0       6530.0           0
10  Gamma X  2017  нове         512           87  88000.0       6530.0           0
15  Gamma X  2017  нове         512          580  91000.0       6530.0           0
20  Gamma X  2017  нове         512           50  95000.0       6530.0           0

Ось головний урок теми в одній таблиці. Три незалежні методи одностайно назвали найдивнішими рядками чотири абсолютно чесні оголошення: колекційні Gamma X 2017 року, запаковані, з максимальною памʼяттю. Вони справді ні на що не схожі — і саме тому їх знайшли. Аномалія й шахрайство — різні речі.

8 · Поріг — це рішення, а не обчислення¶

Оцінка є, відповіді немає. Скільки відсотків дошки позначити аномальними — вибір, що залежить від ціни перевірки й ціни пропуску. Подивімось, як міняються точність і повнота, коли ми рухаємо цей поріг.

In [16]:
рядки = []
for частка in [0.5, 1, 2, 3, 5, 10, 15, 20, 30]:
    k = int(round(len(оголошення) * частка / 100))
    топ = np.argsort(-оцінка_лоф)[:k]
    знайдено = int(шахрайське_насправді[топ].sum())
    рядки.append({
        "позначаємо, %": частка,
        "на перевірку": k,
        "знахідок": знайдено,
        "хибних тривог": k - знайдено,
        "точність, %": round(знайдено / k * 100, 1),
        "повнота, %": round(знайдено / шахрайське_насправді.sum() * 100, 1),
    })

ціна_порогу = pd.DataFrame(рядки)
print(ціна_порогу.to_string(index=False))
 позначаємо, %  на перевірку  знахідок  хибних тривог  точність, %  повнота, %
           0.5             6         0              6          0.0         0.0
           1.0            11         3              8         27.3         2.2
           2.0            22        11             11         50.0         7.9
           3.0            33        17             16         51.5        12.2
           5.0            55        22             33         40.0        15.8
          10.0           110        31             79         28.2        22.3
          15.0           165        37            128         22.4        26.6
          20.0           220        46            174         20.9        33.1
          30.0           330        59            271         17.9        42.4
In [17]:
# та сама таблиця картинкою: точність і повнота як функції порогу
частки = np.arange(0.5, 30.01, 0.5)
точності, повноти = [], []
for частка in частки:
    k = max(1, int(round(len(оголошення) * частка / 100)))
    топ = np.argsort(-оцінка_лоф)[:k]
    точності.append(шахрайське_насправді[топ].mean())
    повноти.append(шахрайське_насправді[топ].sum() / шахрайське_насправді.sum())

plt.figure(figsize=(8, 4))
plt.plot(частки, np.array(точності) * 100, label="точність (скільки з перевірених — шахраї)")
plt.plot(частки, np.array(повноти) * 100, label="повнота (скільки шахраїв спіймали)")
plt.axhline(шахрайське_насправді.mean() * 100, linestyle="--", linewidth=1,
            color="gray", label="навмання")
plt.xlabel("частка дошки, позначена аномальною, %")
plt.ylabel("%")
plt.title("Поріг і його ціна · оцінки LOF")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()

print("максимум точності:", f"{max(точності) * 100:.1f} %",
      "при частці", частки[int(np.argmax(точності))], "%")
No description has been provided for this image
максимум точності: 51.5 % при частці 3.0 %

Криві розходяться в різні боки, і це не вада методу, а сама суть задачі (тема 05). Розширюючи список на перевірку, ти неминуче знаходиш більше шахраїв і неминуче витрачаєш більше часу даремно. Правильного положення порогу не існує — воно є лише в конкретної задачі з її цінами.

9 · Що з цього виходить¶

  • Одновимірні правила позначають великі числа, а не дивні рядки: z-оцінка спрацювала гірше за випадковий вибір.
  • Багатовимірні методи бачать те, чого не бачить жодна колонка окремо — рядок, у якого всі сім z-оцінок менші за одиницю, LOF ставить у перші двадцять.
  • Методи згодні між собою лише частково: приблизно половина топ-50 у кожного своя. Запускати варто кілька.
  • Найупевненіші знахідки всіх трьох методів — чесні колекційні оголошення. Аномалія — це «не схоже на решту», а не «шкідливе».
  • Поріг не обчислюється, а вибирається з економіки задачі.

Завдання¶

🟢 Рівень 1¶

Постав n_neighbors у LocalOutlierFactor рівним 5, потім 50, потім 200 і щоразу порахуй precision@20. Побудуй таблицю з трьох рядків і поясни словами, що робить із методом замалий і завеликий k.

🟡 Рівень 2¶

Додай до семи ознак восьму — np.log(ціна / типова_ціна) — і перерахуй усі три методи. Порівняй precision@20 до й після. Поясни, чому саме ця ознака так змінює результат і чому жоден метод не міг придумати її сам.

🔴 Рівень 3¶

Реалізуй спрощений LOF самостійно, без scikit-learn: для кожного оголошення знайди 20 найближчих сусідів, візьми середню відстань до них і поділи на середню таку саму відстань у цих сусідів. Порівняй свій рейтинг із бібліотечним: скільки спільних рядків у топ-50? Поясни, звідки береться розбіжність (підказка: відстань досяжності з врізки в лекції).