Практика 01 · Що таке машинне навчання¶

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

Наскрізний приклад той самий, що в лекції: дошка оголошень про продаж вживаних телефонів. Серед оголошень є шахрайські, і їх треба відсіювати.

Що зробимо:

  1. Зберемо синтетичну дошку оголошень — звичайний DataFrame
  2. Напишемо фільтр шахрайства правилами руками й поміряємо, скільки він ловить
  3. Дамо ті самі дані моделі зі scikit-learn і порівняємо два стовпчики чисел
  4. Побудуємо крихітну регресію ціни й подивимось, що вона каже за межами даних
In [1]:
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 — це те, що колись проставив модератор. Саме вона й робить можливим навчання з учителем.

In [2]:
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.

In [3]:
# шахрайська знижка глибока, чесний торг — дрібний
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

Одна ознака нам ще потрібна, і її в таблиці немає: наскільки ціна нижча за ринкову. Ринкову ціну ми не знаємо, але можемо оцінити її з самих даних — як медіанну ціну серед оголошень із тим самим роком і обсягом памʼяті.

Медіана тут краща за середнє: шахрайські ціни її майже не зсувають.

In [4]:
# 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

Подивимось на дві ознаки очима — це та сама картинка, що в лекції.

In [5]:
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("шахрайські тиснуться вліво-вниз, але не всі — саме тут і буде помилка")
No description has been provided for this image
шахрайські тиснуться вліво-вниз, але не всі — саме тут і буде помилка

2. Фільтр правилами руками¶

Спершу зробимо так, як робили б без жодного машинного навчання: подивимось на картинку, помітимо закономірність і напишемо дві умови.

In [6]:
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

Число позначених саме по собі нічого не каже. Важливо інше: скільки з них справді шахрайські і скільки чесних продавців ми образили. Розкладемо на три числа руками, самими логічними масками.

In [7]:
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 — щоб побачити, що всередині немає магії.

In [8]:
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. Ті самі дані, але вчимо на прикладах¶

Тепер жодного написаного правила. Даємо моделі дві ознаки й колонку відповідей — і нехай межу шукає сама.

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

In [9]:
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 оголошень
модель навчена

Порівнюємо чесно: обидва підходи міряємо на одній і тій самій тестовій вибірці, якої модель не бачила.

In [10]:
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

Що саме «вивчила» модель? Три числа: два коефіцієнти й вільний член. Це і є те саме «правило», яке ми не писали. Знак коефіцієнта читається просто: чим більша ціна відносно ринкової, тим нижча оцінка шахрайства; чим старший акаунт — теж нижча.

In [11]:
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.

In [12]:
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. Крихітна регресія ціни¶

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

In [13]:
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 — звичайне множення й додавання.

In [14]:
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 ГБ. Спитаймо її про те, чого вона не бачила ніколи, і подивимось, чи попередить вона нас хоч якось.

In [15]:
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 — те саме число для всіх чотирьох рядків.
Метрика якості міряє минулі приклади й про ці запити не знає нічого.

Останні два рядки — те, заради чого вся ця практика. Модель не сказала «не знаю»: вона порахувала формулу й видала числа. Одне з них відʼємне, друге завелике для вживаного телефона. Схожих прикладів у даних — нуль, і побачити це мусили ми, а не вона.

Намалюймо це.

In [16]:
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("формула не має країв — а дані мають")
No description has been provided for this image
формула не має країв — а дані мають

7. Підсумок¶

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

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


Завдання¶

🟢 Рівень 1¶

Додай до rule_filter третє правило — наприклад, «ціна нижча за 80 % ринкової і акаунту менше 30 днів» — і поміряй усі три числа знову на тестовій вибірці.

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

🟡 Рівень 2¶

Додай моделі третю ознаку — memory_gb — і перевір, чи стало краще. Порівняй count_hits до і після на тій самій тестовій вибірці.

Зроблено, якщо: є таблиця з двома рядками (дві ознаки / три ознаки) і висновок, чи допомогла нова ознака. Якщо не допомогла — поясни, чому обсяг памʼяті нічого не каже про шахрайство.

🔴 Рівень 3¶

Змоделюй упереджені дані. Додай колонку city з двома значеннями і зроби так, щоб модератор позначав шахрайство в одному місті вдвічі частіше за однакової поведінки продавців. Навчи модель із цією колонкою серед ознак.

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