Практика 01 · Що таке машинне навчання¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
Наскрізний приклад той самий, що в лекції: дошка оголошень про продаж вживаних телефонів. Серед оголошень є шахрайські, і їх треба відсіювати.
Що зробимо:
- Зберемо синтетичну дошку оголошень — звичайний
DataFrame - Напишемо фільтр шахрайства правилами руками й поміряємо, скільки він ловить
- Дамо ті самі дані моделі зі
scikit-learnі порівняємо два стовпчики чисел - Побудуємо крихітну регресію ціни й подивимось, що вона каже за межами даних
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
print("numpy ", np.__version__)
print("pandas ", pd.__version__)
print("генератор випадкових чисел зафіксовано: default_rng(42)")
numpy 2.5.1 pandas 2.3.3 генератор випадкових чисел зафіксовано: default_rng(42)
1. Дошка оголошень¶
Дані синтетичні, але побудовані за правдоподібною логікою. Спершу для кожного оголошення визначаємо справедливу ціну — від року випуску, обсягу памʼяті та стану. Потім вирішуємо, чи оголошення шахрайське, і залежно від цього ставимо ціну: шахрай ставить помітно нижчу, щоб привабити покупця.
Колонка is_fraud — це те, що колись проставив модератор. Саме вона й робить
можливим навчання з учителем.
N_ADS = 700
FRAUD_SHARE = 0.25 # чверть оголошень на дошці — шахрайські
phone_models = np.array(["Galaxy A54", "iPhone 12", "Redmi Note 12", "Pixel 7"])
condition_names = np.array(["новий", "добрий", "потертий"])
condition_bonus = {"новий": 1500, "добрий": 0, "потертий": -1200}
year = rng.integers(2018, 2024, N_ADS) # рік випуску
memory_gb = rng.choice([64, 128, 256], N_ADS) # обсяг памʼяті
condition = rng.choice(condition_names, N_ADS)
model_name = rng.choice(phone_models, N_ADS)
# справедлива ціна: базова вартість + надбавки. Це «правда», якої в реальних
# даних ми не бачимо — вона потрібна лише щоб згенерувати правдоподібні ціни
fair_price = (2900
+ 1750 * (year - 2018)
+ 21 * (memory_gb - 64)
+ np.array([condition_bonus[c] for c in condition])
+ rng.normal(0, 500, N_ADS))
is_fraud = (rng.random(N_ADS) < FRAUD_SHARE).astype(int)
print(f"усього оголошень : {N_ADS}")
print(f"з них шахрайських: {is_fraud.sum()}")
print(f"чесних : {N_ADS - is_fraud.sum()}")
усього оголошень : 700 з них шахрайських: 173 чесних : 527
Тепер ставимо ціну й вік акаунта продавця. Шахрай робить дві речі: ставить ціну
помітно нижчу за справедливу і публікує оголошення зі свіжого акаунта.
Але робить це не завжди — інакше задача розвʼязувалась би одним if.
# шахрайська знижка глибока, чесний торг — дрібний
discount = np.where(is_fraud == 1,
rng.uniform(0.35, 0.95, N_ADS),
rng.uniform(0.85, 1.15, N_ADS))
price = np.round(fair_price * discount, -1) # округлюємо до десятків
# вік акаунта: у шахраїв здебільшого свіжий, але трапляються й старі
account_age_days = np.where(is_fraud == 1,
rng.integers(0, 60, N_ADS),
rng.integers(0, 400, N_ADS))
ads = pd.DataFrame({
"model": model_name,
"year": year,
"condition": condition,
"memory_gb": memory_gb,
"account_age_days": account_age_days,
"price": price,
"is_fraud": is_fraud,
})
print(ads.head(8).to_string(index=False))
model year condition memory_gb account_age_days price is_fraud
Pixel 7 2018 потертий 128 207 3120.0 0
Redmi Note 12 2022 новий 128 289 13920.0 0
Galaxy A54 2021 новий 64 32 3950.0 1
Galaxy A54 2020 новий 64 9 9480.0 0
Pixel 7 2020 новий 64 147 8190.0 0
iPhone 12 2023 новий 128 12 11250.0 1
Pixel 7 2018 добрий 64 258 2670.0 0
Galaxy A54 2022 потертий 128 41 3930.0 1
Одна ознака нам ще потрібна, і її в таблиці немає: наскільки ціна нижча за ринкову. Ринкову ціну ми не знаємо, але можемо оцінити її з самих даних — як медіанну ціну серед оголошень із тим самим роком і обсягом памʼяті.
Медіана тут краща за середнє: шахрайські ціни її майже не зсувають.
# transform повертає значення для кожного рядка, а не по одному на групу —
# саме тому результат одразу лягає в нову колонку
market_price = ads.groupby(["year", "memory_gb"])["price"].transform("median")
ads["price_ratio"] = ads["price"] / market_price
print("ціна відносно ринкової, медіана по групах:")
print(ads.groupby("is_fraud")["price_ratio"].describe()[["count", "mean", "min", "max"]])
ціна відносно ринкової, медіана по групах:
count mean min max
is_fraud
0 527.0 1.057138 0.207746 1.978673
1 173.0 0.679042 0.257042 1.401408
Подивимось на дві ознаки очима — це та сама картинка, що в лекції.
fig, ax = plt.subplots(figsize=(9, 5))
honest = ads[ads["is_fraud"] == 0]
fraud = ads[ads["is_fraud"] == 1]
ax.scatter(honest["price_ratio"], honest["account_age_days"],
s=16, alpha=0.5, color="steelblue", label="чесні")
ax.scatter(fraud["price_ratio"], fraud["account_age_days"],
s=16, alpha=0.7, color="crimson", label="шахрайські")
ax.set_xlabel("ціна оголошення відносно ринкової")
ax.set_ylabel("вік акаунта, днів")
ax.set_title("Дві групи перекриваються — ідеальної відсічки не існує")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()
print("шахрайські тиснуться вліво-вниз, але не всі — саме тут і буде помилка")
шахрайські тиснуться вліво-вниз, але не всі — саме тут і буде помилка
2. Фільтр правилами руками¶
Спершу зробимо так, як робили б без жодного машинного навчання: подивимось на картинку, помітимо закономірність і напишемо дві умови.
def rule_filter(table):
"""Позначає підозрілі оголошення двома написаними руками правилами."""
too_cheap = table["price_ratio"] < 0.60 # ціна нижча за 60 % ринкової
too_new = table["account_age_days"] < 7 # акаунту менше тижня
return (too_cheap | too_new).astype(int)
flagged_by_rules = rule_filter(ads)
print(f"правила позначили : {flagged_by_rules.sum()} оголошень")
print(f"насправді шахрайських: {ads['is_fraud'].sum()}")
правила позначили : 111 оголошень насправді шахрайських: 173
Число позначених саме по собі нічого не каже. Важливо інше: скільки з них справді шахрайські і скільки чесних продавців ми образили. Розкладемо на три числа руками, самими логічними масками.
def count_hits(truth, flags):
"""Три числа, з яких складається будь-яка розмова про якість фільтра."""
caught = int(((flags == 1) & (truth == 1)).sum()) # спіймані шахраї
false_alarms = int(((flags == 1) & (truth == 0)).sum()) # ображені чесні
missed = int(((flags == 0) & (truth == 1)).sum()) # пропущені шахраї
return caught, false_alarms, missed
caught, false_alarms, missed = count_hits(ads["is_fraud"], flagged_by_rules)
print(f"спіймано шахраїв : {caught} з {ads['is_fraud'].sum()}")
print(f"хибних тривог : {false_alarms}")
print(f"пропущено : {missed}")
спіймано шахраїв : 92 з 173 хибних тривог : 19 пропущено : 81
3. Перевірка: наші числа = бібліотечні¶
Ті самі величини бібліотека називає precision (яка частка позначених справді
шахрайська) і recall (яку частку шахраїв ми спіймали). Порахуємо їх із наших
трьох чисел і звіримо зі sklearn — щоб побачити, що всередині немає магії.
from sklearn.metrics import precision_score, recall_score
our_precision = caught / (caught + false_alarms)
our_recall = caught / (caught + missed)
sk_precision = precision_score(ads["is_fraud"], flagged_by_rules)
sk_recall = recall_score(ads["is_fraud"], flagged_by_rules)
print(f"precision: наша {our_precision:.4f} sklearn {sk_precision:.4f}")
print(f"recall : наша {our_recall:.4f} sklearn {sk_recall:.4f}")
assert np.allclose([our_precision, our_recall], [sk_precision, sk_recall]), "розрахунок розійшовся!"
print("✅ збігається")
precision: наша 0.8288 sklearn 0.8288 recall : наша 0.5318 sklearn 0.5318 ✅ збігається
4. Ті самі дані, але вчимо на прикладах¶
Тепер жодного написаного правила. Даємо моделі дві ознаки й колонку відповідей — і нехай межу шукає сама.
Дані ділимо на дві частини: на одній модель вчиться, на другій ми її перевіряємо. Якість завжди міряють на прикладах, яких модель не бачила, — інакше ми виміряємо не вміння, а памʼять.
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
features = ["price_ratio", "account_age_days"]
X = ads[features]
y = ads["is_fraud"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# StandardScaler зводить ознаки до одного масштабу: вік акаунта міряється
# сотнями, а відношення цін — одиницями, і без цього оптимізація йде важче
fraud_model = make_pipeline(StandardScaler(), LogisticRegression())
fraud_model.fit(X_train, y_train)
print(f"навчальна вибірка: {len(X_train)} оголошень")
print(f"тестова вибірка : {len(X_test)} оголошень")
print("модель навчена")
навчальна вибірка: 490 оголошень тестова вибірка : 210 оголошень модель навчена
Порівнюємо чесно: обидва підходи міряємо на одній і тій самій тестовій вибірці, якої модель не бачила.
rules_on_test = rule_filter(ads.loc[X_test.index])
model_on_test = fraud_model.predict(X_test)
rules_hits = count_hits(y_test, rules_on_test)
model_hits = count_hits(y_test, model_on_test)
comparison = pd.DataFrame(
[rules_hits, model_hits],
index=["правила руками", "модель"],
columns=["спіймано", "хибних тривог", "пропущено"])
print(f"у тестовій вибірці шахраїв: {int(y_test.sum())}\n")
print(comparison.to_string())
у тестовій вибірці шахраїв: 52
спіймано хибних тривог пропущено
правила руками 35 6 17
модель 49 9 3
Що саме «вивчила» модель? Три числа: два коефіцієнти й вільний член. Це і є те саме «правило», яке ми не писали. Знак коефіцієнта читається просто: чим більша ціна відносно ринкової, тим нижча оцінка шахрайства; чим старший акаунт — теж нижча.
logreg = fraud_model.named_steps["logisticregression"]
print("вільний член :", round(float(logreg.intercept_[0]), 4))
for name, weight in zip(features, logreg.coef_[0]):
print(f"вага {name:<20}: {weight:+.4f}")
print("\nусе правило моделі вміщується в три числа")
вільний член : -3.9105 вага price_ratio : -1.2821 вага account_age_days : -3.7556 усе правило моделі вміщується в три числа
І ще одна перевірка «немає магії»: порахуємо оцінку моделі вручну за формулою
логістичної регресії й звіримо з тим, що видає predict_proba.
Формула така: беремо зважену суму ознак, додаємо вільний член і пропускаємо результат через сигмоїду — функцію, яка перетворює будь-яке число на значення від 0 до 1.
scaler = fraud_model.named_steps["standardscaler"]
X_test_scaled = scaler.transform(X_test)
# зважена сума ознак плюс вільний член
linear_part = X_test_scaled @ logreg.coef_[0] + logreg.intercept_[0]
our_proba = 1 / (1 + np.exp(-linear_part)) # сигмоїда
sk_proba = fraud_model.predict_proba(X_test)[:, 1]
print(f"наші перші три оцінки : {np.round(our_proba[:3], 6)}")
print(f"sklearn перші три оцінки: {np.round(sk_proba[:3], 6)}")
assert np.allclose(our_proba, sk_proba), "розрахунок розійшовся!"
print("✅ збігається")
наші перші три оцінки : [0.577057 0.379083 0.126662] sklearn перші три оцінки: [0.577057 0.379083 0.126662] ✅ збігається
5. Крихітна регресія ціни¶
Другий тип задачі з тієї самої таблиці: передбачити не мітку, а число. Вчимося на чесних оголошеннях — шахрайські ціни зіпсували б орієнтир.
from sklearn.linear_model import LinearRegression
honest_ads = ads[ads["is_fraud"] == 0]
price_features = ["year", "memory_gb"]
price_model = LinearRegression()
price_model.fit(honest_ads[price_features], honest_ads["price"])
print(f"надбавка за рік випуску : {price_model.coef_[0]:8.1f} грн")
print(f"надбавка за 1 ГБ памʼяті : {price_model.coef_[1]:8.1f} грн")
print(f"R² на навчальних даних : {price_model.score(honest_ads[price_features], honest_ads['price']):.3f}")
надбавка за рік випуску : 1727.4 грн надбавка за 1 ГБ памʼяті : 21.5 грн R² на навчальних даних : 0.844
Ще раз переконаємось, що всередині predict — звичайне множення й додавання.
sample = honest_ads[price_features].head(5)
our_prediction = sample.values @ price_model.coef_ + price_model.intercept_
sk_prediction = price_model.predict(sample)
print("наш прогноз :", np.round(our_prediction, 2))
print("sklearn прогноз:", np.round(sk_prediction, 2))
assert np.allclose(our_prediction, sk_prediction), "розрахунок розійшовся!"
print("✅ збігається")
наш прогноз : [ 4408.73 11318.23 6488.89 6488.89 3034.14] sklearn прогноз: [ 4408.73 11318.23 6488.89 6488.89 3034.14] ✅ збігається
6. Що модель відповість за межами даних¶
Модель бачила телефони 2018–2023 років із памʼяттю 64, 128 і 256 ГБ. Спитаймо її про те, чого вона не бачила ніколи, і подивимось, чи попередить вона нас хоч якось.
queries = pd.DataFrame({"year": [2021, 2018, 2046, 2004],
"memory_gb": [128, 256, 1024, 32]})
queries["прогноз, грн"] = np.round(price_model.predict(queries)).astype(int)
# скільки схожих оголошень модель узагалі бачила: той самий обсяг памʼяті
# і рік у межах ±1. Це те, чого модель ніколи не рахує сама
similar = []
for _, q in queries.iterrows():
same = ((honest_ads["memory_gb"] == q["memory_gb"])
& (honest_ads["year"].sub(q["year"]).abs() <= 1))
similar.append(int(same.sum()))
queries["схожих у даних"] = similar
print(queries.to_string(index=False))
train_r2 = price_model.score(honest_ads[price_features], honest_ads["price"])
print(f"\nR² на навчанні: {train_r2:.3f} — те саме число для всіх чотирьох рядків.")
print("Метрика якості міряє минулі приклади й про ці запити не знає нічого.")
year memory_gb прогноз, грн схожих у даних 2021 128 9591 90 2018 256 7158 64 2046 1024 72019 0 2004 32 -21836 0 R² на навчанні: 0.844 — те саме число для всіх чотирьох рядків. Метрика якості міряє минулі приклади й про ці запити не знає нічого.
Останні два рядки — те, заради чого вся ця практика. Модель не сказала «не знаю»: вона порахувала формулу й видала числа. Одне з них відʼємне, друге завелике для вживаного телефона. Схожих прикладів у даних — нуль, і побачити це мусили ми, а не вона.
Намалюймо це.
fig, ax = plt.subplots(figsize=(9, 5))
year_grid = np.arange(2004, 2047)
line = pd.DataFrame({"year": year_grid, "memory_gb": 128})
ax.plot(year_grid, price_model.predict(line), color="black", ls="--",
lw=2, label="що каже модель")
ax.scatter(honest_ads["year"], honest_ads["price"], s=12, alpha=0.4,
color="crimson", label="навчальні дані")
ax.axvspan(2017.5, 2023.5, color="teal", alpha=0.12, label="діапазон даних")
ax.axhline(0, color="gray", lw=1)
ax.set_xlabel("рік випуску")
ax.set_ylabel("ціна, грн")
ax.set_title("Пряма визначена скрізь — дані є лише в смузі")
ax.legend()
ax.grid(alpha=0.25)
plt.tight_layout()
plt.show()
print("формула не має країв — а дані мають")
формула не має країв — а дані мають
7. Підсумок¶
| правила руками | модель | |
|---|---|---|
| звідки береться правило | з твоєї голови | з прикладів у даних |
| скільки часу на оновлення | нарада й реліз | перенавчання за хвилини |
| чи можна пояснити рішення | так, це if |
лише через ваги |
| що робить поза даними | нічого не робить | впевнено вигадує |
Обидва підходи мають межу. Правила ламаються, коли їх стає забагато; модель ламається, коли даних мало або питання виходить за їхні межі.
Завдання¶
🟢 Рівень 1¶
Додай до rule_filter третє правило — наприклад, «ціна нижча за 80 % ринкової
і акаунту менше 30 днів» — і поміряй усі три числа знову на тестовій вибірці.
Зроблено, якщо: ти назвав, яке з трьох чисел покращилось, а яке погіршилось, і пояснив одним реченням чому.
🟡 Рівень 2¶
Додай моделі третю ознаку — memory_gb — і перевір, чи стало краще.
Порівняй count_hits до і після на тій самій тестовій вибірці.
Зроблено, якщо: є таблиця з двома рядками (дві ознаки / три ознаки) і висновок, чи допомогла нова ознака. Якщо не допомогла — поясни, чому обсяг памʼяті нічого не каже про шахрайство.
🔴 Рівень 3¶
Змоделюй упереджені дані. Додай колонку city з двома значеннями і зроби так,
щоб модератор позначав шахрайство в одному місті вдвічі частіше за однакової
поведінки продавців. Навчи модель із цією колонкою серед ознак.
Зроблено, якщо: ти показав числами, що модель позначає оголошення з цього міста частіше, ніж вони насправді бувають шахрайськими, і назвав, звідки взялась ця різниця.