Практика · Передобробка даних> Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.mdБеремо ту саму дошку оголошень, що й у темі 08, — з усімаїї дірками, дублікатами й викидами, — і проходимо кожне рішення передобробки по черзі,щоразу друкуючи наслідки:1. дублікати й типи — кроки, у яких вибору майже немає;2. пропуски: три шляхи, і скільки рядків лишається після кожного;3. прапорець ціни_немає — безкоштовна ознака з порожнечі;4. категорії: пряме проти порядкового кодування;5. масштаб: стандартизація й мінімакс своїми руками, звірені з scikit-learn;6. викиди: обрізання й логарифм;7. витік через передобробку — та сама оцінка при двох порядках дій;8. конвеєр, який робить усе це правильно сам.Зерно генератора зафіксовано (default_rng(42)), тому числа будуть точно ті самі,що в лекції.¶

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, MinMaxScaler, OneHotEncoder
from sklearn.metrics import precision_score, recall_score, f1_score

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

pd.set_option("display.width", 120)
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}

модель = rng.choice(моделі, size=кількість, p=[0.24, 0.22, 0.18, 0.16, 0.12, 0.08])
рік = 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("типова ціна перших трьох:", типова_ціна[:3].round(0))
типова ціна перших трьох: [ 3763.  3060. 12071.]

Шахрай працює зі свіжого акаунта й ставить або різко занижену ціну («неймовірна знижка»),або завищену — під велику передоплату. Скарги надходять після того, як покупецьпостраждав: саме тому колонка «скарг» і виявилась витоком у темі 08.

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),
})
print("чиста таблиця:", дошка.shape, "· шахрайських:", int(дошка["шахрайське"].sum()))
чиста таблиця: (1200, 8) · шахрайських: 196
In [4]:
# 1. колекційні: запаковані флагмани семирічної давності, за них справді платять
колекційні = дошка.index[дошка["модель"] == "Gamma X"][:4]
дошка.loc[колекційні, ["рік", "стан", "памʼять_гб"]] = [2017, "нове", 512]
дошка.loc[колекційні, "ціна"] = [82000.0, 88000.0, 91000.0, 95000.0]
дошка.loc[колекційні, ["шахрайське", "скарг"]] = 0

# 2. одруки: у двох звичайних оголошень при введенні ціни додали зайвий нуль
одруки = дошка.index[(дошка["ціна"] > 7000) & (дошка["ціна"] < 9600)
                     & (дошка["шахрайське"] == 0)][:2]
дошка.loc[одруки, "ціна"] = дошка.loc[одруки, "ціна"] * 10

# 3. памʼять: у частині рядків одиниці виміру приїхали разом зі значенням
памʼять_текстом = дошка["памʼять_гб"].astype(str)
із_одиницями = rng.random(len(дошка)) < 0.18
памʼять_текстом[із_одиницями] = памʼять_текстом[із_одиницями] + " ГБ"
дошка["памʼять_гб"] = памʼять_текстом

# 4. ціна зникає в шахрайських оголошеннях набагато частіше, ніж у чесних
ймовірність_пропуску = np.where(дошка["шахрайське"] == 1, 0.25, 0.03)
дошка.loc[rng.random(len(дошка)) < ймовірність_пропуску, "ціна"] = np.nan

# 5. стан зникає просто так, без звʼязку з чим завгодно
дошка.loc[rng.random(len(дошка)) < 0.04, "стан"] = np.nan

# 6. дванадцять випадкових оголошень подані двічі
повтори = rng.choice(дошка.index, size=12, replace=False)
дошка = pd.concat([дошка, дошка.loc[повтори]], ignore_index=True)

print("та сама брудна таблиця, що в темі 08:", дошка.shape)
print(дошка.isna().sum()[lambda s: s > 0])
та сама брудна таблиця, що в темі 08: (1212, 8)
стан     48
ціна    101
dtype: int64

---# Крок нульовий: те, у чому вибору немає## 2 · Дублікати й типи колонокЦі два кроки нічого не вивчають із даних: вони роблять із кожним рядком те саменезалежно від решти таблиці. Тому їх можна (і треба) робити першими — ще до будь-якогоподілу на навчальну й тестову частини.

In [5]:
до = len(дошка)
дошка = дошка.drop_duplicates().reset_index(drop=True)
print("дублікатів прибрано:", до - len(дошка), "· лишилось рядків:", len(дошка))

# суфікс « ГБ» треба відрізати ДО переведення в число: to_numeric із errors="coerce"
# мовчки перетворив би 215 справжніх значень на пропуски
дошка["памʼять_гб"] = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
print("тип памʼяті тепер:", дошка["памʼять_гб"].dtype)

print("\nпропусків після цих двох кроків:")
print(дошка.isna().sum()[lambda s: s > 0])
дублікатів прибрано: 12 · лишилось рядків: 1200
тип памʼяті тепер: int64

пропусків після цих двох кроків:
стан     48
ціна    100
dtype: int64

Зверни увагу: пропусків у ціні стало 100, а не 101, як було в темі 08. Один іздванадцяти дублікатів був рядком без ціни — і зник разом із дублікатом.---# Пропуски## 3 · Шлях перший: викинути рядки з діркамиНайчесніший на вигляд варіант. Порахуймо не лише скільки рядків лишиться, а й які саме.

In [6]:
повні_рядки = дошка.dropna()

print("рядків було      :", len(дошка))
print("рядків лишилось  :", len(повні_рядки))
print("втрачено         :", len(дошка) - len(повні_рядки))
print()
print("шахрайських було     :", int(дошка["шахрайське"].sum()))
print("шахрайських лишилось :", int(повні_рядки["шахрайське"].sum()))
print("частка шахрайських: %.2f %% → %.2f %%"
      % (дошка["шахрайське"].mean() * 100, повні_рядки["шахрайське"].mean() * 100))
рядків було      : 1200
рядків лишилось  : 1055
втрачено         : 145

шахрайських було     : 195
шахрайських лишилось : 135
частка шахрайських: 16.25 % → 12.80 %

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

In [7]:
# частка шахрайських окремо серед рядків із пропуском і без нього
for стовпець in ["ціна", "стан"]:
    немає = дошка[стовпець].isna()
    print("%-6s: значення є — %5.1f %% шахрайських (%d рядків) · "
          "значення немає — %5.1f %% (%d рядків)"
          % (стовпець,
             дошка.loc[~немає, "шахрайське"].mean() * 100, int((~немає).sum()),
             дошка.loc[немає, "шахрайське"].mean() * 100, int(немає.sum())))
ціна  : значення є —  12.6 % шахрайських (1100 рядків) · значення немає —  56.0 % (100 рядків)
стан  : значення є —  16.4 % шахрайських (1152 рядків) · значення немає —  12.5 % (48 рядків)

4 · Шлях другий: викинути стовпецьЯкщо порожньо в половині рядків, заповнювати нема чого — ти вигадуєш більше, ніж знаєш.Орієнтир: понад 50 % пропусків — колонка радше шкодить; менше 10 % — заповнюй спокійно.¶

In [8]:
частка_пропусків = (дошка.isna().mean() * 100).round(1)
print("частка пропусків у кожному стовпці, %:")
print(частка_пропусків[частка_пропусків > 0])
print("\nобидві колонки нижче 10 % — лишаємо обидві")
частка пропусків у кожному стовпці, %:
стан    4.0
ціна    8.3
dtype: float64

обидві колонки нижче 10 % — лишаємо обидві

5 · Шлях третій: заповнити — і чим самеСереднє чи медіана? На скошеному розподілі це не косметичне питання. Подивімось,що кожен варіант робить із колонкою.¶

In [9]:
ціни = дошка["ціна"]

середнє = ціни.mean()
медіана = ціни.median()

print("середнє : %.1f грн" % середнє)
print("медіана : %.1f грн" % медіана)
print("дешевших за середнє: %d з %d = %.1f %%"
      % ((ціни.dropna() < середнє).sum(), ціни.notna().sum(),
         (ціни.dropna() < середнє).mean() * 100))
print()
# кошик шириною 1 250 грн — той самий, що на картинці в лекції
кошик = 1250
номер_середнього = int(середнє // кошик)
номер_медіани = int(медіана // кошик)
у_кошику = ціни.dropna().floordiv(кошик).astype(int).value_counts()
print("у кошик середнього (%d–%d грн) впаде 100 вигаданих цін до %d справжніх"
      % (номер_середнього * кошик, (номер_середнього + 1) * кошик, у_кошику[номер_середнього]))
print("у кошик медіани   (%d–%d грн) — ті самі 100 до %d справжніх"
      % (номер_медіани * кошик, (номер_медіани + 1) * кошик, у_кошику[номер_медіани]))
середнє : 6498.2 грн
медіана : 4000.0 грн
дешевших за середнє: 755 з 1100 = 68.6 %

у кошик середнього (6250–7500 грн) впаде 100 вигаданих цін до 75 справжніх
у кошик медіани   (3750–5000 грн) — ті самі 100 до 148 справжніх

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

In [10]:
межі = np.arange(0, 25001, 1250)
fig, осі = plt.subplots(1, 3, figsize=(14, 3.4), sharey=True)
для_показу = [("як є (100 дірок)", ціни.dropna()),
              ("заповнено середнім", ціни.fillna(середнє)),
              ("заповнено медіаною", ціни.fillna(медіана))]
for вісь, (назва, значення) in zip(осі, для_показу):
    вісь.hist(значення[значення <= 25000], bins=межі, color="#c2185b", alpha=0.55,
              edgecolor="#c2185b")
    вісь.set_title(назва, fontsize=11)
    вісь.set_xlabel("ціна, грн")
осі[0].set_ylabel("оголошень")
plt.tight_layout()
plt.show()
No description has been provided for this image

Для текстової колонки вибір інший: мода (найчастіше значення) або окрема категорія«невідомо». Мода робить найчастіше значення ще частішим — перевіримо, наскільки.

In [11]:
мода_стану = дошка["стан"].mode()[0]
було = (дошка["стан"] == мода_стану).sum() / дошка["стан"].notna().sum() * 100
стало = (дошка["стан"].fillna(мода_стану) == мода_стану).mean() * 100
print("найчастіший стан:", мода_стану)
print("його частка: %.1f %% → %.1f %% після заповнення модою" % (було, стало))
print()
print("варіант без вигадування — окрема категорія:")
print(дошка["стан"].fillna("невідомо").value_counts())
найчастіший стан: добре
його частка: 41.9 % → 44.2 % після заповнення модою

варіант без вигадування — окрема категорія:
стан
добре         483
дуже добре    364
задовільне    200
нове          105
невідомо       48
Name: count, dtype: int64

---# Пропуск як ознака## 6 · Прапорець, який коштує один рядокЗначення ціни втрачене назавжди. А от факт його відсутності лежить у таблиці — івідрізняє шахрая краще, ніж багато справжніх колонок. Записати його треба дозаповнення: після нього прапорець вийде з самих нулів.

In [12]:
дошка["ціни_немає"] = дошка["ціна"].isna().astype(int)
дошка["стану_немає"] = дошка["стан"].isna().astype(int)

for прапорець in ["ціни_немає", "стану_немає"]:
    print("кореляція %-12s з таргетом: %+.3f"
          % (прапорець, дошка[прапорець].corr(дошка["шахрайське"])))

print("\nдля порівняння — справжні числові колонки:")
for колонка in ["вік_акаунта", "рік", "памʼять_гб"]:
    print("кореляція %-12s з таргетом: %+.3f"
          % (колонка, дошка[колонка].corr(дошка["шахрайське"])))
кореляція ціни_немає   з таргетом: +0.325
кореляція стану_немає  з таргетом: -0.021

для порівняння — справжні числові колонки:
кореляція вік_акаунта  з таргетом: -0.114
кореляція рік          з таргетом: +0.038
кореляція памʼять_гб   з таргетом: +0.076

Прапорець ціни_немає повʼязаний із таргетом сильніше за будь-яку справжню числовуколонку в цій таблиці — і він не коштував нічого. Прапорець стану_немає не несе нічого,і це теж корисно знати: робити такі колонки на кожен стовпець без перевірки не варто.Тепер, коли інформація збережена, дірки можна закривати. Стан закриємо одразу — окремоюкатегорією «невідомо». А ось ціну поки лишимо як є, і на це є дві причини.Перша: усі статистики колонки, які ми рахуватимемо далі — середнє, розкид, квартилі,скошеність, — треба рахувати на справжніх значеннях. Сто однакових підставлених цінштучно звузили б розкид і зсунули б квартилі.Друга, важливіша: заповнення навчається на даних, а отже, його місце — усерединіконвеєра, після поділу на train і test. Саме це ми й побачимо в розділах 11 і 12.

In [13]:
дошка["стан"] = дошка["стан"].fillna("невідомо")
print("пропусків у стані не лишилось:", int(дошка["стан"].isna().sum()))
print("пропусків у ціні поки що     :", int(дошка["ціна"].isna().sum()))
print(дошка.head(3))
пропусків у стані не лишилось: 0
пропусків у ціні поки що     : 100
        модель   рік      стан  памʼять_гб  вік_акаунта  скарг     ціна  шахрайське  ціни_немає  стану_немає
0  Beta 12 Pro  2017     добре         256          186      0   3090.0           0           0            0
1      Alfa A7  2021  невідомо         128          394      0   2810.0           0           0            1
2      Gamma X  2017      нове         512           44      0  82000.0           0           0            0

---# Категорії## 7 · Пряме кодування проти порядковогоМодель приймає лише числа, тому «модель» і «стан» треба перевести в цифри. Спершу —пряме кодування (one-hot): колонка на кожне значення.

In [14]:
пряме = pd.get_dummies(дошка[["модель", "стан"]], prefix=["модель", "стан"]).astype(int)
print("було 2 текстові колонки, стало", пряме.shape[1])
print(list(пряме.columns))
print()
print(пряме.head(3))
print("\nу кожному рядку рівно одна одиниця на кожну вихідну колонку:",
      bool((пряме.filter(like="модель_").sum(axis=1) == 1).all()))
було 2 текстові колонки, стало 11
['модель_Alfa A5', 'модель_Alfa A7', 'модель_Beta 12', 'модель_Beta 12 Pro', 'модель_Gamma X', 'модель_Gamma X Ultra', 'стан_добре', 'стан_дуже добре', 'стан_задовільне', 'стан_невідомо', 'стан_нове']

   модель_Alfa A5  модель_Alfa A7  модель_Beta 12  модель_Beta 12 Pro  модель_Gamma X  модель_Gamma X Ultra  \
0               0               0               0                   1               0                     0   
1               0               1               0                   0               0                     0   
2               0               0               0                   0               1                     0   

   стан_добре  стан_дуже добре  стан_задовільне  стан_невідомо  стан_нове  
0           1                0                0              0          0  
1           0                0                0              1          0  
2           0                0                0              0          1  

у кожному рядку рівно одна одиниця на кожну вихідну колонку: True

Те саме вміє OneHotEncoder зі scikit-learn. Переконаймось, що це буквально одне й те саме.

In [15]:
кодувальник = OneHotEncoder(sparse_output=False, dtype=int)
бібліотечне = кодувальник.fit_transform(дошка[["модель", "стан"]])

# порядок колонок у get_dummies і в OneHotEncoder однаковий — обидва сортують значення
assert np.array_equal(пряме.values, бібліотечне), "кодування розійшлось!"
print("✅ наше get_dummies і OneHotEncoder дають однакову матрицю",
      бібліотечне.shape)
✅ наше get_dummies і OneHotEncoder дають однакову матрицю (1200, 11)

Тепер порядкове кодування. Для «стану» воно правильне: порядок «задовільне → нове»справжній. Для «моделі» — ні, і зараз ми побачимо, що саме воно стверджує.

In [16]:
порядок_стану = {"задовільне": 0, "добре": 1, "дуже добре": 2, "нове": 3}
дошка["стан_код"] = дошка["стан"].map(порядок_стану)   # «невідомо» лишиться NaN — це чесно

медіанні_ціни = дошка.groupby("модель")["ціна"].median().sort_values()
код_моделі = {назва: номер for номер, назва in enumerate(медіанні_ціни.index)}

порівняння = pd.DataFrame({
    "код": pd.Series(код_моделі),
    "медіанна ціна": медіанні_ціни.round(0),
})
порівняння["крок коду"] = порівняння["код"].diff()
порівняння["крок ціни"] = порівняння["медіанна ціна"].diff()
print(порівняння)
               код  медіанна ціна  крок коду  крок ціни
Alfa A5          0         1845.0        NaN        NaN
Alfa A7          1         2810.0        1.0      965.0
Beta 12          2         4215.0        1.0     1405.0
Beta 12 Pro      3         6290.0        1.0     2075.0
Gamma X          4         9350.0        1.0     3060.0
Gamma X Ultra    5        11540.0        1.0     2190.0

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

In [17]:
# код каже, що Beta 12 (2) лежить рівно посередині між Alfa A5 (0) і Gamma X (4)
середина_за_кодом = (медіанні_ціни["Alfa A5"] + медіанні_ціни["Gamma X"]) / 2
насправді = медіанні_ціни["Beta 12"]
print("«середина» між Alfa A5 і Gamma X за цінами: %.0f грн" % середина_за_кодом)
print("справжня медіанна ціна Beta 12          : %.0f грн" % насправді)
print("розбіжність: %.0f грн, тобто %.0f %%"
      % (середина_за_кодом - насправді, (середина_за_кодом / насправді - 1) * 100))
«середина» між Alfa A5 і Gamma X за цінами: 5598 грн
справжня медіанна ціна Beta 12          : 4215 грн
розбіжність: 1382 грн, тобто 33 %

---# Масштаб## 8 · Стандартизація й мінімакс своїми рукамиОбидва перетворення — одна формула кожне. Порахуємо їх руками й звіримо з бібліотекою:всередині scikit-learn немає ніякої магії.

In [18]:
# рядки з відомою ціною: масштаб колонки вимірюємо на справжніх значеннях
відомі = дошка[дошка["ціни_немає"] == 0]
числові = ["ціна", "вік_акаунта", "рік", "памʼять_гб"]
X = відомі[числові].to_numpy(dtype=float)
print("рядків із відомою ціною:", len(X))

# стандартизація: відняти середнє колонки й поділити на її стандартне відхилення
наша_стандартизація = (X - X.mean(axis=0)) / X.std(axis=0)
бібліотечна = StandardScaler().fit_transform(X)
assert np.allclose(наша_стандартизація, бібліотечна), "стандартизація розійшлась!"

# мінімакс: відняти мінімум і поділити на розмах — колонка лягає у відрізок [0, 1]
наш_мінімакс = (X - X.min(axis=0)) / (X.max(axis=0) - X.min(axis=0))
бібліотечний = MinMaxScaler().fit_transform(X)
assert np.allclose(наш_мінімакс, бібліотечний), "мінімакс розійшовся!"

print("✅ обидва перетворення збігаються з scikit-learn")
print()
зведення = pd.DataFrame({
    "середнє, як є": X.mean(axis=0).round(1),
    "розкид, як є": X.std(axis=0).round(1),
    "середнє після стандартизації": наша_стандартизація.mean(axis=0).round(6),
    "розкид після стандартизації": наша_стандартизація.std(axis=0).round(6),
    "мінімум після мінімаксу": наш_мінімакс.min(axis=0),
    "максимум після мінімаксу": наш_мінімакс.max(axis=0),
}, index=числові)
print(зведення)
рядків із відомою ціною: 1100
✅ обидва перетворення збігаються з scikit-learn

             середнє, як є  розкид, як є  середнє після стандартизації  розкид після стандартизації  \
ціна                6498.2        8685.0                           0.0                          1.0   
вік_акаунта          404.7         406.6                           0.0                          1.0   
рік                 2020.5           2.3                          -0.0                          1.0   
памʼять_гб           169.5         123.7                           0.0                          1.0   

             мінімум після мінімаксу  максимум після мінімаксу  
ціна                             0.0                       1.0  
вік_акаунта                      0.0                       1.0  
рік                              0.0                       1.0  
памʼять_гб                       0.0                       1.0  

9 · Масштаб змінює відповідь, а не картинкуТри справжні оголошення з дошки. Питання просте: яке з двох ближче до базового?¶

In [19]:
трійка = pd.DataFrame(
    [[4620.0, 459], [4650.0, 273], [5480.0, 466]],
    index=["базове", "А", "Б"], columns=["ціна", "вік_акаунта"])
print(трійка)

# статистики — по рядках із відомою ціною, тобто по справжніх значеннях колонки
середні = відомі[["ціна", "вік_акаунта"]].mean().to_numpy()
розкиди = відомі[["ціна", "вік_акаунта"]].std(ddof=0).to_numpy()
мінімуми = відомі[["ціна", "вік_акаунта"]].min().to_numpy()
максимуми = відомі[["ціна", "вік_акаунта"]].max().to_numpy()

def відстані(значення):
    '''Відстань від базового оголошення до А і до Б за теоремою Піфагора.'''
    база = значення[0]
    return np.sqrt(((значення[1:] - база) ** 2).sum(axis=1))

сирі = трійка.to_numpy()
режими = {
    "як є": сирі,
    "стандартизація": (сирі - середні) / розкиди,
    "мінімакс": (сирі - мінімуми) / (максимуми - мінімуми),
}
for назва, значення in режими.items():
    до_А, до_Б = відстані(значення)
    print("%-15s до А = %9.4f · до Б = %9.4f · ближче: %s"
          % (назва, до_А, до_Б, "А" if до_А < до_Б else "Б"))
          ціна  вік_акаунта
базове  4620.0          459
А       4650.0          273
Б       5480.0          466
як є            до А =  188.4038 · до Б =  860.0285 · ближче: А
стандартизація  до А =    0.4575 · до Б =    0.1005 · ближче: Б
мінімакс        до А =    0.0529 · до Б =    0.0093 · ближче: Б

Сирі одиниці кажуть «А», обидва масштабування — «Б». Причина видно на числах: різницяв 860 грн — це лише 0.10 розкиду цін, а різниця в 186 днів — 0.46 розкиду віку акаунтів.Порівнювати гривні з днями безглуздо, тому перша відповідь просто не має сенсу.

In [20]:
різниця_ціни = 5480 - 4620
різниця_віку = 459 - 273
print("860 грн  = %.2f розкиду цін (розкид %.0f грн)" % (різниця_ціни / розкиди[0], розкиди[0]))
print("186 днів = %.2f розкиду віку (розкид %.0f днів)" % (різниця_віку / розкиди[1], розкиди[1]))
print()
частка_низько = ((відомі["ціна"] - мінімуми[0]) / (максимуми[0] - мінімуми[0]) < 0.1).mean()
print("мінімакс: %.1f %% усіх цін лежать нижче 0.1 — шкалу зʼїв колекційний телефон за 95 000 грн"
      % (частка_низько * 100))
860 грн  = 0.10 розкиду цін (розкид 8685 грн)
186 днів = 0.46 розкиду віку (розкид 407 днів)

мінімакс: 81.7 % усіх цін лежать нижче 0.1 — шкалу зʼїв колекційний телефон за 95 000 грн

---# Викиди## 10 · Обрізати чи логарифмуватиВикиди ми знайшли ще в темі 08. Тепер вирішуємо, що з ними робити. Порахуємо межуміжквартильного розмаху й порівняємо три варіанти: лишити, обрізати, логарифмувати.

In [21]:
ціни_відомі = дошка["ціна"].dropna()

q1, q3 = ціни_відомі.quantile([0.25, 0.75])
розмах = q3 - q1
верхня_межа = q3 + 1.5 * розмах
за_межею = (ціни_відомі > верхня_межа).sum()
print("Q1 = %.0f · Q3 = %.0f · IQR = %.0f · верхня межа = %.0f грн"
      % (q1, q3, розмах, верхня_межа))
print("за межею: %d оголошень = %.1f %% колонки"
      % (за_межею, за_межею / len(ціни_відомі) * 100))

def скошеність(значення):
    '''Наскільки хвіст витягнутий праворуч. Нуль — симетрія.'''
    з = np.asarray(значення, dtype=float)
    return float((((з - з.mean()) / з.std()) ** 3).mean())

обрізана = ціни_відомі.clip(upper=верхня_межа)
логарифм = np.log10(ціни_відомі)

print()
print("%-16s середнє %8.0f · максимум %8.0f · скошеність %6.2f"
      % ("як є", ціни_відомі.mean(), ціни_відомі.max(), скошеність(ціни_відомі)))
print("%-16s середнє %8.0f · максимум %8.0f · скошеність %6.2f"
      % ("обрізано", обрізана.mean(), обрізана.max(), скошеність(обрізана)))
print("%-16s середнє %8.3f · максимум %8.3f · скошеність %6.2f"
      % ("логарифм", логарифм.mean(), логарифм.max(), скошеність(логарифм)))
print()
print("середнє логарифмів назад у гривні: %.0f грн (медіана колонки: %.0f грн)"
      % (10 ** логарифм.mean(), ціни_відомі.median()))
Q1 = 2168 · Q3 = 7725 · IQR = 5558 · верхня межа = 16061 грн
за межею: 75 оголошень = 6.8 % колонки

як є             середнє     6498 · максимум    95000 · скошеність   5.32
обрізано         середнє     5557 · максимум    16061 · скошеність   1.10
логарифм         середнє    3.610 · максимум    4.978 · скошеність   0.10

середнє логарифмів назад у гривні: 4075 грн (медіана колонки: 4000 грн)
In [22]:
fig, осі = plt.subplots(1, 2, figsize=(11, 3.4))
осі[0].hist(ціни_відомі[ціни_відомі <= 25000], bins=20, color="#c2185b", alpha=0.55,
            edgecolor="#c2185b")
осі[0].set_title("ціна як є (вісь обрізана)", fontsize=11)
осі[0].set_xlabel("ціна, грн")
осі[1].hist(логарифм, bins=24, color="#0f766e", alpha=0.55, edgecolor="#0f766e")
осі[1].set_title("log10(ціна) — уся колонка", fontsize=11)
осі[1].set_xlabel("log10(ціна)")
осі[0].set_ylabel("оголошень")
plt.tight_layout()
plt.show()
No description has been provided for this image

---# Витік через передобробку## 11 · Задача, у якій немає жодної моделіПравило: оголошення підозріле, якщо його ціна нижча за поріг П₁ або вища за поріг П₂.З теми 08 ми знаємо, що звʼязок ціни з шахрайством U-подібний, тому пороги саме два.Вигадувати їх ми не будемо — підберемо за даними, окремо для кожної пари«модель + стан»: для Alfa A5 і для Gamma X Ultra «дешево» означає різні суми.Це і є той крок, який навчається. І зараз ми порівняємо два порядки дій.

In [23]:
НИЖНІ = np.arange(0.02, 0.41, 0.02)      # 20 кандидатів на нижній поріг
ВЕРХНІ = np.arange(0.60, 0.99, 0.02)     # 20 кандидатів на верхній

def підібрати_пороги(ціни, мітки):
    '''Перебирає всі 400 пар квантилів і повертає ту, що дає найбільший F1.'''
    низ = np.quantile(ціни, НИЖНІ)
    верх = np.quantile(ціни, ВЕРХНІ)
    # підозра[i, j, k] — чи позначає k-те оголошення пара порогів (низ[i], верх[j])
    підозра = (ціни[None, None, :] < низ[:, None, None]) | (ціни[None, None, :] > верх[None, :, None])
    tp = (підозра & (мітки == 1)).sum(axis=2)
    fp = (підозра & (мітки == 0)).sum(axis=2)
    fn = int((мітки == 1).sum()) - tp
    f1 = np.where(tp > 0, 2 * tp / np.maximum(2 * tp + fp + fn, 1), 0.0)
    i, j = np.unravel_index(np.argmax(f1), f1.shape)
    return низ[i], верх[j]

def навчити_пороги(ціни, мітки, групи):
    '''Своя пара порогів для кожної групи «модель + стан».'''
    return {група: підібрати_пороги(ціни[групи == група], мітки[групи == група])
            for група in np.unique(групи)}

def застосувати_пороги(ціни, групи, пороги, запасні):
    позначено = np.zeros(len(ціни), dtype=int)
    for група in np.unique(групи):
        низ, верх = пороги.get(група, запасні)   # незнайомій групі даємо спільні пороги
        у_групі = групи == група
        позначено[у_групі] = ((ціни[у_групі] < низ) | (ціни[у_групі] > верх)).astype(int)
    return позначено

групи = (дошка["модель"] + " · " + дошка["стан"]).to_numpy()
print("груп «модель + стан»:", len(np.unique(групи)))
print("порогів, які треба вивчити:", len(np.unique(групи)) * 2)
print("найменша група:", pd.Series(групи).value_counts().min(), "рядків")
груп «модель + стан»: 30
порогів, які треба вивчити: 60
найменша група: 5 рядків

Тепер повертаємось до сирої ціни (з дірками) — бо заповнення теж навчається, і йоготеж треба буде порахувати двома способами.

In [24]:
сира_ціна = дошка["ціна"].to_numpy(dtype=float)      # сто дірок так і лишились на місці
таргет = дошка["шахрайське"].to_numpy()

навчальні, тестові = train_test_split(
    np.arange(len(дошка)), test_size=0.25, random_state=42, stratify=таргет)
print("train:", len(навчальні), "рядків · test:", len(тестові), "рядків")
print("шахрайських у тесті:", int(таргет[тестові].sum()))
train: 900 рядків · test: 300 рядків
шахрайських у тесті: 49

Неправильно: спершу порахували на всьому, потім поділили¶

In [25]:
# медіана для заповнення береться з УСІЄЇ таблиці — включно з тестовими рядками
медіана_на_всьому = np.nanmedian(сира_ціна)
ціна_на_всьому = np.where(np.isnan(сира_ціна), медіана_на_всьому, сира_ціна)

запасні_на_всьому = підібрати_пороги(ціна_на_всьому, таргет)
пороги_на_всьому = навчити_пороги(ціна_на_всьому, таргет, групи)

позначено = застосувати_пороги(ціна_на_всьому[тестові], групи[тестові],
                               пороги_на_всьому, запасні_на_всьому)
неправильно = (precision_score(таргет[тестові], позначено),
               recall_score(таргет[тестові], позначено),
               f1_score(таргет[тестові], позначено))
print("медіана заповнення: %.0f грн" % медіана_на_всьому)
print("precision %.3f · recall %.3f · F1 %.3f" % неправильно)
медіана заповнення: 4000 грн
precision 0.571 · recall 0.735 · F1 0.643

Правильно: спершу поділили, потім вивчили — лише на train¶

In [26]:
медіана_на_train = np.nanmedian(сира_ціна[навчальні])
ціна_train = np.where(np.isnan(сира_ціна[навчальні]), медіана_на_train, сира_ціна[навчальні])
ціна_test = np.where(np.isnan(сира_ціна[тестові]), медіана_на_train, сира_ціна[тестові])

запасні_train = підібрати_пороги(ціна_train, таргет[навчальні])
пороги_train = навчити_пороги(ціна_train, таргет[навчальні], групи[навчальні])

позначено = застосувати_пороги(ціна_test, групи[тестові], пороги_train, запасні_train)
правильно = (precision_score(таргет[тестові], позначено),
             recall_score(таргет[тестові], позначено),
             f1_score(таргет[тестові], позначено))
print("медіана заповнення: %.0f грн" % медіана_на_train)
print("precision %.3f · recall %.3f · F1 %.3f" % правильно)
print()
print("завищення F1: %+.3f — це %.0f %% від чесної оцінки"
      % (неправильно[2] - правильно[2],
         (неправильно[2] - правильно[2]) / правильно[2] * 100))
медіана заповнення: 3975 грн
precision 0.418 · recall 0.571 · F1 0.483

завищення F1: +0.160 — це 33 % від чесної оцінки

Різниця — третина оцінки, і вона взялась із двох дрібниць: медіана 4 000 замість 3 975 і60 порогів, підібраних із заглядуванням у тест. Один поділ міг бути й невдалим збігом,тому перевіримо на двадцяти.

In [27]:
завищення = []
for зерно in range(20):
    a, b = train_test_split(np.arange(len(дошка)), test_size=0.25,
                            random_state=зерно, stratify=таргет)
    # неправильний порядок
    ц = np.where(np.isnan(сира_ціна), np.nanmedian(сира_ціна), сира_ціна)
    п = навчити_пороги(ц, таргет, групи)
    з = підібрати_пороги(ц, таргет)
    погано = f1_score(таргет[b], застосувати_пороги(ц[b], групи[b], п, з))
    # правильний порядок
    м = np.nanmedian(сира_ціна[a])
    ц_a = np.where(np.isnan(сира_ціна[a]), м, сира_ціна[a])
    ц_b = np.where(np.isnan(сира_ціна[b]), м, сира_ціна[b])
    п2 = навчити_пороги(ц_a, таргет[a], групи[a])
    з2 = підібрати_пороги(ц_a, таргет[a])
    добре = f1_score(таргет[b], застосувати_пороги(ц_b, групи[b], п2, з2))
    завищення.append(погано - добре)

завищення = np.array(завищення)
print("середнє завищення F1 по 20 поділах: %+.3f" % завищення.mean())
print("оцінку завищено у %d поділах із 20" % int((завищення > 0).sum()))
print("найменше %+.3f · найбільше %+.3f" % (завищення.min(), завищення.max()))
середнє завищення F1 по 20 поділах: +0.149
оцінку завищено у 20 поділах із 20
найменше +0.086 · найбільше +0.298

Завищення не просто велике — воно завжди в один бік. Це і робить помилку такоюпідступною: вона ніколи не виглядає як помилка.---# Конвеєр## 12 · Об'єкт, який не дає помилитисьТримати порядок дій у голові на десяти колонках і чотирьох перетвореннях безнадійно.Pipeline разом із ColumnTransformer складають усі кроки в один ланцюжок із двомаметодами: fit — «вивчи все, що треба вивчити», transform — «застосуй вивчене».

In [28]:
вихідні = дошка[["ціна", "модель", "стан", "рік",
                 "памʼять_гб", "вік_акаунта", "ціни_немає"]].copy()

числові_колонки = ["ціна", "вік_акаунта", "рік", "памʼять_гб", "ціни_немає"]
текстові_колонки = ["модель", "стан"]

конвеєр = ColumnTransformer([
    ("числа", Pipeline([
        ("заповнити", SimpleImputer(strategy="median")),
        ("масштаб", StandardScaler()),
    ]), числові_колонки),
    ("текст", Pipeline([
        ("заповнити", SimpleImputer(strategy="constant", fill_value="невідомо")),
        ("однобітно", OneHotEncoder(handle_unknown="ignore")),
    ]), текстові_колонки),
])

# fit бачить ЛИШЕ навчальні рядки — це вся суть
конвеєр.fit(вихідні.iloc[навчальні])
train_готовий = конвеєр.transform(вихідні.iloc[навчальні])
test_готовий = конвеєр.transform(вихідні.iloc[тестові])

print("колонок на вході:", вихідні.shape[1], "· на виході:", train_готовий.shape[1])
print("train:", train_готовий.shape, "· test:", test_готовий.shape)
колонок на вході: 7 · на виході: 16
train: (900, 16) · test: (300, 16)

Найцікавіше — подивитись, що саме конвеєр вивчив і на чому.

In [29]:
заповнювач = конвеєр.named_transformers_["числа"].named_steps["заповнити"]
масштаб = конвеєр.named_transformers_["числа"].named_steps["масштаб"]

print("медіана ціни, яку запамʼятав конвеєр: %.0f грн" % заповнювач.statistics_[0])
print("медіана ціни по всій таблиці       : %.0f грн" % np.nanmedian(сира_ціна))
print("→ конвеєр узяв навчальну, а не загальну")
print()
print("середнє й відхилення ціни, вивчені конвеєром: %.1f · %.1f"
      % (масштаб.mean_[0], np.sqrt(масштаб.var_[0])))
print()
print("ціна після масштабування:")
print("  на train: середнє %+.4f, відхилення %.4f  ← рівно 0 і 1, бо саме тут учились"
      % (train_готовий[:, 0].mean(), train_готовий[:, 0].std()))
print("  на test : середнє %+.4f, відхилення %.4f  ← не рівно, і це доказ чесності"
      % (test_готовий[:, 0].mean(), test_готовий[:, 0].std()))
медіана ціни, яку запамʼятав конвеєр: 3975 грн
медіана ціни по всій таблиці       : 4000 грн
→ конвеєр узяв навчальну, а не загальну

середнє й відхилення ціни, вивчені конвеєром: 6130.6 · 8193.7

ціна після масштабування:
  на train: середнє -0.0000, відхилення 1.0000  ← рівно 0 і 1, бо саме тут учились
  на test : середнє +0.0768, відхилення 1.0697  ← не рівно, і це доказ чесності

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

In [30]:
наше_заповнення = np.where(np.isnan(сира_ціна[тестові]), медіана_на_train, сира_ціна[тестові])
наше_масштабування = (наше_заповнення - масштаб.mean_[0]) / np.sqrt(масштаб.var_[0])

assert np.allclose(наше_масштабування, test_готовий[:, 0]), "конвеєр робить не те, що ми думали!"
print("✅ ручний розрахунок і конвеєр збігаються на всіх", len(тестові), "тестових рядках")
✅ ручний розрахунок і конвеєр збігаються на всіх 300 тестових рядках

---# Завдання## 🟢 Рівень 1 — База1. Заповни пропуски ціни середнім замість медіани й повтори розділ 11 (витік). Чи змінилась різниця між правильним і неправильним порядком?2. Побудуй прапорець памʼяті_немає для колонки памʼяті (там пропусків немає — має вийти колонка з самих нулів). Порахуй її кореляцію з таргетом і поясни отримане число.3. Порахуй, скільки колонок дасть пряме кодування, якщо додати до нього ще й рік як категорію. Чи варто так робити?## 🟡 Рівень 2 — Плюс4. Заміни в конвеєрі StandardScaler на MinMaxScaler і подивись на діапазон тестової частини після transform. Чи всі значення лежать у відрізку [0, 1]? Поясни, чому.5. Додай у конвеєр окремий крок, який логарифмує ціну до масштабування (FunctionTransformer(np.log1p)). Порівняй скошеність колонки до й після.6. Повтори розділ 11, але з порогами на модель (6 груп, 12 чисел) замість «модель + стан» (30 груп, 60 чисел). Наскільки менше стало завищення?## 🔴 Рівень 3 — Виклик7. Напиши власний клас Заповнювач із методами fit(X) і transform(X), який запамʼятовує медіану кожної числової колонки і підставляє її. Звір результат із SimpleImputer(strategy="median") через assert np.allclose(...).8. Побудуй графік: по горизонталі — скільки чисел вивчається з даних (2, 12, 60), по вертикалі — середнє завищення F1 по 20 поділах. Чи росте залежність? Додай четверту точку: пороги на кожну пару «модель + рік».9. Придумай і перевір на цих даних ситуацію, у якій видалення рядків із пропусками краще за заповнення. Підказка: подивись, що станеться з precision, якщо в тесті лишити тільки рядки з відомою ціною.