Практика · Розвідка даних (EDA)¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Ми зберемо ту саму дошку оголошень про вживані телефони, що й у лекції, — навмисно «брудну», як буває в житті, — і пройдемо по ній розвідку крок за кроком:
- побудуємо таблицю: чисту частину, шахрайські оголошення й бруд;
- перший погляд:
shape,head,dtypes,info,describe, дублікати; - пропуски: скільки, де — і головне, чи вони випадкові;
- розподіли: гістограма, середнє проти медіани;
- викиди: правило міжквартильного розмаху, порахований руками;
- звʼязки: кореляційна матриця й те, чого вона не бачить;
- витік даних: колонка, якою не можна користуватись.
Усі числа тут ті самі, що в лекції: генератор випадкових чисел зафіксовано зерном 42.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
rng = np.random.default_rng(42)
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 12)
print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3
1 · Будуємо дошку оголошень¶
Спершу — «чесна» частина: шість моделей телефонів, рік випуску, стан, памʼять і вік акаунта продавця. Ціна рахується не навмання, а за зрозумілим правилом: беремо ціну нового телефона й множимо на знос за роки, коефіцієнт стану й коефіцієнт памʼяті.
кількість = 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)
print("модель:", модель[:5])
print("рік: ", рік[:5])
print("стан: ", стан[:5])
модель: ['Beta 12 Pro' 'Alfa A7' 'Gamma X' 'Beta 12 Pro' 'Alfa A5'] рік: [2017 2021 2022 2021 2020] стан: ['добре' 'добре' 'дуже добре' 'добре' 'добре']
Тепер ціна. «Типова» ціна — це те, скільки телефон коштує за паспортом: базова ціна нового мінус знос. Реальна ціна в оголошенні відрізняється від типової на кілька відсотків в обидва боки — продавці ставлять різні суми.
базова = 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 памʼять])
типова_ціна = базова * знос * коефіцієнт_стану * коефіцієнт_памʼяті
# lognormal дає множник біля одиниці, який ніколи не буває відʼємним — ціна теж
ціна = типова_ціна * rng.lognormal(0, 0.13, size=кількість)
print("типова ціна перших пʼяти:", типова_ціна[:5].round(0))
print("ціна в оголошенні :", ціна[:5].round(0))
типова ціна перших пʼяти: [ 3763. 3060. 12071. 8322. 1499.] ціна в оголошенні : [ 3085. 2810. 10822. 8346. 1852.]
2 · Додаємо шахрайські оголошення¶
Шахрай частіше працює зі свіжого акаунта, тому ймовірність шахрайства залежить від віку акаунта. А ціну шахрай ставить не будь-яку: або різко занижену («неймовірна знижка», щоб зловити на жадібності), або завищену — тоді ставка на велику передоплату.
шанс_шахрайства = 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, кількість))
print("шахрайських оголошень:", шахрайське.sum(), "з", кількість)
print("занижена ціна:", дешева_приманка.sum(), "· завищена:", дорога_приманка.sum())
шахрайських оголошень: 196 з 1200 занижена ціна: 145 · завищена: 51
дошка = pd.DataFrame({
"модель": модель,
"рік": рік,
"стан": стан,
"памʼять_гб": памʼять,
"вік_акаунта": вік_акаунта,
"скарг": скарг,
"ціна": ціна,
"шахрайське": шахрайське.astype(int),
})
print(дошка.shape)
дошка.head()
(1200, 8)
| модель | рік | стан | памʼять_гб | вік_акаунта | скарг | ціна | шахрайське | |
|---|---|---|---|---|---|---|---|---|
| 0 | Beta 12 Pro | 2017 | добре | 256 | 186 | 0 | 3090.0 | 0 |
| 1 | Alfa A7 | 2021 | добре | 128 | 394 | 0 | 2810.0 | 0 |
| 2 | Gamma X | 2022 | дуже добре | 64 | 44 | 4 | 33060.0 | 1 |
| 3 | Beta 12 Pro | 2021 | добре | 256 | 777 | 0 | 8350.0 | 0 |
| 4 | Alfa A5 | 2020 | добре | 64 | 351 | 0 | 1850.0 | 0 |
3 · Псуємо дані так, як їх псує життя¶
Поки що таблиця надто охайна. Додамо шість реальних неприємностей — і далі вдамо, що ми їх не бачили: саме їх треба буде знайти розвідкою.
- колекційні телефони — чотири запаковані Gamma X 2017 року за 82–95 тисяч;
- одруки — два оголошення, де при введенні ціни додали зайвий нуль;
- памʼять текстом — частина рядків записана як
128 ГБ, а не128; - пропуски в ціні — і зникає вона зовсім не випадково;
- пропуски в стані — а ось ці випадкові;
- дублікати — дванадцять оголошень подані двічі.
# 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
print("колекційні:", list(колекційні), "· одруки:", list(одруки))
print(дошка.loc[list(колекційні) + list(одруки), ["модель", "рік", "стан", "ціна"]])
колекційні: [2, 13, 18, 23] · одруки: [3, 11]
модель рік стан ціна
2 Gamma X 2017 нове 82000.0
13 Gamma X 2017 нове 88000.0
18 Gamma X 2017 нове 91000.0
23 Gamma X 2017 нове 95000.0
3 Beta 12 Pro 2021 добре 83500.0
11 Gamma X Ultra 2017 добре 76000.0
# 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("таблиця готова:", дошка.shape)
таблиця готова: (1212, 8)
Перший погляд¶
Далі ми вдаємо, що бачимо цю таблицю вперше. Пʼять команд, з яких починається знайомство з будь-якими даними. Головне в них — не виклик, а питання, яке до кожної поставлене.
4 · shape і head: чи стільки рядків і чи схожі значення на назви¶
shape перевіряє, чи не загубилась частина даних дорогою. head — чи значення в стовпці
схожі на те, що обіцяє його назва.
print("рядків і стовпців:", дошка.shape)
дошка.head()
рядків і стовпців: (1212, 8)
| модель | рік | стан | памʼять_гб | вік_акаунта | скарг | ціна | шахрайське | |
|---|---|---|---|---|---|---|---|---|
| 0 | Beta 12 Pro | 2017 | добре | 256 | 186 | 0 | 3090.0 | 0 |
| 1 | Alfa A7 | 2021 | NaN | 128 | 394 | 0 | 2810.0 | 0 |
| 2 | Gamma X | 2017 | нове | 512 | 44 | 0 | 82000.0 | 0 |
| 3 | Beta 12 Pro | 2021 | добре | 256 ГБ | 777 | 0 | 83500.0 | 0 |
| 4 | Alfa A5 | 2020 | добре | 64 | 351 | 0 | 1850.0 | 0 |
Уже в перших пʼятьох рядках видно дві дрібниці: NaN у стані й памʼять, записану як
256 ГБ. Обидві коштуватимуть години, якщо їх зараз не помітити.
5 · dtypes: чи всі числові стовпці справді числові¶
Тип object у pandas означає «щось, що я не звів до числа» — зазвичай текст. Для
«моделі» й «стану» це нормально. Для «памʼять_гб» — тривожний знак.
print(дошка.dtypes)
print()
print("унікальні значення памʼяті:")
print(дошка["памʼять_гб"].value_counts())
модель object рік int64 стан object памʼять_гб object вік_акаунта int64 скарг int64 ціна float64 шахрайське int64 dtype: object унікальні значення памʼяті: памʼять_гб 128 378 64 306 256 234 128 ГБ 83 512 79 64 ГБ 62 256 ГБ 49 512 ГБ 21 Name: count, dtype: int64
Пастка, яка мовчить¶
Найочевидніша реакція — «зведімо стовпець до чисел одним викликом». З
errors="coerce" вона не падає й нічого не пише в консоль. Вона просто мовчки
перетворює на порожнечу все, що не змогла прочитати. Порахуймо, скільки саме.
наївно = pd.to_numeric(дошка["памʼять_гб"], errors="coerce")
print("наївний to_numeric зробив пропусками:", int(наївно.isna().sum()), "значень")
# правильно: спершу прибрати суфікс, і лише потім переводити в число
памʼять_число = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
дошка["памʼять_число"] = памʼять_число
print("правильний розбір зробив пропусками:", int(памʼять_число.isna().sum()), "значень")
assert памʼять_число.isna().sum() == 0, "після розбору пропусків бути не має"
print("✅ памʼять розібрана без втрат")
наївний to_numeric зробив пропусками: 215 значень правильний розбір зробив пропусками: 0 значень ✅ памʼять розібрана без втрат
6 · info і describe: де дірки й чи бувають такі числа¶
info() показує, у скількох рядках кожного стовпця є значення. Порівнюй це число з
кількістю рядків усієї таблиці.
дошка.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1212 entries, 0 to 1211 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 модель 1212 non-null object 1 рік 1212 non-null int64 2 стан 1164 non-null object 3 памʼять_гб 1212 non-null object 4 вік_акаунта 1212 non-null int64 5 скарг 1212 non-null int64 6 ціна 1111 non-null float64 7 шахрайське 1212 non-null int64 8 памʼять_число 1212 non-null int64 dtypes: float64(1), int64(5), object(3) memory usage: 85.3+ KB
describe() читають не зверху вниз, а трьома парами рядків:
countпроти кількості рядків — різниця це пропуски;minіmaxпроти здорового глузду — чи буває таке на дошці вживаних телефонів;meanпроти50 %— якщо розходяться, у даних довгий хвіст або помилки введення.
print(дошка[["рік", "памʼять_число", "вік_акаунта", "скарг", "ціна"]].describe().round(1))
рік памʼять_число вік_акаунта скарг ціна count 1212.0 1212.0 1212.0 1212.0 1111.0 mean 2020.5 170.1 402.1 0.7 6508.8 std 2.3 124.4 409.8 1.7 8674.0 min 2017.0 64.0 4.0 0.0 130.0 25% 2018.0 64.0 117.8 0.0 2180.0 50% 2020.0 128.0 288.0 0.0 4010.0 75% 2023.0 256.0 527.2 0.0 7700.0 max 2024.0 512.0 3519.0 11.0 95000.0
7 · Дублікати¶
Повні повтори рядків нічого не ламають одразу — вони тихо збільшують вагу тих самих оголошень у будь-яких підрахунках.
print("повних дублікатів:", int(дошка.duplicated().sum()))
print()
print("ось вони (перші два разом із оригіналами):")
всі_повтори = дошка[дошка.duplicated(keep=False)].sort_values(["модель", "ціна", "вік_акаунта"])
print(всі_повтори[["модель", "рік", "вік_акаунта", "ціна"]].head(4))
повних дублікатів:
12
ось вони (перші два разом із оригіналами):
модель рік вік_акаунта ціна
98 Alfa A5 2017 515 940.0
1201 Alfa A5 2017 515 940.0
421 Alfa A5 2023 128 3450.0
1210 Alfa A5 2023 128 3450.0
пропуски = дошка.isna().sum()
print(пропуски[пропуски > 0])
print()
print("усього рядків:", len(дошка))
стан 48 ціна 101 dtype: int64 усього рядків: 1212
9 · І головне — чому саме ці рядки¶
Питання «скільки» майже нічого не варте. Питання «чому» варте всього. Перевірка проста: порівняти рядки з пропуском і без нього за третім стовпцем. Найцікавіший третій стовпець — таргет.
def частка_шахрайських_за_пропуском(стовпець):
'''Частка шахрайських окремо серед рядків із пропуском і без нього.'''
немає_значення = дошка[стовпець].isna()
таблиця = pd.crosstab(немає_значення, дошка["шахрайське"])
таблиця.index = ["значення є", "значення немає"]
таблиця.columns = ["чесних", "шахрайських"]
таблиця["частка шахрайських, %"] = (
таблиця["шахрайських"] / (таблиця["чесних"] + таблиця["шахрайських"]) * 100).round(1)
return таблиця
print("ЦІНА")
print(частка_шахрайських_за_пропуском("ціна"))
print()
print("СТАН")
print(частка_шахрайських_за_пропуском("стан"))
ЦІНА
чесних шахрайських частка шахрайських, % значення є 972 139 12.5 значення немає 44 57 56.4 СТАН
чесних шахрайських частка шахрайських, % значення є 974 190 16.3 значення немає 42 6 12.5
Різниця разюча. Серед оголошень без ціни шахрайських більше половини, серед решти — восьма частина. Це вже не статистична дрібниця, а поведінка: шахрай не називає ціну, бо саме ціна робить його оголошення підозрілим.
У стовпці «стан» такої різниці немає — там пропуск справді випадковий.
Висновок: сам факт пропуску буває ознакою. Не значення, якого немає, а те, що його немає. Технічно це один додатковий стовпець.
дошка["ціни_немає"] = дошка["ціна"].isna().astype(int)
# наскільки ця нова колонка сама по собі повʼязана з таргетом
звʼязок = дошка["ціни_немає"].corr(дошка["шахрайське"])
print("кореляція «ціни немає» з таргетом:", round(звʼязок, 3))
print("для порівняння, кореляція «стану немає»:",
round(дошка["стан"].isna().astype(int).corr(дошка["шахрайське"]), 3))
кореляція «ціни немає» з таргетом: 0.33 для порівняння, кореляція «стану немає»: -0.02
ціни = дошка["ціна"].dropna()
середнє = ціни.mean()
медіана = ціни.median()
дешевші_за_середнє = (ціни < середнє).sum()
print("оголошень із вказаною ціною:", len(ціни))
print("середнє :", round(середнє, 1), "грн")
print("медіана :", round(медіана, 1), "грн")
print("розрив :", round(середнє / медіана, 2), "раза")
print()
print("дешевші за середнє:", дешевші_за_середнє, "оголошень —",
round(дешевші_за_середнє / len(ціни) * 100, 1), "% усіх")
оголошень із вказаною ціною: 1111 середнє : 6508.8 грн медіана : 4010.0 грн розрив : 1.62 раза дешевші за середнє: 763 оголошень — 68.7 % усіх
Понад дві третини оголошень дешевші за «середнє оголошення». Речення «середня ціна на дошці — 6 509 грн» правдиве арифметично й хибне по суті: воно описує обʼєкт, якого майже не буває.
11 · Гістограма: скільки кошиків — стільки й висновків¶
Гістограма має властивість, про яку зазвичай мовчать: її висновок залежить від кількості кошиків. Побудуємо ту саму колонку тричі.
малюнок, осі = plt.subplots(1, 3, figsize=(14, 3.6), sharey=False)
for вісь, кошиків in zip(осі, [4, 20, 80]):
вісь.hist(ціни, bins=кошиків, range=(0, 25000), color="#c2185b", alpha=0.35,
edgecolor="#c2185b")
вісь.axvline(медіана, color="#0f766e", linestyle="--", label=f"медіана {медіана:.0f}")
вісь.axvline(середнє, color="#c2185b", linestyle="--", label=f"середнє {середнє:.0f}")
вісь.set_title(f"{кошиків} кошиків")
вісь.set_xlabel("ціна, грн")
вісь.legend(fontsize=8)
осі[0].set_ylabel("оголошень")
plt.tight_layout()
plt.show()
print("на 4 кошиках пік прилипає до нуля; на 20 він стоїть там, де є насправді;")
print("на 80 сусідні стовпчики вже стрибають — це шум, а не форма розподілу")
на 4 кошиках пік прилипає до нуля; на 20 він стоїть там, де є насправді; на 80 сусідні стовпчики вже стрибають — це шум, а не форма розподілу
Викиди¶
12 · Правило міжквартильного розмаху, пораховане руками¶
Квартилі ділять відсортований список на чотири рівні за кількістю частини. Міжквартильний розмах — відстань між першим і третім: ширина смуги, у якій живе середня половина оголошень. Вона не залежить від того, що коїться в хвостах, — тому лінійка, якою ми міряємо викиди, сама від викидів не залежить.
q1 = ціни.quantile(0.25)
q3 = ціни.quantile(0.75)
розмах = q3 - q1
верхня_межа = q3 + 1.5 * розмах
нижня_межа = q1 - 1.5 * розмах
print(f"Q1 = {q1:.0f} Q3 = {q3:.0f} IQR = {розмах:.0f}")
print(f"верхня межа = {q3:.0f} + 1.5 · {розмах:.0f} = {верхня_межа:.0f}")
print(f"нижня межа = {q1:.0f} − 1.5 · {розмах:.0f} = {нижня_межа:.0f} (ціна відʼємною не буває)")
викиди = ціни[(ціни > верхня_межа) | (ціни < нижня_межа)]
print()
print("викидів:", len(викиди), "—", round(len(викиди) / len(ціни) * 100, 1), "% таблиці")
Q1 = 2180 Q3 = 7700 IQR = 5520 верхня межа = 7700 + 1.5 · 5520 = 15980 нижня межа = 2180 − 1.5 · 5520 = -6100 (ціна відʼємною не буває) викидів: 78 — 7.0 % таблиці
Перевіримо, що наші квартилі — це те саме, що рахує бібліотека іншим шляхом.
бібліотечні = np.percentile(ціни.to_numpy(), [25, 75])
наші = np.array([q1, q3])
assert np.allclose(наші, бібліотечні), "квартилі розійшлися!"
print("наші :", наші)
print("numpy :", бібліотечні)
print("✅ збігається")
наші : [2180. 7700.] numpy : [2180. 7700.] ✅ збігається
13 · Множник 1.5 — домовленість, а не закон¶
Подивімось, як кількість викидів залежить від множника.
рядки = []
for множник in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]:
межа = q3 + множник * розмах
за_межею = ціни > межа
рядки.append({
"множник": множник,
"верхня межа": round(межа),
"викидів": int(за_межею.sum()),
"% таблиці": round(за_межею.sum() / len(ціни) * 100, 1),
})
print(pd.DataFrame(рядки).to_string(index=False))
множник верхня межа викидів % таблиці
0.5 10460 175 15.8
1.0 13220 116 10.4
1.5 15980 78 7.0
2.0 18740 58 5.2
2.5 21500 44 4.0
3.0 24260 35 3.2
14 · Викид — не синонім помилки¶
Правило вміє лише показати пальцем. Що робити з рядком, вирішує зміст, а не арифметика. Подивімось на найдорожчі оголошення цілком, з усіма стовпцями.
найдорожчі = дошка.nlargest(8, "ціна")[
["модель", "рік", "стан", "памʼять_число", "вік_акаунта", "скарг", "ціна", "шахрайське"]]
print(найдорожчі.to_string())
модель рік стан памʼять_число вік_акаунта скарг ціна шахрайське 23 Gamma X 2017 нове 512 50 0 95000.0 0 18 Gamma X 2017 нове 512 580 0 91000.0 0 13 Gamma X 2017 нове 512 87 0 88000.0 0 3 Beta 12 Pro 2021 добре 256 777 0 83500.0 0 2 Gamma X 2017 нове 512 44 0 82000.0 0 11 Gamma X Ultra 2017 добре 256 556 0 76000.0 0 936 Gamma X Ultra 2021 дуже добре 128 62 2 59840.0 1 300 Beta 12 Pro 2024 добре 512 708 4 52620.0 1
Чотири рядки з шести верхніх — одна й та сама комбінація: Gamma X 2017 року, стан «нове», 512 гігабайтів. Запакований флагман, який ніколи не вмикали: за такі речі колекціонери платять, і це не помилка, а ринок.
А ось Beta 12 Pro у стані «добре» за 83 500 і Gamma X Ultra за 76 000 — на тій самій
висоті, але сусідні стовпці їх не підтримують. Телефон у стані «добре» не коштує як
чотири нових. Це майже напевно зайвий нуль при введенні.
Дві однакові за величиною ціни — і два протилежні рішення. Розрізняє їх не ціна, а решта рядка.
Що викиди роблять із підрахунками¶
Візьмімо одну модель — Gamma X — і порахуймо, наскільки ціна повʼязана з роком випуску. Спершу як є, потім без чотирьох колекційних рядків.
gamma = дошка[(дошка["модель"] == "Gamma X") & (дошка["шахрайське"] == 0)].dropna(subset=["ціна"])
gamma_без_колекційних = gamma[gamma["ціна"] < 50000]
print("Gamma X, усі", len(gamma), "рядків : кореляція рік–ціна =",
round(gamma["рік"].corr(gamma["ціна"]), 3))
print("Gamma X, без 4 колекційних (", len(gamma_без_колекційних), "): кореляція рік–ціна =",
round(gamma_без_колекційних["рік"].corr(gamma_без_колекційних["ціна"]), 3))
print()
print("чотири рядки зі ста двадцяти двох перевернули висновок")
Gamma X, усі 122 рядків : кореляція рік–ціна = 0.062 Gamma X, без 4 колекційних ( 118 ): кореляція рік–ціна = 0.798 чотири рядки зі ста двадцяти двох перевернули висновок
числові = ["рік", "памʼять_число", "вік_акаунта", "скарг", "ціна", "шахрайське"]
матриця = дошка[числові].corr()
print(матриця.round(3).to_string())
рік памʼять_число вік_акаунта скарг ціна шахрайське рік 1.000 -0.028 -0.018 0.023 0.249 0.037 памʼять_число -0.028 1.000 -0.022 0.049 0.209 0.078 вік_акаунта -0.018 -0.022 1.000 -0.096 -0.030 -0.101 скарг 0.023 0.049 -0.096 1.000 -0.019 0.887 ціна 0.249 0.209 -0.030 -0.019 1.000 -0.012 шахрайське 0.037 0.078 -0.101 0.887 -0.012 1.000
Перш ніж читати матрицю, переконаймося, що ми розуміємо, як це число рахується. Напишемо формулу Пірсона самі й порівняємо з pandas.
def кореляція_пірсона(x, y):
'''Наскільки узгоджено дві величини відхиляються від своїх середніх.'''
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
відхилення_x = x - x.mean()
відхилення_y = y - y.mean()
разом = (відхилення_x * відхилення_y).sum() # рухаються в один бік — плюс
окремо = np.sqrt((відхилення_x ** 2).sum() * (відхилення_y ** 2).sum())
return разом / окремо
наша = кореляція_пірсона(дошка["скарг"], дошка["шахрайське"])
бібліотечна = дошка["скарг"].corr(дошка["шахрайське"])
assert np.allclose(наша, бібліотечна), "кореляція розійшлася!"
print("наша :", round(наша, 6))
print("pandas :", round(бібліотечна, 6))
print("✅ збігається")
наша : 0.887279 pandas : 0.887279 ✅ збігається
16 · Чому кореляція буває меншою, ніж звʼязок¶
рік ↔ ціна дало всього 0.249. Здається, вік телефона майже не впливає на ціну — а це
безглуздо. Причина в тому, що в одній хмарі змішані шість різних моделей: Alfa A5
2024 року дешевша за Gamma X Ultra 2017-го, і ця різниця забиває залежність від року.
Візьмімо одну модель і лише чесні оголошення.
з_ціною = дошка.dropna(subset=["ціна"])
beta = з_ціною[(з_ціною["модель"] == "Beta 12") & (з_ціною["шахрайське"] == 0)]
print("уся таблиця,", len(з_ціною), "рядків : рік ↔ ціна =",
round(з_ціною["рік"].corr(з_ціною["ціна"]), 3))
print("лише Beta 12,", len(beta), "рядків : рік ↔ ціна =",
round(beta["рік"].corr(beta["ціна"]), 3))
print()
print("звʼязок був увесь час — кореляція міряє не його силу,")
print("а те, наскільки тісно точки тиснуться до прямої")
уся таблиця, 1111 рядків : рік ↔ ціна = 0.249 лише Beta 12, 171 рядків : рік ↔ ціна = 0.84 звʼязок був увесь час — кореляція міряє не його силу, а те, наскільки тісно точки тиснуться до прямої
17 · Чого кореляція не бачить зовсім¶
ціна ↔ шахрайське дало −0.012, тобто рівно нічого. Спокуса — викреслити ціну зі списку
корисних ознак. Перевірмо цю спокусу: порахуємо частку шахрайських окремо в кожній
десятій частині цін.
з_ціною = дошка.dropna(subset=["ціна"]).copy()
з_ціною["десята_частина"] = pd.qcut(з_ціною["ціна"], 10, labels=False)
по_кошиках = з_ціною.groupby("десята_частина").agg(
оголошень=("ціна", "size"),
від_ціни=("ціна", "min"),
до_ціни=("ціна", "max"),
частка_шахрайських=("шахрайське", "mean"),
)
по_кошиках["частка_шахрайських"] = (по_кошиках["частка_шахрайських"] * 100).round(1)
print(по_кошиках.to_string())
оголошень від_ціни до_ціни частка_шахрайських десята_частина 0 112 130.0 1230.0 42.9 1 111 1240.0 1820.0 18.9 2 111 1840.0 2510.0 12.6 3 111 2520.0 3210.0 8.1 4 111 3220.0 4010.0 4.5 5 111 4020.0 5060.0 2.7 6 112 5070.0 6690.0 3.6 7 110 6710.0 9040.0 6.4 8 111 9080.0 13550.0 7.2 9 111 13590.0 95000.0 18.0
plt.figure(figsize=(7.5, 3.4))
plt.plot(по_кошиках.index, по_кошиках["частка_шахрайських"], marker="o", color="#17212b")
plt.axhline(дошка["шахрайське"].mean() * 100, color="#c2185b", linestyle="--",
label="середнє по таблиці")
plt.xlabel("десята частина за ціною: 0 — найдешевші, 9 — найдорожчі")
plt.ylabel("частка шахрайських, %")
plt.title("Кореляція нульова, а звʼязок U-подібний")
plt.legend(fontsize=9)
plt.tight_layout()
plt.show()
print("кореляція ціни з таргетом:", round(дошка["ціна"].corr(дошка["шахрайське"]), 3))
print("а частка шахрайських гуляє від",
по_кошиках["частка_шахрайських"].min(), "% до",
по_кошиках["частка_шахрайських"].max(), "%")
кореляція ціни з таргетом: -0.012 а частка шахрайських гуляє від 2.7 % до 42.9 %
Ламана падає майже в шістнадцять разів і повертається назад: підозрілі й дуже дешеві оголошення (приманка «неймовірна знижка»), і дуже дорогі (виманити велику передоплату). Пряма, проведена крізь таку хмару, лежить горизонтально — і кореляція чесно повідомляє нуль. Вона не помилилась: ми поставили їй не те питання.
Нульова кореляція означає «прямої немає», а не «звʼязку немає».
Таргет і витік¶
18 · Баланс класів¶
баланс = дошка["шахрайське"].value_counts().sort_index()
print(баланс.to_string())
print()
частка_рідкісного = дошка["шахрайське"].mean()
print("частка шахрайських:", round(частка_рідкісного * 100, 1), "%")
print("модель «усі оголошення чесні» вгадає в",
round((1 - частка_рідкісного) * 100, 1), "% випадків — і не впіймає жодного шахрая")
шахрайське 0 1016 1 196 частка шахрайських: 16.2 % модель «усі оголошення чесні» вгадає в 83.8 % випадків — і не впіймає жодного шахрая
Ось чому дисбаланс треба знати до того, як обрана метрика: він задає, з чим порівнювати будь-яке майбутнє число. Про це — тема Precision і Recall.
19 · Витік даних¶
Повернімось до найпершого рядка кореляційної матриці: скарг ↔ шахрайське = 0.887.
Це надзвичайно багато. Подивімось на цю пару впритул.
таблиця_скарг = pd.crosstab(дошка["скарг"] > 0, дошка["шахрайське"])
таблиця_скарг.index = ["скарг немає", "скарг ≥ 1"]
таблиця_скарг.columns = ["чесних", "шахрайських"]
print(таблиця_скарг.to_string())
позначені = дошка["скарг"] > 0
влучань = int((позначені & (дошка["шахрайське"] == 1)).sum())
усього_шахраїв = int((дошка["шахрайське"] == 1).sum())
print()
print("правило «є скарга → шахрайське»:")
print(" позначено оголошень :", int(позначені.sum()))
print(" з них справді шахраї:", влучань,
f"({влучань / позначені.sum() * 100:.1f} % точності)")
print(" упіймано шахраїв :", влучань, "з", усього_шахраїв,
f"({влучань / усього_шахраїв * 100:.1f} % усіх)")
чесних шахрайських скарг немає 994 0 скарг ≥ 1 22 196 правило «є скарга → шахрайське»: позначено оголошень : 218 з них справді шахраї: 196 (89.9 % точності) упіймано шахраїв : 196 з 196 (100.0 % усіх)
Одна колонка, жодного навчання, майже ідеальний результат. Це не удача, це витік даних.
Подумай, коли зʼявляється скарга. Оголошення опублікували — скарг нуль. Хтось надіслав передоплату й не отримав телефон — надійшла скарга. Служба підтримки розібралась і поставила мітку «шахрайське». Скарга й мітка зʼявляються в один і той самий момент і з однієї й тієї самої події.
Модель, навчена на скаргах, у робочому режимі отримає нове оголошення, у якого скарг нуль завжди — і назве чесними всі. Перевірка одна: чи знав би я це значення тоді, коли треба відповідати?
ознаки_для_моделі = ["рік", "памʼять_число", "вік_акаунта", "ціна", "ціни_немає"]
викинути = ["скарг"]
print("беремо в ознаки :", ознаки_для_моделі)
print("НЕ беремо :", викинути, "— значення зʼявляється після відповіді")
print()
print("кореляція кожної ознаки з таргетом:")
for ознака in ознаки_для_моделі + викинути:
print(f" {ознака:<16} {дошка[ознака].corr(дошка['шахрайське']):+.3f}")
беремо в ознаки : ['рік', 'памʼять_число', 'вік_акаунта', 'ціна', 'ціни_немає'] НЕ беремо : ['скарг'] — значення зʼявляється після відповіді кореляція кожної ознаки з таргетом: рік +0.037 памʼять_число +0.078 вік_акаунта -0.101 ціна -0.012 ціни_немає +0.330 скарг +0.887
Правило, яке варто вивчити напамʼять: занадто добре — це помилка, поки не доведено протилежне. Ознака, яка сама по собі майже ідеально пояснює таргет, у переважній більшості випадків означає витік, а не відкриття.
20 · Що ми знайшли¶
Ми пройшли таблицю наскрізь і жодного разу нічого в ній не змінили. Спершу дивимось, потім вирішуємо.
знахідки = [
("дублікати", "12 повних повторів — зрозуміти походження, найімовірніше прибрати"),
("памʼять_гб", "текст замість числа; наївний to_numeric знищує 215 значень"),
("ціна", "101 пропуск, і він НЕ випадковий — потрібна колонка «ціни_немає»"),
("стан", "48 пропусків, випадкові — можна заповнювати"),
("ціна", "78 викидів за правилом 1.5·IQR: 4 колекційні, 2 одруки, решта — ринок"),
("ціна ↔ таргет", "кореляція нуль, звʼязок U-подібний — ознаку не викидати"),
("скарг", "витік даних — зі списку ознак прибрати"),
]
підсумок = pd.DataFrame(знахідки, columns=["стовпець", "що знайшли"])
print(підсумок.to_string(index=False))
стовпець що знайшли
дублікати 12 повних повторів — зрозуміти походження, найімовірніше прибрати
памʼять_гб текст замість числа; наївний to_numeric знищує 215 значень
ціна 101 пропуск, і він НЕ випадковий — потрібна колонка «ціни_немає»
стан 48 пропусків, випадкові — можна заповнювати
ціна 78 викидів за правилом 1.5·IQR: 4 колекційні, 2 одруки, решта — ринок
ціна ↔ таргет кореляція нуль, звʼязок U-подібний — ознаку не викидати
скарг витік даних — зі списку ознак прибрати
Завдання¶
🟢 Рівень 1 — повторити¶
Візьми стовпець вік_акаунта і пройди по ньому ту саму розвідку, що ми пройшли
для ціни: describe(), гістограма, середнє проти медіани, межі за правилом
1.5·IQR і кількість викидів.
Зроблено, якщо: ти назвав кількість викидів угорі, пояснив, чому середнє більше за медіану, і сказав словами, чи варто ці викиди прибирати.
🟡 Рівень 2 — застосувати¶
Перевір, чи випадковий пропуск у стовпці стан, не за таргетом, а за іншими
стовпцями: чи не зникає стан частіше у старих телефонів, у дорогих, у нових акаунтів?
Побудуй ту саму перевірку, що в клітинці 9, але з третім стовпцем замість таргета
(підказка: розбий числовий стовпець на кошики через pd.qcut).
Зроблено, якщо: для трьох різних стовпців порахована частка пропусків стану в кошиках, і зроблено висновок словами — випадковий цей пропуск чи ні.
🔴 Рівень 3 — дослідити межу¶
Кореляція Пірсона не бачить нелінійного звʼязку. Є проста міра, яка бачить: кореляційне відношення — частка розкиду таргета, яку пояснює розбиття ознаки на кошики.
Реалізуй її самотужки:
- розбий ознаку на
kкошиків рівної наповненості (pd.qcut); - порахуй загальну дисперсію таргета й середню дисперсію всередині кошиків;
- відношення
1 − внутрішня / загальнаі є шуканою мірою.
Порахуй її для пар ціна → шахрайське й вік_акаунта → шахрайське і порівняй із
кореляцією Пірсона для тих самих пар.
Зроблено, якщо: для пари ціна → шахрайське кореляція Пірсона близька до нуля, а
твоя міра помітно більша за нуль; і ти пояснив словами, чому так, і як міра поводиться
при k = 2 та при k = 100.