Към съдържанието
wannacode

Оценка: точността лъже

Накратко: Моделът се оценява на данни, които не е виждал: матрицата на грешките брои четирите изхода, а precision и recall казват повече от точността.

Проблем

99% точност, нула хванати измами

Колега показва модела си за измами: „Точност 99%!“ Звучи отлично. Ето модел, който постига същото, без да прави нищо:

frauds = sum(fraud)
# „винаги нормално“: никога не спира, затова е прав за всяко честно плащане
accuracy = 100 * (4000 - frauds) / 4000
print(f"плащания: 4000, измами: {frauds}")
print(f"„винаги нормално“: точност {accuracy:.2f}%, хванати измами: 0 от {frauds}")
Изход
плащания: 4000, измами: 40
„винаги нормално“: точност 99.00%, хванати измами: 0 от 40

Това е един месец с 4000 плащания, а измамите са 40: едно на сто. Моделът „винаги нормално“ не спира нищо. За 3960 честни плащания е прав, за 40 измами греши. Точност 99,00%, хванати измами: нула.

Точността е делът на верните отговори. Когато единият клас е 99%, моделът, който винаги казва него, е 99% точен. Числото е вярно, но не отговаря на въпроса на магазина: „Колко измами хващаме и колко честни клиенти ядосваме?“

Виж го

Месецът в код

Истинските плащания идват от базата на магазина. Тук месецът е направен по правило, за да е малък и еднакъв на всеки език. Всяко плащане е измама с вероятност 1%. Измамите обикновено са 3× до 12× обичайната сума, а понякога са малка проба (0,3× до 1,3×). Честните обикновено са 0,2× до 2,2×, а понякога са голяма покупка до 6×.

Случайните числа са от малък генератор със семе (LCG): едно и също семе дава едни и същи числа на всеки език и при всяко пускане.

# генератор със семе: еднакви числа на всеки език
class Lcg:
def __init__(self, seed):
self.state = seed
def next(self):
self.state = (self.state * 1664525 + 1013904223) % 4294967296
return self.state / 4294967296
# един месец плащания: около 1 на 100 е измама
rng = Lcg(319)
ratios, fraud = [], []
for i in range(4000):
is_fraud = rng.next() < 0.01
if is_fraud: # измама: обикновено 3× до 12×, понякога малка проба
ratio = 3 + 9 * rng.next() if rng.next() < 0.85 else 0.3 + rng.next()
else: # честно: обикновено 0.2× до 2.2×, понякога голяма покупка
ratio = 2.5 + 3.5 * rng.next() if rng.next() < 0.03 else 0.2 + rng.next() + rng.next()
ratios.append(ratio)
fraud.append(1 if is_fraud else 0)

Признакът е като в урока за класификацията: сумата спрямо обичайната за клиента.

Идеята

Обучение и тест

Преди да мерим каквото и да е, има по-важен въпрос: върху кои данни мерим. Ето най-простият „модел“: помни сумите на измамите, които е видял, и спира само тях. Месецът е разбъркан и разделен: 70% за обучение, 30% за проверка.

# разбъркване (Fisher–Yates) със същия генератор
order = list(range(4000))
for i in range(3999, 0, -1):
j = int(rng.next() * (i + 1))
order[i], order[j] = order[j], order[i]
train = order[:2800]
test = order[2800:]
print(f"обучение: {len(train)} плащания, {count_frauds(fraud, train)} измами")
print(f"тест: {len(test)} плащания, {count_frauds(fraud, test)} измами")
# помни сумите на измамите, които е видял
seen = {ratios[i] for i in train if fraud[i] == 1}
report("наизустяване върху обучението", seen, ratios, fraud, train)
report("наизустяване върху теста", seen, ratios, fraud, test)
Изход
обучение: 2800 плащания, 24 измами
тест: 1200 плащания, 16 измами
наизустяване върху обучението: точност 100.00%, хванати 24 от 24
наизустяване върху теста: точност 98.67%, хванати 0 от 16

Върху плащанията, от които се е учил, моделът е перфектен: 100%, 24 от 24. Върху нови не хваща нито една измама, защото нова измама никога не е със сума, точно равна на стара. Моделът не е научил нищо за измамите, само ги е запомнил.

Затова данните се делят на две още преди обучението:

  • данни за обучение (training set): от тях моделът се учи;
  • тестови данни (test set): с тях се мери накрая, и моделът не ги вижда до този момент.

Тестът замества утрешните плащания. Разбъркването преди деленето е важно: ако месецът е подреден по дата, тестът ще е само последната седмица.

Виж го

Матрицата на грешките

Сега истинският модел: логистичната регресия от урока за класификацията. Функцията е същата, само че получава и rows: кои редове да ползва. Учи се само от обучението:

# учи се само от редовете rows
def train_logistic(xs, ys, rows, rate, epochs):
w, b = 0.0, 0.0
n = len(rows)
for epoch in range(epochs):
gw, gb = 0.0, 0.0
for i in rows:
p = sigmoid(w * xs[i] + b)
gw += (p - ys[i]) * xs[i]
gb += p - ys[i]
w -= rate * gw / n
b -= rate * gb / n
return w, b

Плащанията от теста могат да свършат по четири начина. Моделът спира или пуска, а плащането е измама или честно:

моделът спира моделът пуска
измама спряна измама (TP) пропусната измама (FN)
честно спрян честен (FP) пуснат честен (TN)

Това е матрицата на грешките (confusion matrix). Буквите са от английски: true/false positive/negative. „Положителен“ е класът, който търсим: измамата.

def confusion(ratios, fraud, rows, w, b, threshold):
tp, fp, fn, tn = 0, 0, 0, 0
for i in rows:
stop = block(ratios[i], w, b, threshold)
if stop and fraud[i] == 1:
tp += 1 # спряна измама
elif stop:
fp += 1 # спрян честен клиент
elif fraud[i] == 1:
fn += 1 # пропусната измама
else:
tn += 1 # пуснат честен клиент
return tp, fp, fn, tn
w, b = train_logistic(ratios, fraud, train, 1, 1000)
tp, fp, fn, tn = confusion(ratios, fraud, test,
w, b, 0.5)
accuracy = 100 * (tp + tn) / len(test)
precision = 100 * tp / (tp + fp)
recall = 100 * tp / (tp + fn)
print(f"w = {w:.4f}, b = {b:.4f}")
print(f"спрени измами (TP): {tp}")
print(f"спрени честни (FP): {fp}")
print(f"пропуснати измами (FN): {fn}")
print(f"пуснати честни (TN): {tn}")
print(f"точност {accuracy:.2f}%, precision {precision:.2f}%, recall {recall:.2f}%")
Изход
w = 1.0702, b = -7.1054
спрени измами (TP): 7
спрени честни (FP): 0
пропуснати измами (FN): 9
пуснати честни (TN): 1184
точност 99.25%, precision 100.00%, recall 43.75%

Точността е 99,25%, малко над 98,67% на „винаги нормално“ върху същия тест. А моделът хваща 7 от 16 измами и не спира нито един честен клиент. Двете точности почти не се различават, а моделите са съвсем различни. Нужни са числа, които гледат измамите.

Виж го

Матрицата се пълни

def confusion(ratios, fraud, rows, w, b, threshold):
tp, fp, fn, tn = 0, 0, 0, 0
for i in rows:
stop = block(ratios[i], w, b, threshold)
if stop and fraud[i] == 1:
tp += 1 # спряна измама
elif stop:
fp += 1 # спрян честен клиент
elif fraud[i] == 1:
fn += 1 # пропусната измама
else:
tn += 1 # пуснат честен клиент
return tp, fp, fn, tn
Стъпка по стъпка, с думи
  1. 1200 плащания от теста, които моделът не е виждал, праг 0,5. Редът е истината, колоната е какво каза моделът.
  2. Първо 16-те измами, една по една: 7 спрени (TP), 9 пропуснати (FN).
  3. После честните: 0 спрени (FP), 1184 пуснати (TN). Почти всичко е в един ъгъл.
  4. Точността е диагоналът: (7 + 1184) / 1200 = 99,25%. „Винаги нормално“ е 98,67% на същия тест.
  5. Precision е колоната „спри“: от спрените колко са измами? 7 / 7 = 100,00%.
  6. Recall е редът „измама“: от измамите колко хванахме? 7 / 16 = 43,75%.
  7. Точността гледа всичко и е висока. Precision и recall гледат измамите: всяко спиране е измама, но хващаме по-малко от половината.

1184 от 1200 плащания са в долния десен ъгъл: пуснати честни. Затова точността е висока при всеки модел, който пуска честните. Интересното е в горния ред.

Идеята

Precision и recall с думите на магазина

Два въпроса, две числа:

  • Precision: от спрените плащания колко наистина са измами? TP / (TP + FP). Ниска precision значи много ядосани честни клиенти.
  • Recall: от всички измами колко хванахме? TP / (TP + FN). Нисък recall значи пропуснати измами и загубени пари.

Моделът при праг 0,5 е с precision 100% и recall 43,75%. Всяко спиране е измама, но хваща по-малко от половината. При „винаги нормално“ recall е 0%, и това се вижда веднага, без да се лъжем с 99%.

Двете се дърпат. За да хванеш повече измами (recall), спираш повече съмнителни плащания, а сред тях има и честни (precision пада).

Пробвай

Попълни

Precision дели на всички спрени. На какво дели recall?

precision = 100 * tp / (tp + fp)
recall = 100 * tp / (tp + )

Пробвай

Предвиди изхода

Десет плащания: истината (1 = измама) и какво е спрял моделът (1 = спрян). Какво ще отпечата?

actual = [1, 0, 0, 1, 0, 0, 0, 1, 0, 0]
blocked = [1, 1, 0, 0, 0, 1, 0, 1, 0, 0]
tp, fp, fn = 0, 0, 0
for said, real in zip(blocked, actual):
if said == 1 and real == 1:
tp += 1
elif said == 1:
fp += 1
elif real == 1:
fn += 1
print(f"precision {100 * tp / (tp + fp):.2f}%, recall {100 * tp / (tp + fn):.2f}%")
Отговор

Идеята

Прагът и цената в евро

Прагът от урока за класификацията сега има числа. Същият модел върху същия тест, при четири прага:

for threshold in [0.5, 0.3, 0.1, 0.02]:
tp, fp, fn, _ = confusion(ratios, fraud, test, w, b, threshold)
precision = 100 * tp / (tp + fp)
recall = 100 * tp / (tp + fn)
print(f"праг {threshold:.2f}: TP {tp}, FP {fp}, FN {fn} → precision {precision:.2f}%, recall {recall:.2f}%")
Изход
праг 0.50: TP 7, FP 0, FN 9 → precision 100.00%, recall 43.75%
праг 0.30: TP 10, FP 1, FN 6 → precision 90.91%, recall 62.50%
праг 0.10: TP 11, FP 13, FN 5 → precision 45.83%, recall 68.75%
праг 0.02: TP 14, FP 28, FN 2 → precision 33.33%, recall 87.50%

Колкото по-нисък е прагът, толкова повече измами хващаме (recall расте от 43,75% до 87,5%) и толкова повече честни спираме (precision пада от 100% до 33,33%). Кое е по-добре? Отговорът е в евро. Нека пропусната измама струва 250 € (стоката и таксата за върнатото плащане), а спрян честен клиент 10 € (обаждане до поддръжката и изгубено време). Цените са примерни:

# примерни цени: пропусната измама и спрян честен клиент
missed_cost, blocked_cost = 250, 10
best, best_cost = None, None
for threshold in [0.5, 0.3, 0.1, 0.02]:
_, fp, fn, _ = confusion(ratios, fraud, test, w, b, threshold)
cost = fn * missed_cost + fp * blocked_cost
print(f"праг {threshold:.2f}: {fn} × {missed_cost} € + {fp} × {blocked_cost} € = {cost} €")
if best_cost is None or cost < best_cost:
best, best_cost = threshold, cost
print(f"най-евтин праг: {best:.2f}")
Изход
праг 0.50: 9 × 250 € + 0 × 10 € = 2250 €
праг 0.30: 6 × 250 € + 1 × 10 € = 1510 €
праг 0.10: 5 × 250 € + 13 × 10 € = 1380 €
праг 0.02: 2 × 250 € + 28 × 10 € = 780 €
най-евтин праг: 0.02

С тези цени най-евтин е най-строгият праг: 0,02, с 780 € вместо 2250 € при 0,5. Една пропусната измама струва колкото 25 спрени клиенти, затова си струва да спираме по-често. Ако спреният клиент си тръгва завинаги и струва много повече, отговорът е друг. Това е упражнението накрая.

Едно предупреждение. Тук прагът е избран с теста. Така тестът вече участва в решение и цената от 780 € е малко по-оптимистична от истината. В истински проект прагът се избира с трети дял от данните, валидацията, а тестът се пуска веднъж, накрая. Как, е в следващия урок.

Пробвай

Намери грешката

Колега дели данните така. Числата на теста му са отлични, а в магазина моделът хваща много по-малко. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога вреди

  • Точност при неравни класове. Измами, болести, повреди, спам: щом търсеното е рядко, точността е почти безполезна. Гледай матрицата, precision и recall.
  • Тест, който прилича на обучението повече от бъдещето. Измамниците сменят похватите си. Ако тестът е разбъркан с минали месеци, той е по-лесен от следващия месец. За такива данни тестът е по-новият период: учиш се до 30 септември, мериш октомври.
  • Малко примери на рядкото. В теста има 16 измами. Една измама повече или по-малко мести recall с 6,25%. Числата тук са за урока, а истинските екипи събират хиляди измами, преди да вярват на втория знак.
  • Едно число за всичко. Precision и recall са две, защото грешките са две и струват различно. Ако ги смачкаш в едно, губиш решението за цената.
  • Тестът, пипан много пъти. Всеки път, когато сменяш модела, защото тестът е казал „лошо“, тестът става малко обучение.
По-точно, за любопитните

F1. Хармоничното средно на precision и recall: 2 · P · R / (P + R). Едно число, което пада, ако някое от двете е ниско. Полезно е за сравнение, но не знае цената на грешките.

Кривата. Ако пуснеш прага от 1 до 0 и рисуваш precision срещу recall, получаваш PR кривата. Площта под нея (PR AUC) мери модела без конкретен праг. Сродната ROC крива (дял хванати измами срещу дял спрени честни) е по-известна, но при 1 на 100 изглежда по-добре, отколкото е, защото честните са толкова много.

Прагът от цената. Ако вероятностите на модела са верни, най-евтиният праг е цената на спрения делено на сбора на двете цени: 10 / (10 + 250) ≈ 0,04. Програмата намери 0,02 от четирите прага, които пробва. Моделът тук е учен на 1% измами, затова вероятностите му са близо до истинските. Модел, научен на 40% измами (като в урока за класификацията), вижда измама твърде често и прагът му трябва да е по-висок.

Разделяне по класове. При 40 измами случайното делене може да прати 30 в обучението и 10 в теста, или обратното. Стратифицирано делене пази един и същ дял измами в двете части.

В истинския код. В scikit-learn са train_test_split (с stratify= за класовете), confusion_matrix, precision_score, recall_score и precision_recall_curve. В ML.NET TrainTestSplit и BinaryClassificationMetrics дават същото. Самото следене на модела в продукция (дали recall не пада с месеците) е част от MLOps и се учи в ZeroToOps.

{ratios[i] for i in train if fraud[i] == 1} е множество (set), направено с генератор: само сумите на измамите от обучението.

Упражнение

Скъпият честен клиент

Реши задачата в редактора си.

Магазинът продава абонаменти. Клиент, спрян по погрешка, често си отива при конкурента, затова спреният честен клиент струва 60 €, а пропусната измама пак 250 €. Пусни програмата с цената със 60 € и намери най-евтиния праг.

Изход
праг 0.50: 9 × 250 € + 0 × 60 € = 2250 €
праг 0.30: 6 × 250 € + 1 × 60 € = 1560 €
праг 0.10: 5 × 250 € + 13 × 60 € = 2030 €
праг 0.02: 2 × 250 € + 28 × 60 € = 2180 €
най-евтин праг: 0.30

Сега най-евтин е 0,3: спира 10 от 16 измами и само 1 честен клиент. Моделът е същият, данните са същите, а прагът е друг, защото грешките струват друго.

Още задачи

  • По-гъста мрежа от прагове. Пробвай праговете от 0,02 до 0,5 през 0,02. Има ли по-евтин праг от 0,3?
  • Тест по време. Вместо разбъркване вземи първите 2800 плащания за обучение и последните 1200 за тест. Колко измами има в теста сега и как се промени recall при праг 0,3?

Тест

Провери се

1. Измамите са 1 на 100. Модел казва „нормално“ за всяко плащане. Каква е точността му?
2. Защо моделът се проверява на данни, които не е виждал при обучението?
3. Precision е 90%. Какво значи това за магазина?
4. Пропусната измама струва 250 €, а спрян честен клиент 10 €. Накъде да мръднем прага?
5. Колега избира прага по най-ниската цена върху теста и докладва тази цена. Какво не е наред?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·