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

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

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

Проблем

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

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

int frauds = 0;
for (int y : fraud) frauds += y;
// „винаги нормално“: никога не спира, затова е прав за всяко честно плащане
double accuracy = 100.0 * (4000 - frauds) / 4000;
System.out.printf("плащания: 4000, измами: %d%n", frauds);
System.out.printf("„винаги нормално“: точност %.2f%%, хванати измами: 0 от %d%n", accuracy, 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 {
private long state;
Lcg(long seed) {
state = seed;
}
double next() {
state = (state * 1664525 + 1013904223) % 4294967296L;
return state / 4294967296.0;
}
}
// един месец плащания: около 1 на 100 е измама
Lcg rng = new Lcg(319);
double[] ratios = new double[4000];
int[] fraud = new int[4000];
for (int i = 0; i < 4000; i++) {
boolean isFraud = rng.next() < 0.01;
if (isFraud) // измама: обикновено 3× до 12×, понякога малка проба
ratios[i] = rng.next() < 0.85 ? 3 + 9 * rng.next() : 0.3 + rng.next();
else // честно: обикновено 0.2× до 2.2×, понякога голяма покупка
ratios[i] = rng.next() < 0.03 ? 2.5 + 3.5 * rng.next() : 0.2 + rng.next() + rng.next();
fraud[i] = isFraud ? 1 : 0;
}
4294967296L е 2³². Умножението се събира в long, затова резултатът е еднакъв с другите езици.

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

Идеята

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

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

// разбъркване (Fisher–Yates) със същия генератор
int[] order = new int[4000];
for (int i = 0; i < 4000; i++) order[i] = i;
for (int i = 3999; i > 0; i--) {
int j = (int) (rng.next() * (i + 1));
int t = order[i];
order[i] = order[j];
order[j] = t;
}
int[] train = Arrays.copyOfRange(order, 0, 2800);
int[] test = Arrays.copyOfRange(order, 2800, 4000);
System.out.printf("обучение: %d плащания, %d измами%n", train.length, countFrauds(fraud, train));
System.out.printf("тест: %d плащания, %d измами%n", test.length, countFrauds(fraud, test));
// помни сумите на измамите, които е видял
Set<Double> seen = new HashSet<>();
for (int i : train) if (fraud[i] == 1) seen.add(ratios[i]);
report("наизустяване върху обучението", seen, ratios, fraud, train);
report("наизустяване върху теста", seen, ratios, fraud, test);
Изход
обучение: 2800 плащания, 24 измами
тест: 1200 плащания, 16 измами
наизустяване върху обучението: точност 100.00%, хванати 24 от 24
наизустяване върху теста: точност 98.67%, хванати 0 от 16
Arrays.copyOfRange(order, 0, 2800) прави нов масив с редове 0 до 2799. HashSet и Set са от java.util.

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

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

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

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

Виж го

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

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

// учи се само от редовете rows
static double[] trainLogistic(double[] xs, int[] ys, int[] rows, double rate, int epochs) {
double w = 0, b = 0;
int n = rows.length;
for (int epoch = 0; epoch < epochs; epoch++) {
double gw = 0, gb = 0;
for (int i : rows) {
double 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 new double[] {w, b};
}

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

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

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

static int[] confusion(double[] ratios, int[] fraud,
int[] rows, double w, double b,
double threshold) {
int tp = 0, fp = 0, fn = 0, tn = 0;
for (int i : rows) {
boolean stop = block(ratios[i], w, b, threshold);
if (stop && fraud[i] == 1) tp++; // спряна измама
else if (stop) fp++; // спрян честен клиент
else if (fraud[i] == 1) fn++; // пропусната измама
else tn++; // пуснат честен клиент
}
return new int[] {tp, fp, fn, tn};
}
double[] m = trainLogistic(ratios, fraud, train, 1, 1000);
double w = m[0], b = m[1];
int[] c = confusion(ratios, fraud, test, w, b, 0.5);
int tp = c[0], fp = c[1], fn = c[2], tn = c[3];
double accuracy = 100.0 * (tp + tn) / test.length;
double precision = 100.0 * tp / (tp + fp);
double recall = 100.0 * tp / (tp + fn);
System.out.printf("w = %.4f, b = %.4f%n", w, b);
System.out.println("спрени измами (TP): " + tp);
System.out.println("спрени честни (FP): " + fp);
System.out.println("пропуснати измами (FN): " + fn);
System.out.println("пуснати честни (TN): " + tn);
System.out.printf("точност %.2f%%, precision %.2f%%, recall %.2f%%%n", accuracy, precision, recall);
Изход
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 измами и не спира нито един честен клиент. Двете точности почти не се различават, а моделите са съвсем различни. Нужни са числа, които гледат измамите.

Виж го

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

static int[] confusion(double[] ratios, int[] fraud,
int[] rows, double w, double b,
double threshold) {
int tp = 0, fp = 0, fn = 0, tn = 0;
for (int i : rows) {
boolean stop = block(ratios[i], w, b, threshold);
if (stop && fraud[i] == 1) tp++; // спряна измама
else if (stop) fp++; // спрян честен клиент
else if (fraud[i] == 1) fn++; // пропусната измама
else tn++; // пуснат честен клиент
}
return new int[] {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?

double precision = 100.0 * tp / (tp + fp);
double recall = 100.0 * tp / (tp + );

Пробвай

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

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

int[] actual = {1, 0, 0, 1, 0, 0, 0, 1, 0, 0};
int[] blocked = {1, 1, 0, 0, 0, 1, 0, 1, 0, 0};
int tp = 0, fp = 0, fn = 0;
for (int i = 0; i < actual.length; i++) {
if (blocked[i] == 1 && actual[i] == 1) tp++;
else if (blocked[i] == 1) fp++;
else if (actual[i] == 1) fn++;
}
System.out.printf("precision %.2f%%, recall %.2f%%%n",
100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));
Отговор

Идеята

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

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

for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) {
int[] c = confusion(ratios, fraud, test, w, b, threshold);
int tp = c[0], fp = c[1], fn = c[2];
System.out.printf("праг %.2f: TP %d, FP %d, FN %d → precision %.2f%%, recall %.2f%%%n",
threshold, tp, fp, fn, 100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));
}
Изход
праг 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 € (обаждане до поддръжката и изгубено време). Цените са примерни:

// примерни цени: пропусната измама и спрян честен клиент
int missedCost = 250, blockedCost = 10;
double best = -1;
int bestCost = Integer.MAX_VALUE;
for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) {
int[] c = confusion(ratios, fraud, test, w, b, threshold);
int cost = c[2] * missedCost + c[1] * blockedCost;
System.out.printf("праг %.2f: %d × %d € + %d × %d € = %d €%n",
threshold, c[2], missedCost, c[1], blockedCost, cost);
if (cost < bestCost) {
bestCost = cost;
best = threshold;
}
}
System.out.printf("най-евтин праг: %.2f%n", best);
Изход
праг 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.

printf с %% пише самия знак %. Форматът зависи от Locale: с българска ще видиш запетая.

Упражнение

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

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

Магазинът продава абонаменти. Клиент, спрян по погрешка, често си отива при конкурента, затова спреният честен клиент струва 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 за достъпно обучение по програмиране ·