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

Класификация: измама или не

Накратко: Двоичната класификация отговаря с „да“ или „не“: логистичната регресия свива числото със сигмоида до вероятност между 0 и 1, а прагът решава.

Проблем

Измама или не

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

Тук отговорът не е число като времето за доставка, а „измама“ или „не“. Имаме 20 минали плащания. Признакът е сумата спрямо обичайната за клиента: 3.0 значи „три пъти повече от обикновено“. Етикетът е 1 за измама и 0 за честно плащане:

// сумата спрямо обичайната за клиента: 3.0 е три пъти повече
double[] ratios = {0.4, 0.6, 0.7, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 2.0, 2.6, 3.2,
0.8, 3.6, 4.1, 5.0, 6.0, 7.5, 8.2, 9.0};
// 1 = измама, 0 = честно плащане
int[] fraud = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1};

Първата идея е правата от линейната регресия: етикетите са 0 и 1, а над 0,5 спираме. Научена със същото спускане:

double[] line = trainLine(ratios, fraud, 0.01, 5000);
System.out.printf("права, плащане 0.4×: %.2f%n", line[0] * 0.4 + line[1]);
System.out.printf("права, плащане 9.0×: %.2f%n", line[0] * 9.0 + line[1]);
// малка стъпка: 40× е голямо число
double[] ratios2 = Arrays.copyOf(ratios, ratios.length + 1);
ratios2[ratios.length] = 40.0;
int[] fraud2 = Arrays.copyOf(fraud, fraud.length + 1);
fraud2[fraud.length] = 1;
double[] moved = trainLine(ratios2, fraud2, 0.01, 5000);
report("права", line, ratios2, fraud2);
report("права с плащането 40×", moved, ratios2, fraud2);
Изход
права, плащане 0.4×: 0.03
права, плащане 9.0×: 1.24
права: 0.5 при 3.75×, пропуснати измами: 2 от 9
права с плащането 40×: 0.5 при 7.31×, пропуснати измами: 5 от 9

Правата казва 1,24 за плащане 9×. Това не е вероятност: „124% измама“ няма смисъл. По-лошото е долу. Пристига една измама от 40× обичайната сума. Тя е очевидна, а накланя правата толкова, че границата ѝ отива от 3,75× на 7,31×. Сега правата пропуска 5 от 9 измами, вместо 2. Една ясна измама направи модела по-лош за всички останали.

Идеята

Отговорът е клас

Когато моделът избира между няколко отговора („измама“ или „честно“, „лош“ или „добър“ отзив), задачата се казва класификация, а отговорите са класове. При линейната регресия отговорът беше число на права линия. Тук искаме друго: число между 0 и 1, което казва колко вероятна е измамата. После решаваме с него.

Нужни са две неща:

  • функция, която свива всяко число между 0 и 1, без да реже;
  • граница, над която спираме плащането.

Идеята

Сигмоидата

Сигмоидата е sigmoid(z) = 1 / (1 + e−z). Голямо z я прави почти 1, голямо отрицателно z я прави почти 0, а z = 0 дава точно 0,5:

static double sigmoid(double z) {
return 1 / (1 + Math.exp(-z));
}
for (int z = -4; z <= 4; z += 2) {
System.out.printf("z = %d: %.2f%n", z, sigmoid(z));
}
Изход
z = -4: 0.02
z = -2: 0.12
z = 0: 0.50
z = 2: 0.88
z = 4: 0.98

Колкото и голямо да е z, резултатът не минава 1. Затова плащането от 40× не може да „дръпне“ кривата като правата: за него сигмоидата вече казва почти 1 и няма какво повече да поправя.

Виж го

Логистична регресия

Моделът е същата права, но пусната през сигмоидата. Първо смята оценка (score): w · x + b, като в линейната регресия. После сигмоидата я прави вероятност p. Накрая прагът решава: p ≥ 0,5 значи „спри“.

static double sigmoid(double z) {
return 1 / (1 + Math.exp(-z));
}
static boolean block(double ratio, double w, double b,
double threshold) {
double score = w * ratio + b;
double p = sigmoid(score);
return p >= threshold;
}

Това е логистична регресия. Името е подвеждащо: тя не предсказва число, а клас. „Регресия“ е заради правата вътре.

С научените w и b (как, е в следващата карта) за три нови плащания:

double[] m = trainLogistic(ratios, fraud, 0.1, 3000);
double w = m[0], b = m[1];
for (double ratio : new double[] {1.0, 3.0, 5.0}) {
double p = sigmoid(w * ratio + b);
String verdict = block(ratio, w, b, 0.5) ? "спри" : "пропусни";
System.out.printf("%.1f×: вероятност за измама %.2f → %s%n", ratio, p, verdict);
}
Изход
1.0×: вероятност за измама 0.06 → пропусни
3.0×: вероятност за измама 0.46 → пропусни
5.0×: вероятност за измама 0.91 → спри

Плащане 3× е 0,46: подозрително, но под прага. Числото между 0 и 1 е полезно и само по себе си. Магазинът може да пусне 0,46 без въпроси, да поиска потвърждение от банката (3-D Secure) при 0,6 и да спре 0,95.

Виж го

Учи се със същото спускане

Как се намират w и b? Точно както в урока за градиентното спускане: започваме от нула, смятаме колко греши моделът за всяко плащане и местим теглата малко срещу наклона. Ето обучението на правата от проблема и до него логистичното:

static double[] trainLine(double[] xs, int[] ys, double rate, int epochs) {
double w = 0, b = 0;
int n = xs.length;
for (int epoch = 0; epoch < epochs; epoch++) {
double gw = 0, gb = 0;
for (int i = 0; i < n; i++) {
double p = 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};
}
static double[] trainLogistic(double[] xs, int[] ys, double rate, int epochs) {
double w = 0, b = 0;
int n = xs.length;
for (int epoch = 0; epoch < epochs; epoch++) {
double gw = 0, gb = 0;
for (int i = 0; i < n; i++) {
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};
}

Различен е само един ред: предсказанието минава през сигмоидата. Стъпката (p − y) · x е същата. Ако измамата е казала 0,9, грешката е малка и теглата почти не мърдат. Ако е казала 0,1, грешката е голяма и стъпката също.

За да видим, че модел се учи, мерим грешката му. При „да“ и „не“ мярката е log loss: колко изненадан е моделът от верния отговор. Защо не средната квадратна грешка, е в „По-точно“ накрая.

static double logLoss(double[] xs, int[] ys, double w, double b) {
double sum = 0;
for (int i = 0; i < xs.length; i++) {
double p = sigmoid(w * xs[i] + b);
sum -= ys[i] == 1 ? Math.log(p) : Math.log(1 - p);
}
return sum / xs.length;
}
for (int epochs : new int[] {10, 100, 1000, 3000}) {
double[] m = trainLogistic(ratios, fraud, 0.1, epochs);
System.out.printf("епоха %d: грешка %.4f, w = %.4f, b = %.4f%n",
epochs, logLoss(ratios, fraud, m[0], m[1]), m[0], m[1]);
}
Изход
епоха 10: грешка 0.5895, w = 0.2139, b = -0.1837
епоха 100: грешка 0.3899, w = 0.5246, b = -1.4580
епоха 1000: грешка 0.2906, w = 1.1696, b = -3.6490
епоха 3000: грешка 0.2897, w = 1.2624, b = -3.9459

Грешката пада и спира около 0,29. Теглата стигат w = 1,26 и b = −3,95. Сигмоидата минава 0,5, където оценката е 0, тоест при x = −b / w ≈ 3,13×. А плащането от 40× от проблема?

double[] ratios2 = Arrays.copyOf(ratios, ratios.length + 1);
ratios2[ratios.length] = 40.0;
int[] fraud2 = Arrays.copyOf(fraud, fraud.length + 1);
fraud2[fraud.length] = 1;
report("сигмоида", trainLogistic(ratios, fraud, 0.1, 3000), ratios2, fraud2);
report("сигмоида с плащането 40×", trainLogistic(ratios2, fraud2, 0.1, 3000), ratios2, fraud2);
Изход
сигмоида: 0.5 при 3.13×, пропуснати измами: 1 от 9
сигмоида с плащането 40×: 0.5 при 3.13×, пропуснати измами: 1 от 9

Границата не се мести. Пропусната остава само измамата от 0,8×: малко плащане, с което крадецът проверява дали картата работи. По сумата тя не се отличава от честните и никой праг по тази сума няма да я хване. Трябва друг признак (нова държава, ново устройство, нощ).

Виж го

Сигмоидата и прагът

static double sigmoid(double z) {
return 1 / (1 + Math.exp(-z));
}
static boolean block(double ratio, double w, double b,
double threshold) {
double score = w * ratio + b;
double p = sigmoid(score);
return p >= threshold;
}
Стъпка по стъпка, с думи
  1. 20-те плащания: отляво надясно е сумата спрямо обичайната. Измамите са горе (1), честните долу (0).
  2. Права през нулите и единиците: при 9× казва 1,24. Това не е вероятност.
  3. Сигмоидата огъва правата между 0 и 1. Тя минава 0,5, където оценката е 0: при 3,13×.
  4. Всяко плащане получава вероятност и сяда на кривата. Червените са измами, зелените са честни.
  5. Праг 0,5: над него спираме, под него пускаме. Пропуснати измами: 1, спрени честни клиенти: 1.
  6. Праг 0,3 е по-строг: пропуснати измами: 1, спрени честни клиенти: 2.
  7. Праг 0,7 е по-мек: пропуснати измами: 2, спрени честни клиенти: 0.
  8. Моделът дава вероятност, а прагът е решение: по-нисък хваща повече измами и спира повече честни клиенти.

Правата излиза над 1 и под 0. Сигмоидата остава между тях, а хоризонталната линия на прага реже плащанията на „спри“ и „пусни“. Когато прагът слезе, по-малко измами минават, но се спират и повече честни клиенти.

Пробвай

Попълни

Кой е единственият ред, който прави от обучението на правата логистична регресия?

for (int i = 0; i < n; i++) {
    double p = (w * xs[i] + b);
    gw += (p - ys[i]) * xs[i];
    gb += p - ys[i];
}

Пробвай

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

Моделът е с w = 1,26 и b = −3,95, а прагът е 0,5. Какво ще отпечата за плащания 1,5×, 3,0× и 3,5×?

double w = 1.26, b = -3.95;
for (double ratio : new double[] {1.5, 3.0, 3.5}) {
System.out.println(block(ratio, w, b, 0.5) ? "спри" : "пропусни");
}
Отговор

Идеята

Прагът е бизнес решение

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

for (double threshold : new double[] {0.3, 0.5, 0.7, 0.9}) {
int missed = 0, blocked = 0;
for (int i = 0; i < ratios.length; i++) {
boolean stop = block(ratios[i], w, b, threshold);
if (fraud[i] == 1 && !stop) missed++;
if (fraud[i] == 0 && stop) blocked++;
}
System.out.printf("праг %.1f: пропуснати измами %d, спрени честни %d%n",
threshold, missed, blocked);
}
Изход
праг 0.3: пропуснати измами 1, спрени честни 2
праг 0.5: пропуснати измами 1, спрени честни 1
праг 0.7: пропуснати измами 2, спрени честни 0
праг 0.9: пропуснати измами 3, спрени честни 0

Нисък праг е строг: при 0,3 пропускаме 1 измама, но спираме 2 честни клиенти. Висок праг е мек: при 0,9 не спираме нито един честен, но пропускаме 3 измами. Няма праг без грешки, защото измамите и честните плащания се застъпват.

Кой праг е верният? Зависи колко струва всяка грешка. Пропусната измама за 300 € струва 300 € и таксата. Спрян честен клиент струва един ядосан човек, обаждане до поддръжката и понякога изгубен клиент. Как се сравняват двете с числа, когато измамите са 1 на 100, е следващият урок.

Пробвай

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

Колега пише сигмоидата направо в block. Тестовете му са с измами и минават. В петък магазинът спира всяко плащане, и честните. На кой ред е грешката?

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

Идеята

Кога вреди

Логистичната регресия е добро първо решение: бърза е, лесно се обяснява („спряхме, защото сумата е 6 пъти обичайната“) и дава вероятност. Ето кога не стига или вреди:

  • Границата е една. Моделът спира над един праг по претеглена сума от признаците. Ако опасното е в средата (нито много малки, нито много големи суми), една граница не може да го отдели. Трябва друг признак или друг модел, например дърво или невронна мрежа (следващият модул).
  • Вероятността е толкова вярна, колкото данните. Тук измамите са 8 от 20, защото така имаме от какво да учим. В истинския поток са около 1 на 100. Модел, научен на 40% измами, вижда измама твърде често. Това е следващият урок.
  • Правило, което е закон, не се учи. „Не приемаме карти от държава под санкции“ е if, а не модел. Моделът е за това, което не можеш да напишеш като правило.
  • Признак, който не съществува в момента на решението. „Клиентът оспори плащането“ е чудесен признак за измама, но идва седмици след плащането. Ако е в данните за обучение, моделът изглежда отличен, а в момента на решението е сляп.
  • Решенията за хора. Кредит, работа, застраховка: там моделът трябва да е честен към групи хора и да може да се обясни. Това е отделна тема, и често закон.
По-точно, за любопитните

Защо log loss, а не MSE. Log loss за едно плащане е −log(p), ако е измама, и −log(1 − p), ако е честно. Ако моделът каже 0,01 за истинска измама, грешката е −log(0,01) ≈ 4,6. При MSE същата грешка е само (1 − 0,01)² ≈ 0,98. Log loss наказва увереното грешно много по-силно. А наклонът на log loss по w е точно (p − y) · x, затова кодът е толкова кратък. Със сигмоида и MSE наклонът има още един множител p · (1 − p), който става почти 0, когато моделът е уверен и греши: ученето спира точно когато трябва да е най-бързо.

Защо стъпките са различни. Правата е научена със стъпка 0,01, а логистичната регресия с 0,1. При правата грешката расте с x, а плащането от 40× дава огромен наклон: при стъпка 0,1 правата се разлита. При сигмоидата p − y е между −1 и 1, затова и голямо x не я разлюлява толкова.

Повече признаци. С няколко признака (сумата, нощ ли е, ново ли е устройството) оценката е w₁ · x₁ + w₂ · x₂ + w₃ · x₃ + b, а кодът е същият с масиви от тегла. Границата е права в равнина, или равнина в пространство: пак една.

Повече от два класа. За „добър / неутрален / лош“ има по една оценка за всеки клас, а softmax ги превръща във вероятности със сбор 1. Ще го срещнеш при езиковите модели: те избират следващия токен точно така, между десетки хиляди класове.

В истинския код. В scikit-learn това е LogisticRegression, в ML.NET е SdcaLogisticRegression, в Tribuo (Java) е LogisticRegressionTrainer. Вътре е същата идея с по-умно спускане и с регуляризация, която ще видиш два урока по-нататък.

String.format и printf пишат числото според Locale по подразбиране. С българска ще видиш 0,46.

Упражнение

Лош ли е отзивът

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

Под всеки продукт има отзиви. Магазинът иска лошите да стигат до поддръжката веднага. Признакът е колко думи от списък („счупен“, „върнах“, „закъсня“…) има в отзива. Имаш 18 отзива: броят на лошите думи е 0, 0, 1, 0, 2, 0, 1, 0, 1, 3, 2, 3, 4, 1, 5, 3, 2, 4, а етикетите (1 = лош отзив) са десет нули, после осем единици. Научи логистична регресия със стъпка 0,5 за 2000 епохи и отпечатай вероятността и решението (праг 0,5) за отзив с 0 до 5 лоши думи.

Изход
лоши думи: 0 → 0.05, добър
лоши думи: 1 → 0.20, добър
лоши думи: 2 → 0.52, лош
лоши думи: 3 → 0.83, лош
лоши думи: 4 → 0.96, лош
лоши думи: 5 → 0.99, лош

Отзив с 2 лоши думи е на ръба: 0,52. Там са и добрите отзиви, които казват „не е счупен, но закъсня“.

Още задачи

  • Различен праг. Поддръжката иска да вижда всичко съмнително. Кой праг пуска до нея и отзивите с 1 лоша дума?
  • Нов признак. Добави звездите (1 до 5) като втори признак: w₁ · думи + w₂ · звезди + b. Колко се смени вероятността при 2 лоши думи и 5 звезди?

Тест

Провери се

1. Защо права, научена върху етикети 0 и 1, е лош модел за измама?
2. Какво прави сигмоидата?
3. Кое е различно в обучението на логистичната регресия спрямо правата?
4. Банката сваля прага от 0,5 на 0,3. Какво става?
5. Кога логистичната регресия не е добрият инструмент?

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