Практика 40 · Інтерпретованість моделей¶

📖 Теорія: lecture.html у цій же теці · 📝 Домашнє: homework.md · 🧪 Тест: quiz.html

Ми беремо дошку оголошень про вживані телефони, навчаємо на ній випадковий ліс і далі весь час ставимо йому одне питання: чому?

Що зробимо:

  1. навчимо три моделі — логістичну, дерево й ліс — і побачимо ціну зрозумілості;
  2. порахуємо feature_importances_ власноруч із дерев і звіримо зі sklearn;
  3. додамо до таблиці випадкову колонку з великою кількістю значень і подивимось, куди вона потрапить у рейтингу MDI;
  4. порахуємо перестановкову важливість і побачимо, як вона ламається на колонці-близнюку;
  5. порахуємо значення Шеплі з нуля — перебором усіх перестановок — і перевіримо адитивність;
  6. побудуємо частинну залежність (PDP) руками, звіримо зі sklearn і подивимось на криві ICE, які вона усереднює;
  7. візьмемо чотири конкретні оголошення й побачимо, що локальні пояснення в них різні.

Зерно генератора зафіксоване: у тебе вийдуть точно ті самі числа, що в лекції.

In [1]:
import itertools
from math import factorial

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split
from sklearn.inspection import permutation_importance, partial_dependence
from sklearn.metrics import accuracy_score, roc_auc_score

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

pd.set_option("display.width", 130)
pd.set_option("display.max_columns", 14)
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 - рік)                # телефон дешевшає приблизно на 18 % за рік
коефіцієнт_стану = 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),
})
# порядковий номер стану: моделі потрібне число, а не слово
дошка["стан_бали"] = дошка["стан"].map(
    {"задовільне": 0, "добре": 1, "дуже добре": 2, "нове": 3})

print("шахрайських оголошень:", int(дошка["шахрайське"].sum()), "з", кількість,
      f"({дошка['шахрайське'].mean() * 100:.1f} %)")
print(дошка.head(3))
шахрайських оголошень: 196 з 1200 (16.3 %)
        модель   рік        стан  памʼять_гб  вік_акаунта  скарг     ціна  ціна_нового  шахрайське  стан_бали
0  Beta 12 Pro  2017       добре         256          186      0   3090.0        17500           0          1
1      Alfa A7  2021       добре         128          394      0   2810.0         7400           0          1
2      Gamma X  2022  дуже добре          64           44      4  33060.0        24000           1          2

2 · Ознаки й поділ¶

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

In [4]:
ОЗНАКИ = ["ціна", "ціна_нового", "рік", "стан_бали", "памʼять_гб", "вік_акаунта"]

X = дошка[ОЗНАКИ]
y = дошка["шахрайське"]

навч_X, тест_X, навч_y, тест_y = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y)

print("навчальна частина:", навч_X.shape, "· тестова:", тест_X.shape)
print("частка шахрайських у тесті:", round(float(тест_y.mean()), 4))
навчальна частина: (900, 6) · тестова: (300, 6)
частка шахрайських у тесті: 0.1633

3 · Три моделі й ціна зрозумілості¶

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

In [5]:
логістична = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
логістична.fit(навч_X, навч_y)

дерево = DecisionTreeClassifier(max_depth=3, random_state=42).fit(навч_X, навч_y)

ліс = RandomForestClassifier(n_estimators=200, min_samples_leaf=3,
                             random_state=42).fit(навч_X, навч_y)

for назва, готова_модель in [("логістична", логістична),
                             ("дерево (глибина 3)", дерево),
                             ("випадковий ліс", ліс)]:
    ймовірності = готова_модель.predict_proba(тест_X)[:, 1]
    print(f"{назва:<20} точність {accuracy_score(тест_y, готова_модель.predict(тест_X)):.4f}"
          f" · AUC {roc_auc_score(тест_y, ймовірності):.4f}")
логістична           точність 0.8367 · AUC 0.5988
дерево (глибина 3)   точність 0.8667 · AUC 0.7155
випадковий ліс       точність 0.9133 · AUC 0.9586

Тепер прочитаємо дві зрозумілі моделі вголос — і подивимось, чи правду вони кажуть.

In [6]:
print("коефіцієнти логістичної (ознаки стандартизовані, тому їх можна порівнювати):")
for назва, вага in zip(ОЗНАКИ, логістична[-1].coef_[0]):
    print(f"   {назва:<14} {вага:+.3f}")

print()
print(export_text(дерево, feature_names=ОЗНАКИ, decimals=0))
коефіцієнти логістичної (ознаки стандартизовані, тому їх можна порівнювати):
   ціна           +0.105
   ціна_нового    -0.215
   рік            +0.089
   стан_бали      +0.068
   памʼять_гб     +0.174
   вік_акаунта    -0.416

|--- ціна <= 735
|   |--- class: 1
|--- ціна >  735
|   |--- вік_акаунта <= 138
|   |   |--- ціна <= 1820
|   |   |   |--- class: 0
|   |   |--- ціна >  1820
|   |   |   |--- class: 0
|   |--- вік_акаунта >  138
|   |   |--- ціна <= 40935
|   |   |   |--- class: 0
|   |   |--- ціна >  40935
|   |   |   |--- class: 1

Логістична каже: «що дорожче — то підозріліше» (коефіцієнт при ціні додатний). Це неправда: три чверті шахраїв ціну занижують. Модель, яку легко прочитати, чесно показала своє правило — і правило виявилось поганим, бо звʼязок ціни з шахрайством не монотонний, а лінійна модель монотонності не вміє (тема 17).

Дерево впоралось краще: перший розріз ціна <= 735 — це і є приманка на жадібність. Два нижні розрізи не змінюють клас, зате змінюють ймовірність.

4 · MDI: важливість ознак «з коробки»¶

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

In [7]:
важливість_mdi = pd.Series(ліс.feature_importances_, index=ОЗНАКИ)
print(важливість_mdi.sort_values(ascending=False).round(4))
ціна           0.5444
вік_акаунта    0.1785
ціна_нового    0.1034
рік            0.1004
памʼять_гб     0.0435
стан_бали      0.0296
dtype: float64

Ніякої магії всередині¶

Порахуємо те саме власноруч, прямо з дерев. Для кожного розрізу беремо, наскільки впала забрудненість, множимо на кількість обʼєктів у вузлі, додаємо до рахунку тієї ознаки, за якою різали, — і нормуємо.

In [8]:
def важливість_руками(готовий_ліс, кількість_ознак):
    """Те саме, що feature_importances_, але зібране з дерев власноруч."""
    сума = np.zeros(кількість_ознак)
    for одне_дерево in готовий_ліс.estimators_:
        вузли = одне_дерево.tree_
        внесок = np.zeros(кількість_ознак)
        обʼєктів = вузли.weighted_n_node_samples
        for вузол in range(вузли.node_count):
            ліворуч = вузли.children_left[вузол]
            праворуч = вузли.children_right[вузол]
            if ліворуч == -1:
                continue                      # лист нічого не ділить
            # скільки забрудненості прибрав саме цей розріз, з вагою за розміром вузла
            внесок[вузли.feature[вузол]] += (
                обʼєктів[вузол] * вузли.impurity[вузол]
                - обʼєктів[ліворуч] * вузли.impurity[ліворуч]
                - обʼєктів[праворуч] * вузли.impurity[праворуч])
        внесок = внесок / обʼєктів[0]
        сума += внесок / внесок.sum()         # sklearn нормує кожне дерево окремо
    return сума / len(готовий_ліс.estimators_)


наша_mdi = важливість_руками(ліс, len(ОЗНАКИ))
print(pd.DataFrame({"наша": наша_mdi, "sklearn": ліс.feature_importances_},
                   index=ОЗНАКИ).round(6))

assert np.allclose(наша_mdi, ліс.feature_importances_), "розрахунок розійшовся!"
print("\n✅ збігається")
                 наша   sklearn
ціна         0.544444  0.544444
ціна_нового  0.103442  0.103442
рік          0.100449  0.100449
стан_бали    0.029617  0.029617
памʼять_гб   0.043547  0.043547
вік_акаунта  0.178501  0.178501

✅ збігається

5 · Перестановкова важливість: інша відповідь на інше питання¶

Перемішуємо одну колонку між рядками — звʼязок ознаки з відповіддю руйнується, розподіл лишається — і дивимось, наскільки впала якість на тестових даних.

In [9]:
перестановкова = permutation_importance(ліс, тест_X, тест_y, n_repeats=10,
                                        random_state=0, scoring="roc_auc")

рейтинги = pd.DataFrame({
    "MDI": важливість_mdi,
    "перестановкова": pd.Series(перестановкова.importances_mean, index=ОЗНАКИ),
})
рейтинги["місце_MDI"] = рейтинги["MDI"].rank(ascending=False).astype(int)
рейтинги["місце_перест"] = рейтинги["перестановкова"].rank(ascending=False).astype(int)
print(рейтинги.sort_values("MDI", ascending=False).round(4))
                MDI  перестановкова  місце_MDI  місце_перест
ціна         0.5444          0.4043          1             1
вік_акаунта  0.1785          0.0143          2             4
ціна_нового  0.1034          0.1489          3             2
рік          0.1004          0.1136          4             3
памʼять_гб   0.0435          0.0103          5             5
стан_бали    0.0296          0.0052          6             6

Рейтинги розійшлися, і найцікавіше — вік_акаунта. За MDI це друга ознака (0.1785), за перестановковою — четверта, і значення майже нульове (0.0143).

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

6 · Пастка перша: випадкова ознака з великою кардинальністю¶

Додамо колонку код_оголошення — чисте випадкове число, яке за побудовою не знає нічого. І будемо міняти лише одне: скільки в неї різних значень.

In [10]:
шум_rng = np.random.default_rng(7)
рядки_досліду = []

for кардинальність in [2, 4, 8, 16, 64, 256, 1200]:
    з_кодом = X.copy()
    з_кодом["код_оголошення"] = шум_rng.integers(0, кардинальність, len(X))

    н_X, т_X, н_y, т_y = train_test_split(з_кодом, y, test_size=0.25,
                                          random_state=42, stratify=y)
    # 120 дерев замість 200: дослід повторюється сім разів, і його треба дочекатись
    ліс_з_кодом = RandomForestClassifier(n_estimators=120, min_samples_leaf=3,
                                         random_state=42).fit(н_X, н_y)

    mdi = pd.Series(ліс_з_кодом.feature_importances_, index=з_кодом.columns)
    пер = pd.Series(
        permutation_importance(ліс_з_кодом, т_X, т_y, n_repeats=6, random_state=0,
                               scoring="roc_auc").importances_mean,
        index=з_кодом.columns)

    рядки_досліду.append({
        "унікальних значень": кардинальність,
        "MDI": round(float(mdi["код_оголошення"]), 4),
        "місце з 7": int(mdi.rank(ascending=False)["код_оголошення"]),
        "перестановкова": round(float(пер["код_оголошення"]), 4),
        "місце з 7 ": int(пер.rank(ascending=False)["код_оголошення"]),
    })

дослід = pd.DataFrame(рядки_досліду)
print(дослід.to_string(index=False))
 унікальних значень    MDI  місце з 7  перестановкова  місце з 7 
                  2 0.0166          7          0.0022           7
                  4 0.0370          6          0.0110           7
                  8 0.0551          5         -0.0129           7
                 16 0.0700          5          0.0013           7
                 64 0.0912          3         -0.0051           7
                256 0.1025          3         -0.0009           7
               1200 0.1207          3         -0.0056           7

Ось воно. Колонка, у якій немає жодної інформації, з двома значеннями чесно стоїть останньою (MDI 0.0166). Дайте їй 1200 різних значень — і MDI піднімає її на третє місце з семи (0.1207), вище за ціна_нового (0.0905) і рік (0.0865), які справді щось знають.

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

Перестановкова важливість не купується: на всіх семи положеннях код лишається сьомим, а його значення коливається біля нуля (від −0.0129 до +0.0110) — тобто перемішування безглуздої колонки то трохи псує, то трохи покращує AUC. Це і є шум.

7 · Пастка друга: колонка-близнюк¶

Тепер зламаємо перестановкову важливість. Уявімо, що дошка зберігає ціну ще й у доларах — ціна_usd. Нової інформації нуль, кореляція одиниця.

In [11]:
з_близнюком = X.copy()
з_близнюком["ціна_usd"] = np.round(дошка["ціна"] / 41.2, 0)

н_X, т_X, н_y, т_y = train_test_split(з_близнюком, y, test_size=0.25,
                                      random_state=42, stratify=y)
ліс_близнюк = RandomForestClassifier(n_estimators=200, min_samples_leaf=3,
                                     random_state=42).fit(н_X, н_y)

пер_близнюк = pd.Series(
    permutation_importance(ліс_близнюк, т_X, т_y, n_repeats=10, random_state=0,
                           scoring="roc_auc").importances_mean,
    index=з_близнюком.columns)

print("кореляція ціна ↔ ціна_usd:",
      round(float(np.corrcoef(з_близнюком["ціна"], з_близнюком["ціна_usd"])[0, 1]), 5))
print()
print("БЕЗ близнюка:")
print(pd.Series(перестановкова.importances_mean, index=ОЗНАКИ).sort_values(ascending=False).round(4))
print()
print("ІЗ близнюком:")
print(пер_близнюк.sort_values(ascending=False).round(4))
кореляція ціна ↔ ціна_usd: 1.0

БЕЗ близнюка:
ціна           0.4043
ціна_нового    0.1489
рік            0.1136
вік_акаунта    0.0143
памʼять_гб     0.0103
стан_бали      0.0052
dtype: float64

ІЗ близнюком:
ціна_нового    0.1666
ціна           0.1429
рік            0.1191
ціна_usd       0.1180
вік_акаунта    0.0079
стан_бали      0.0072
памʼять_гб     0.0029
dtype: float64

Ціна щойно «подешевшала» з 0.4043 до 0.1429 — не тому, що стала менш важливою, а тому, що коли ми псуємо одну колонку, модель бере ту саму інформацію з другої. Перевіримо цю здогадку прямо: перемішаємо обидві колонки разом.

In [12]:
базовий_auc = roc_auc_score(т_y, ліс_близнюк.predict_proba(т_X)[:, 1])
разом_rng = np.random.default_rng(0)
падіння = []

for повтор in range(10):
    зіпсовані = т_X.copy()
    # той самий порядок для обох колонок: ламаємо звʼязок із відповіддю, а не між ними
    порядок = разом_rng.permutation(len(зіпсовані))
    зіпсовані["ціна"] = т_X["ціна"].values[порядок]
    зіпсовані["ціна_usd"] = т_X["ціна_usd"].values[порядок]
    падіння.append(базовий_auc - roc_auc_score(т_y, ліс_близнюк.predict_proba(зіпсовані)[:, 1]))

print(f"перемішали обидві колонки разом: AUC падає на {np.mean(падіння):.4f}")
print(f"а поодинці вони «коштують» {пер_близнюк['ціна']:.4f} + {пер_близнюк['ціна_usd']:.4f}"
      f" = {пер_близнюк['ціна'] + пер_близнюк['ціна_usd']:.4f}")
print()
print("Разом вони варті більше, ніж сума своїх окремих важливостей.")
перемішали обидві колонки разом: AUC падає на 0.4346
а поодинці вони «коштують» 0.1429 + 0.1180 = 0.2609

Разом вони варті більше, ніж сума своїх окремих важливостей.

8 · Значення Шеплі з нуля¶

Тепер головне. Беремо одне оголошення й ділимо його прогноз між ознаками чесно.

Правила гри такі. Прогноз — це виграш, ознаки — гравці. Щоб порахувати внесок гравця, треба знати, скільки коштує кожна команда з нього й без нього. «Команда» — це набір ознак S, а її цінність v(S) — середній прогноз моделі, коли ознаки з S узяті з нашого оголошення, а всі інші підмінені значеннями з фонових рядків. Фон — це просто сто випадкових оголошень із навчальної частини.

Щоб усі перестановки поміщались на екрані, візьмемо модель на трьох ознаках.

In [13]:
ТРИ = ["ціна", "ціна_нового", "вік_акаунта"]

ліс3 = RandomForestClassifier(n_estimators=200, min_samples_leaf=3,
                              random_state=42).fit(навч_X[ТРИ], навч_y)
фон = навч_X[ТРИ].sample(100, random_state=0)

оголошення = тест_X.iloc[180]
print("оголошення, яке пояснюємо:")
print(оголошення[["ціна", "ціна_нового", "рік", "памʼять_гб", "вік_акаунта"]].to_string())
print()
print("справжня мітка:", int(тест_y.iloc[180]))
print("прогноз лісу на трьох ознаках:",
      round(float(ліс3.predict_proba(тест_X[ТРИ].iloc[[180]])[0, 1]), 4))
оголошення, яке пояснюємо:
ціна            1050.0
ціна_нового    17500.0
рік             2017.0
памʼять_гб       128.0
вік_акаунта       73.0

справжня мітка: 1
прогноз лісу на трьох ознаках: 0.8223

Флагман 2017 року, який новим коштував 17 500 грн, продають за 1050 грн з акаунта, якому 73 дні. Модель дає 0.8223. Тепер порахуємо цінність усіх восьми команд.

In [14]:
def цінність(набір):
    """v(S): середній прогноз, коли ознаки з набору взяті з нашого оголошення,
    а решта — з фонових рядків. Це «що знає модель, якщо їй сказали лише S»."""
    суміш = фон.copy()
    for позиція in набір:
        суміш.iloc[:, позиція] = оголошення[ТРИ[позиція]]
    return float(ліс3.predict_proba(суміш)[:, 1].mean())


усі_набори = [frozenset(с) for розмір in range(4)
              for с in itertools.combinations(range(3), розмір)]
v = {набір: цінність(набір) for набір in усі_набори}

for набір in sorted(усі_набори, key=lambda s: (len(s), sorted(s))):
    імена = ", ".join(ТРИ[j] for j in sorted(набір)) or "нічого"
    print(f"v({імена:<38}) = {v[набір]:.4f}")
v(нічого                                ) = 0.1402
v(ціна                                  ) = 0.4359
v(ціна_нового                           ) = 0.2851
v(вік_акаунта                           ) = 0.2152
v(ціна, ціна_нового                     ) = 0.6547
v(ціна, вік_акаунта                     ) = 0.5669
v(ціна_нового, вік_акаунта              ) = 0.3863
v(ціна, ціна_нового, вік_акаунта        ) = 0.8223

v(нічого) = 0.1402 — це середній прогноз по фону, тобто «що модель думає про випадкове оголошення з дошки». Саме від цього рівня ми й відраховуємо внески.

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

In [15]:
внески = np.zeros(3)
print(f"{'порядок':<44}" + "".join(f"{назва:>16}" for назва in ТРИ))

for порядок in itertools.permutations(range(3)):
    набір = frozenset()
    приріст = np.zeros(3)
    for ознака in порядок:
        приріст[ознака] = v[набір | {ознака}] - v[набір]
        набір = набір | {ознака}
    внески += приріст
    підпис = " → ".join(ТРИ[j] for j in порядок)
    print(f"{підпис:<44}" + "".join(f"{приріст[j]:+16.4f}" for j in range(3)))

внески = внески / 6                    # значення Шеплі = середнє по всіх порядках
print(f"{'СЕРЕДНЄ (значення Шеплі)':<44}" + "".join(f"{внески[j]:+16.4f}" for j in range(3)))
порядок                                                 ціна     ціна_нового     вік_акаунта
ціна → ціна_нового → вік_акаунта                     +0.2957         +0.2188         +0.1676
ціна → вік_акаунта → ціна_нового                     +0.2957         +0.2554         +0.1310
ціна_нового → ціна → вік_акаунта                     +0.3696         +0.1449         +0.1676
ціна_нового → вік_акаунта → ціна                     +0.4360         +0.1449         +0.1012
вік_акаунта → ціна → ціна_нового                     +0.3517         +0.2554         +0.0750
вік_акаунта → ціна_нового → ціна                     +0.4360         +0.1712         +0.0750
СЕРЕДНЄ (значення Шеплі)                             +0.3641         +0.1984         +0.1195

Подивись на колонку «ціна»: залежно від порядку її внесок гуляє від +0.2957 до +0.4360. Саме тому один порядок нічого не вирішує — Шеплі усереднює всі.

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

In [16]:
def шеплі_формулою(цінності, кількість_ознак):
    """Та сама величина через ваги підмножин: скільки перестановок дають цей набір."""
    результат = np.zeros(кількість_ознак)
    for ознака in range(кількість_ознак):
        for набір in усі_набори:
            if ознака in набір:
                continue
            розмір = len(набір)
            вага = (factorial(розмір) * factorial(кількість_ознак - розмір - 1)
                    / factorial(кількість_ознак))
            результат[ознака] += вага * (цінності[набір | {ознака}] - цінності[набір])
    return результат


assert np.allclose(внески, шеплі_формулою(v, 3)), "два способи розійшлися!"
print("✅ перебір перестановок і формула через підмножини дають те саме")
✅ перебір перестановок і формула через підмножини дають те саме

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

In [17]:
база = v[frozenset()]
прогноз = v[frozenset({0, 1, 2})]

print(f"база {база:.4f} + сума внесків {внески.sum():.4f} = {база + внески.sum():.4f}")
print(f"прогноз моделі                              = {прогноз:.4f}")

assert np.allclose(база + внески.sum(), прогноз), "адитивність порушена!"
print("\n✅ сума внесків плюс базове значення дорівнює прогнозу")
база 0.1402 + сума внесків 0.6821 = 0.8223
прогноз моделі                              = 0.8223

✅ сума внесків плюс базове значення дорівнює прогнозу

А що бібліотека?¶

Бібліотека shap рахує те саме — швидше й для десятків ознак. Її може не бути в твоєму середовищі, тому імпорт загорнуто в try: наш власний розрахунок на трьох ознаках працює завжди.

In [18]:
try:
    import shap

    пояснювач = shap.explainers.Exact(
        lambda рядки: ліс3.predict_proba(pd.DataFrame(рядки, columns=ТРИ))[:, 1],
        фон.values)
    бібліотечні = пояснювач(оголошення[ТРИ].values.reshape(1, -1).astype(float)).values[0]

    print("бібліотека shap:", np.round(бібліотечні, 4))
    print("наш розрахунок :", np.round(внески, 4))
    print("максимальна різниця:", float(np.max(np.abs(бібліотечні - внески))))
except Exception as помилка:
    print("бібліотеки shap тут немає:", type(помилка).__name__)
    print("і це не біда: на трьох ознаках точні значення Шеплі рахуються перебором за мить,")
    print("на шести — за секунду. Бібліотека потрібна там, де ознак десятки.")
бібліотеки shap тут немає: ImportError
і це не біда: на трьох ознаках точні значення Шеплі рахуються перебором за мить,
на шести — за секунду. Бібліотека потрібна там, де ознак десятки.

9 · Глобальне проти локального¶

Тепер той самий розрахунок для повної моделі на шести ознаках — це 64 набори замість 8 — і для чотирьох різних оголошень. Питання одне: чи однакове пояснення в різних рядків?

In [19]:
фон6 = навч_X.sample(100, random_state=0)
набори6 = [frozenset(с) for розмір in range(7)
           for с in itertools.combinations(range(6), розмір)]


def шеплі_для_рядка(рядок):
    """Точні значення Шеплі для одного оголошення: всі 64 набори за один прогін."""
    суміші = []
    for набір in набори6:
        суміш = фон6.copy()
        for позиція in набір:
            суміш.iloc[:, позиція] = рядок[ОЗНАКИ[позиція]]
        суміші.append(суміш)

    # один виклик predict_proba на 6400 рядках замість 64 окремих — так помітно швидше
    прогнози = ліс.predict_proba(pd.concat(суміші, ignore_index=True))[:, 1]
    середні = прогнози.reshape(len(набори6), len(фон6)).mean(axis=1)
    цінності = dict(zip(набори6, середні))

    результат = np.zeros(6)
    for ознака in range(6):
        for набір in набори6:
            if ознака in набір:
                continue
            розмір = len(набір)
            вага = factorial(розмір) * factorial(5 - розмір) / factorial(6)
            результат[ознака] += вага * (цінності[набір | {ознака}] - цінності[набір])
    return float(цінності[frozenset()]), результат


глобальне_місце = важливість_mdi.rank(ascending=False).astype(int)

for номер in [237, 180, 154, 97]:
    рядок = тест_X.iloc[номер]
    база6, внески6 = шеплі_для_рядка(рядок)
    прогноз6 = float(ліс.predict_proba(тест_X.iloc[[номер]])[0, 1])

    print(f"\nоголошення {номер}: ціна {int(рядок['ціна'])} грн, нового "
          f"{int(рядок['ціна_нового'])}, акаунт {int(рядок['вік_акаунта'])} дн, "
          f"рік {int(рядок['рік'])}, памʼять {int(рядок['памʼять_гб'])} ГБ")
    print(f"   прогноз {прогноз6:.4f} · база {база6:.4f} · сума внесків {внески6.sum():+.4f}")
    for назва, значення in sorted(zip(ОЗНАКИ, внески6), key=lambda пара: -abs(пара[1])):
        print(f"   {назва:<14} {значення:+.4f}   (у глобальному рейтингу MDI "
              f"місце {глобальне_місце[назва]} з 6)")
оголошення 237: ціна 620 грн, нового 5200, акаунт 120 дн, рік 2019, памʼять 128 ГБ
   прогноз 0.9343 · база 0.1453 · сума внесків +0.7890
   ціна           +0.7177   (у глобальному рейтингу MDI місце 1 з 6)
   вік_акаунта    +0.0627   (у глобальному рейтингу MDI місце 2 з 6)
   ціна_нового    +0.0157   (у глобальному рейтингу MDI місце 3 з 6)
   рік            -0.0063   (у глобальному рейтингу MDI місце 4 з 6)
   стан_бали      -0.0036   (у глобальному рейтингу MDI місце 6 з 6)
   памʼять_гб     +0.0029   (у глобальному рейтингу MDI місце 5 з 6)
оголошення 180: ціна 1050 грн, нового 17500, акаунт 73 дн, рік 2017, памʼять 128 ГБ
   прогноз 0.7245 · база 0.1453 · сума внесків +0.5792
   ціна           +0.3201   (у глобальному рейтингу MDI місце 1 з 6)
   ціна_нового    +0.1638   (у глобальному рейтингу MDI місце 3 з 6)
   вік_акаунта    +0.1304   (у глобальному рейтингу MDI місце 2 з 6)
   рік            -0.0942   (у глобальному рейтингу MDI місце 4 з 6)
   стан_бали      +0.0415   (у глобальному рейтингу MDI місце 6 з 6)
   памʼять_гб     +0.0178   (у глобальному рейтингу MDI місце 5 з 6)
оголошення 154: ціна 2280 грн, нового 17500, акаунт 1371 дн, рік 2024, памʼять 128 ГБ
   прогноз 0.4630 · база 0.1453 · сума внесків +0.3178
   рік            +0.1276   (у глобальному рейтингу MDI місце 4 з 6)
   ціна_нового    +0.1111   (у глобальному рейтингу MDI місце 3 з 6)
   ціна           +0.0713   (у глобальному рейтингу MDI місце 1 з 6)
   вік_акаунта    +0.0243   (у глобальному рейтингу MDI місце 2 з 6)
   стан_бали      -0.0147   (у глобальному рейтингу MDI місце 6 з 6)
   памʼять_гб     -0.0018   (у глобальному рейтингу MDI місце 5 з 6)
оголошення 97: ціна 8240 грн, нового 7400, акаунт 115 дн, рік 2022, памʼять 512 ГБ
   прогноз 0.3655 · база 0.1453 · сума внесків +0.2202
   памʼять_гб     +0.0848   (у глобальному рейтингу MDI місце 5 з 6)
   вік_акаунта    +0.0648   (у глобальному рейтингу MDI місце 2 з 6)
   ціна_нового    +0.0571   (у глобальному рейтингу MDI місце 3 з 6)
   ціна           -0.0327   (у глобальному рейтингу MDI місце 1 з 6)
   рік            +0.0294   (у глобальному рейтингу MDI місце 4 з 6)
   стан_бали      +0.0169   (у глобальному рейтингу MDI місце 6 з 6)

Чотири оголошення — чотири різні історії.

  • 237 — ціна 620 грн за телефон, який новим коштував 5200: класика, вирішує ціна (+0.7177), і глобальний рейтинг тут не бреше.
  • 154 — вирішує рік (+0.1276), ознака, яка глобально лише четверта, а ціна дає вчетверо менше.
  • 97 — вирішує памʼять_гб (+0.0848), ознака пʼята з шести глобально. А ціна, головна ознака моделі, тут узагалі тягне прогноз вниз (−0.0327).

Глобальна важливість — це середнє по тисячі оголошень. Конкретне оголошення не зобовʼязане бути схожим на середнє.

10 · Частинна залежність (PDP)¶

PDP відповідає на питання «як міняється прогноз, якщо крутити одну ознаку». Рецепт прямолінійний: ставимо всім рядкам те саме значення ціни й дивимось на середній прогноз.

In [20]:
сітка_цін = np.array([500, 1500, 3000, 5000, 7000, 10000, 14000, 19000, 25000, 32000],
                     dtype=float)


def часткова_залежність(колонка, сітка):
    """PDP руками: ставимо всім рядкам те саме значення ознаки й усереднюємо прогноз."""
    крива = []
    for значення in сітка:
        копія = навч_X.copy()
        копія[колонка] = значення
        крива.append(float(ліс.predict_proba(копія)[:, 1].mean()))
    return np.array(крива)


наш_pdp = часткова_залежність("ціна", сітка_цін)
print(pd.DataFrame({"ціна": сітка_цін.astype(int), "PDP": наш_pdp.round(4)}).to_string(index=False))

# звірка з бібліотекою на її власній сітці
бібліотечний = partial_dependence(ліс, навч_X, features=["ціна"], grid_resolution=10,
                                  percentiles=(0, 1), kind="average", method="brute")
на_їхній_сітці = часткова_залежність("ціна", бібліотечний["grid_values"][0])

assert np.allclose(на_їхній_сітці, бібліотечний["average"][0]), "розрахунок розійшовся!"
print("\n✅ збігається з sklearn.inspection.partial_dependence")
 ціна    PDP
  500 0.8762
 1500 0.3979
 3000 0.1747
 5000 0.0923
 7000 0.1143
10000 0.1901
14000 0.2657
19000 0.3300
25000 0.3662
32000 0.4156
✅ збігається з sklearn.inspection.partial_dependence

Крива має форму ванни: дуже дешево — небезпечно (0.8762), нормально — спокійно (0.0923 на 5000 грн), дуже дорого — знову небезпечно (0.4156). Обидві приманки видно.

А тепер подивимось, що ця крива приховує: побудуємо окремі криві для кожного з чотирьох оголошень (ICE) поруч із середньою.

In [21]:
plt.figure(figsize=(7.5, 4.2))

for номер, колір in zip([237, 180, 154, 97], ["#c2185b", "#0f766e", "#c2620f", "#5b6b7c"]):
    рядок = тест_X.iloc[[номер]]
    крива = []
    for значення in сітка_цін:
        копія = рядок.copy()
        копія["ціна"] = значення
        крива.append(float(ліс.predict_proba(копія)[0, 1]))
    plt.plot(сітка_цін, крива, color=колір, lw=1.6, marker="o", ms=3,
             label=f"оголошення {номер}")
    print(f"ICE {номер}: {[round(z, 3) for z in крива]}")

plt.plot(сітка_цін, наш_pdp, color="black", lw=3, label="PDP (середнє)")
plt.xlabel("ціна в оголошенні, грн")
plt.ylabel("ймовірність «шахрайське»")
plt.title("PDP і чотири окремі криві ICE")
plt.legend(fontsize=8)
plt.tight_layout()
plt.show()
ICE 237: [0.934, 0.161, 0.057, 0.098, 0.174, 0.329, 0.454, 0.506, 0.538, 0.556]
ICE 180: [0.911, 0.69, 0.188, 0.163, 0.186, 0.227, 0.369, 0.469, 0.537, 0.57]
ICE 154: [0.867, 0.629, 0.412, 0.19, 0.15, 0.117, 0.122, 0.163, 0.226, 0.311]
ICE 97: [0.878, 0.578, 0.322, 0.248, 0.28, 0.413, 0.485, 0.542, 0.564, 0.579]
No description has been provided for this image

Дно ванни в кожного своє: у дешевого Alfa A5 (оголошення 237) прогноз мінімальний біля 3000 грн, у флагмана Beta 12 Pro (оголошення 154) — аж біля 10 000. Середня крива проходить між ними й не описує жодного.

І остання незручність. Щоб порахувати PDP у точці 32 000 грн, ми поставили ціну 32 000 всім навчальним рядкам — включно з тими, де ціна_нового дорівнює 5200.

In [22]:
дешевий_дорого = int(((дошка["ціна_нового"] <= 5200) & (дошка["ціна"] > 20000)).sum())
взагалі_дорогих = int(((дошка["ціна"] > 28000) & (дошка["ціна"] < 36000)).sum())

print("оголошень «Alfa A5 дорожче 20 000 грн» на дошці:", дешевий_дорого)
print("оголошень із ціною 28-36 тис. грн узагалі:", взагалі_дорогих)
print()
print("Тобто в точці 32 000 PDP усереднює прогноз по 900 рядках,")
print("значна частина яких — комбінації, яких у житті не буває.")
print("Модель у цих точках нічого не вчила: вона там просто щось видає.")
оголошень «Alfa A5 дорожче 20 000 грн» на дошці: 0
оголошень із ціною 28-36 тис. грн узагалі: 20

Тобто в точці 32 000 PDP усереднює прогноз по 900 рядках,
значна частина яких — комбінації, яких у житті не буває.
Модель у цих точках нічого не вчила: вона там просто щось видає.

Завдання¶

🟢 Рівень 1 — База¶

Візьми оголошення тест_X.iloc[46] і поясни його прогноз двома способами: значеннями Шеплі на трьох ознаках (ТРИ) і на шести (ОЗНАКИ).

Зроблено, якщо: для обох наборів адитивність зійшлася (assert не впав), і ти письмово відповів, чому внесок ціни в цих двох поясненнях різний.

🟡 Рівень 2 — Плюс¶

Побудуй PDP для вік_акаунта замість ціни й накресли поруч ICE-криві тих самих чотирьох оголошень.

Зроблено, якщо: є графік, і ти назвав хоча б одне оголошення, чия крива йде проти середньої, та пояснив, чому.

🔴 Рівень 3 — Виклик¶

Перевір пояснення експериментом. Візьми оголошення 97, де памʼять дала найбільший внесок (+0.0848). Зміни в ньому лише памʼять (64 → 128 → 256 → 512), не чіпаючи більше нічого, і подивись на прогноз.

Зроблено, якщо: є таблиця «памʼять → прогноз» і висновок: чи справді зміна памʼяті рухає прогноз на величину, близьку до внеску Шеплі, — і що це говорить про довіру до пояснення.

Підказки¶

  • Значення Шеплі залежать від того, які ще ознаки є в моделі: додай ознаку — і всі внески перерахуються. Це не баг.
  • ICE-крива для одного рядка — це рядок.copy() у циклі по сітці, як у розділі 10.
  • Внесок Шеплі — не обіцянка «зміни ознаку й отримаєш стільки ж». Рівень 3 саме про це.