Практика 40 · Інтерпретованість моделей¶
📖 Теорія:
lecture.htmlу цій же теці · 📝 Домашнє:homework.md· 🧪 Тест:quiz.html
Ми беремо дошку оголошень про вживані телефони, навчаємо на ній випадковий ліс і далі весь час ставимо йому одне питання: чому?
Що зробимо:
- навчимо три моделі — логістичну, дерево й ліс — і побачимо ціну зрозумілості;
- порахуємо
feature_importances_власноруч із дерев і звіримо зіsklearn; - додамо до таблиці випадкову колонку з великою кількістю значень і подивимось, куди вона потрапить у рейтингу MDI;
- порахуємо перестановкову важливість і побачимо, як вона ламається на колонці-близнюку;
- порахуємо значення Шеплі з нуля — перебором усіх перестановок — і перевіримо адитивність;
- побудуємо частинну залежність (PDP) руками, звіримо зі
sklearnі подивимось на криві ICE, які вона усереднює; - візьмемо чотири конкретні оголошення й побачимо, що локальні пояснення в них різні.
Зерно генератора зафіксоване: у тебе вийдуть точно ті самі числа, що в лекції.
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
кількість = 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.]
Тепер шахраї. Шахрай частіше працює зі свіжого акаунта — саме тому ймовірність шахрайства залежить від віку акаунта. А ціну він ставить або різко занижену (приманка на жадібність), або завищену — під велику передоплату. Запамʼятай цю деталь: далі вона пояснить дивну форму однієї кривої.
шанс_шахрайства = 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 · Ознаки й поділ¶
Шість ознак. Колонку скарг до них не беремо: скарги зʼявляються після публікації,
а в момент прогнозу там завжди нуль — це класичний
витік даних.
ОЗНАКИ = ["ціна", "ціна_нового", "рік", "стан_бали", "памʼять_гб", "вік_акаунта"]
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 · Три моделі й ціна зрозумілості¶
Логістичну регресію й дерево можна прочитати вголос — вони інтерпретовані за будовою. Ліс прочитати не можна. Подивимось, скільки якості коштує ця зрозумілість.
логістична = 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
Тепер прочитаємо дві зрозумілі моделі вголос — і подивимось, чи правду вони кажуть.
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_ рахує, скільки забрудненості прибрали розрізи по кожній ознаці.
Спочатку подивимось, що дає бібліотека.
важливість_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
Ніякої магії всередині¶
Порахуємо те саме власноруч, прямо з дерев. Для кожного розрізу беремо, наскільки впала забрудненість, множимо на кількість обʼєктів у вузлі, додаємо до рахунку тієї ознаки, за якою різали, — і нормуємо.
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 · Перестановкова важливість: інша відповідь на інше питання¶
Перемішуємо одну колонку між рядками — звʼязок ознаки з відповіддю руйнується, розподіл лишається — і дивимось, наскільки впала якість на тестових даних.
перестановкова = 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 · Пастка перша: випадкова ознака з великою кардинальністю¶
Додамо колонку код_оголошення — чисте випадкове число, яке за побудовою не знає нічого.
І будемо міняти лише одне: скільки в неї різних значень.
шум_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. Нової інформації нуль, кореляція одиниця.
з_близнюком = 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 — не тому, що стала менш важливою, а тому, що коли ми псуємо одну колонку, модель бере ту саму інформацію з другої. Перевіримо цю здогадку прямо: перемішаємо обидві колонки разом.
базовий_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 узяті з
нашого оголошення, а всі інші підмінені значеннями з фонових рядків. Фон — це просто
сто випадкових оголошень із навчальної частини.
Щоб усі перестановки поміщались на екрані, візьмемо модель на трьох ознаках.
ТРИ = ["ціна", "ціна_нового", "вік_акаунта"]
ліс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. Тепер порахуємо цінність усіх восьми команд.
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 — це середній прогноз по фону, тобто «що модель думає про
випадкове оголошення з дошки». Саме від цього рівня ми й відраховуємо внески.
Тепер перебираємо всі шість порядків, у яких ознаки можуть заходити в команду. У кожному порядку внесок ознаки — це приріст цінності в момент, коли вона зайшла.
внески = 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. Саме тому один порядок нічого не вирішує — Шеплі усереднює всі.
Перевіримо себе двічі. По-перше, ту саму величину можна отримати не перебором перестановок, а сумою по підмножинах із вагами — має вийти те саме число.
def шеплі_формулою(цінності, кількість_ознак):
"""Та сама величина через ваги підмножин: скільки перестановок дають цей набір."""
результат = np.zeros(кількість_ознак)
for ознака in range(кількість_ознак):
for набір in усі_набори:
if ознака in набір:
continue
розмір = len(набір)
вага = (factorial(розмір) * factorial(кількість_ознак - розмір - 1)
/ factorial(кількість_ознак))
результат[ознака] += вага * (цінності[набір | {ознака}] - цінності[набір])
return результат
assert np.allclose(внески, шеплі_формулою(v, 3)), "два способи розійшлися!"
print("✅ перебір перестановок і формула через підмножини дають те саме")
✅ перебір перестановок і формула через підмножини дають те саме
По-друге — адитивність. Сума всіх внесків плюс базове значення має точно дорівнювати прогнозу. Не приблизно, а до останнього знака: це властивість, доведена для значень Шеплі, а не побажання.
база = 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: наш власний розрахунок на трьох
ознаках працює завжди.
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 — і для чотирьох різних оголошень. Питання одне: чи однакове пояснення в різних рядків?
фон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 відповідає на питання «як міняється прогноз, якщо крутити одну ознаку». Рецепт прямолінійний: ставимо всім рядкам те саме значення ціни й дивимось на середній прогноз.
сітка_цін = 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) поруч із середньою.
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]
Дно ванни в кожного своє: у дешевого Alfa A5 (оголошення 237) прогноз мінімальний біля 3000 грн, у флагмана Beta 12 Pro (оголошення 154) — аж біля 10 000. Середня крива проходить між ними й не описує жодного.
І остання незручність. Щоб порахувати PDP у точці 32 000 грн, ми поставили ціну 32 000
всім навчальним рядкам — включно з тими, де ціна_нового дорівнює 5200.
дешевий_дорого = 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 саме про це.