Практика · Пошук аномалій¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Та сама дошка оголошень про вживані телефони, що й у
темі 08 та темі 13:
1 100 оголошень із відомою ціною й сім числових ознак. Колонка шахрайське
в таблиці є, але жоден метод її не побачить — дістанемо її аж наприкінці,
щоб перевірити, що саме ми знайшли.
Що ми зробимо:
- зберемо ту саму таблицю й сім числових ознак;
- застосуємо одновимірні правила — z-оцінку й міжквартильний розмах — і порахуємо, скільки шахрайських оголошень вони ловлять;
- знайдемо оголошення, нормальне за кожною ознакою окремо й аномальне разом;
- порахуємо три багатовимірні методи:
IsolationForest,LocalOutlierFactorі помилку відновлення після PCA — і зведемо їхні оцінки в одну таблицю; - перевіримо помилку відновлення вручну на NumPy проти бібліотечної;
- подивимось, наскільки методи згодні між собою;
- візьмемо топ-20 кожного й порахуємо precision@20 проти
шахрайське; - прогонимо поріг від 0.5 % до 30 % і побачимо, як точність міняється на повноту.
Зерно генератора зафіксовано (np.random.default_rng(42)), тож числа збігатимуться
з лекцією до цифри.
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
кількість = 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.]
# шахрай тим імовірніший, чим молодший акаунт; ціну він або занижує (приманка),
# або завищує під велику передоплату
шанс_шахрайства = 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
# ті самі шість неприємностей із теми 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.
дошка["памʼять_гб"] = 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 %: саме стільки дало б тикання навмання.
# межі 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
# «позначене» = вилетіло хоч за одним правилом хоч в одній колонці
позначене_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 · Оголошення, нормальне за кожною ознакою окремо¶
Тепер знайдімо рядки, які жодне одновимірне правило не позначило, і подивімось, чи є серед них дивні. Спершу порахуємо багатовимірні оцінки, а потім відфільтруємо.
# 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
# ранг 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
# розглянемо переможця цього списку повністю
підозріле = таблиця.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 й переконаймось, що числа збігаються.
Стиснення — це проєкція центрованих даних на матрицю компонент; відновлення — множення назад і додавання середнього.
# компоненти 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 · Три методи поруч¶
Зведімо всі три оцінки в одну таблицю й подивімось на верхівку кожного списку.
рейтинг = оголошення[["модель", "рік", "стан", "памʼять_гб", "вік_акаунта",
"ціна", "типова_ціна"]].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
# наскільки методи згодні між собою: перетини топ-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 % — стільки дав би випадковий вибір двадцяти рядків.
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
# що саме потрапило в перетин усіх трьох топ-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 · Поріг — це рішення, а не обчислення¶
Оцінка є, відповіді немає. Скільки відсотків дошки позначити аномальними — вибір, що залежить від ціни перевірки й ціни пропуску. Подивімось, як міняються точність і повнота, коли ми рухаємо цей поріг.
рядки = []
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
# та сама таблиця картинкою: точність і повнота як функції порогу
частки = 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(точності))], "%")
максимум точності: 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? Поясни, звідки береться розбіжність
(підказка: відстань досяжності з врізки в лекції).