Практика · Розвідка даних (EDA)¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

Ми зберемо ту саму дошку оголошень про вживані телефони, що й у лекції, — навмисно «брудну», як буває в житті, — і пройдемо по ній розвідку крок за кроком:

  1. побудуємо таблицю: чисту частину, шахрайські оголошення й бруд;
  2. перший погляд: shape, head, dtypes, info, describe, дублікати;
  3. пропуски: скільки, де — і головне, чи вони випадкові;
  4. розподіли: гістограма, середнє проти медіани;
  5. викиди: правило міжквартильного розмаху, порахований руками;
  6. звʼязки: кореляційна матриця й те, чого вона не бачить;
  7. витік даних: колонка, якою не можна користуватись.

Усі числа тут ті самі, що в лекції: генератор випадкових чисел зафіксовано зерном 42.

In [1]:
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 · Будуємо дошку оголошень¶

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

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}
частки_моделей = [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]
стан:   ['добре' 'добре' 'дуже добре' 'добре' 'добре']

Тепер ціна. «Типова» ціна — це те, скільки телефон коштує за паспортом: базова ціна нового мінус знос. Реальна ціна в оголошенні відрізняється від типової на кілька відсотків в обидва боки — продавці ставлять різні суми.

In [3]:
базова = 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 · Додаємо шахрайські оголошення¶

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

In [4]:
шанс_шахрайства = 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
In [5]:
дошка = pd.DataFrame({
    "модель": модель,
    "рік": рік,
    "стан": стан,
    "памʼять_гб": памʼять,
    "вік_акаунта": вік_акаунта,
    "скарг": скарг,
    "ціна": ціна,
    "шахрайське": шахрайське.astype(int),
})
print(дошка.shape)
дошка.head()
(1200, 8)
Out[5]:
модель рік стан памʼять_гб вік_акаунта скарг ціна шахрайське
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 · Псуємо дані так, як їх псує життя¶

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

  1. колекційні телефони — чотири запаковані Gamma X 2017 року за 82–95 тисяч;
  2. одруки — два оголошення, де при введенні ціни додали зайвий нуль;
  3. памʼять текстом — частина рядків записана як 128 ГБ, а не 128;
  4. пропуски в ціні — і зникає вона зовсім не випадково;
  5. пропуски в стані — а ось ці випадкові;
  6. дублікати — дванадцять оголошень подані двічі.
In [6]:
# 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
In [7]:
# 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 — чи значення в стовпці схожі на те, що обіцяє його назва.

In [8]:
print("рядків і стовпців:", дошка.shape)
дошка.head()
рядків і стовпців: (1212, 8)
Out[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 означає «щось, що я не звів до числа» — зазвичай текст. Для «моделі» й «стану» це нормально. Для «памʼять_гб» — тривожний знак.

In [9]:
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" вона не падає й нічого не пише в консоль. Вона просто мовчки перетворює на порожнечу все, що не змогла прочитати. Порахуймо, скільки саме.

In [10]:
наївно = 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() показує, у скількох рядках кожного стовпця є значення. Порівнюй це число з кількістю рядків усієї таблиці.

In [11]:
дошка.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 % — якщо розходяться, у даних довгий хвіст або помилки введення.
In [12]:
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 · Дублікати¶

Повні повтори рядків нічого не ламають одразу — вони тихо збільшують вагу тих самих оголошень у будь-яких підрахунках.

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

Пропуски¶

8 · Скільки й де¶

Це найлегша частина: один рядок.

In [14]:
пропуски = дошка.isna().sum()
print(пропуски[пропуски > 0])
print()
print("усього рядків:", len(дошка))
стан     48
ціна    101
dtype: int64

усього рядків: 1212

9 · І головне — чому саме ці рядки¶

Питання «скільки» майже нічого не варте. Питання «чому» варте всього. Перевірка проста: порівняти рядки з пропуском і без нього за третім стовпцем. Найцікавіший третій стовпець — таргет.

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

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

У стовпці «стан» такої різниці немає — там пропуск справді випадковий.

Висновок: сам факт пропуску буває ознакою. Не значення, якого немає, а те, що його немає. Технічно це один додатковий стовпець.

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

# наскільки ця нова колонка сама по собі повʼязана з таргетом
звʼязок = дошка["ціни_немає"].corr(дошка["шахрайське"])
print("кореляція «ціни немає» з таргетом:", round(звʼязок, 3))
print("для порівняння, кореляція «стану немає»:",
      round(дошка["стан"].isna().astype(int).corr(дошка["шахрайське"]), 3))
кореляція «ціни немає» з таргетом: 0.33
для порівняння, кореляція «стану немає»: -0.02

Розподіли¶

10 · Середнє проти медіани¶

Середнє й медіана відповідають на різні питання. Середнє — «скільки грошей на дошці всього». Медіана — «яке оголошення типове». Коли розподіл скошений, вони розходяться, і плутанина тут коштує дорого.

In [17]:
ціни = дошка["ціна"].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 · Гістограма: скільки кошиків — стільки й висновків¶

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

In [18]:
малюнок, осі = 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 сусідні стовпчики вже стрибають — це шум, а не форма розподілу")
No description has been provided for this image
на 4 кошиках пік прилипає до нуля; на 20 він стоїть там, де є насправді;
на 80 сусідні стовпчики вже стрибають — це шум, а не форма розподілу

Викиди¶

12 · Правило міжквартильного розмаху, пораховане руками¶

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

In [19]:
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 % таблиці

Перевіримо, що наші квартилі — це те саме, що рахує бібліотека іншим шляхом.

In [20]:
бібліотечні = 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 — домовленість, а не закон¶

Подивімось, як кількість викидів залежить від множника.

In [21]:
рядки = []
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 · Викид — не синонім помилки¶

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

In [22]:
найдорожчі = дошка.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 — і порахуймо, наскільки ціна повʼязана з роком випуску. Спершу як є, потім без чотирьох колекційних рядків.

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

чотири рядки зі ста двадцяти двох перевернули висновок

Звʼязки між ознаками¶

15 · Кореляційна матриця¶

Кореляція Пірсона відповідає на одне питання: наскільки добре хмару точок можна замінити однією прямою. Від −1 до +1.

In [24]:
числові = ["рік", "памʼять_число", "вік_акаунта", "скарг", "ціна", "шахрайське"]
матриця = дошка[числові].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.

In [25]:
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-го, і ця різниця забиває залежність від року.

Візьмімо одну модель і лише чесні оголошення.

In [26]:
з_ціною = дошка.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, тобто рівно нічого. Спокуса — викреслити ціну зі списку корисних ознак. Перевірмо цю спокусу: порахуємо частку шахрайських окремо в кожній десятій частині цін.

In [27]:
з_ціною = дошка.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
In [28]:
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(), "%")
No description has been provided for this image
кореляція ціни з таргетом: -0.012
а частка шахрайських гуляє від 2.7 % до 42.9 %

Ламана падає майже в шістнадцять разів і повертається назад: підозрілі й дуже дешеві оголошення (приманка «неймовірна знижка»), і дуже дорогі (виманити велику передоплату). Пряма, проведена крізь таку хмару, лежить горизонтально — і кореляція чесно повідомляє нуль. Вона не помилилась: ми поставили їй не те питання.

Нульова кореляція означає «прямої немає», а не «звʼязку немає».


Таргет і витік¶

18 · Баланс класів¶

In [29]:
баланс = дошка["шахрайське"].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. Це надзвичайно багато. Подивімось на цю пару впритул.

In [30]:
таблиця_скарг = 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 % усіх)

Одна колонка, жодного навчання, майже ідеальний результат. Це не удача, це витік даних.

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

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

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

print("беремо в ознаки  :", ознаки_для_моделі)
print("НЕ беремо        :", викинути, "— значення зʼявляється після відповіді")
print()
print("кореляція кожної ознаки з таргетом:")
for ознака in ознаки_для_моделі + викинути:
    print(f"  {ознака:<16} {дошка[ознака].corr(дошка['шахрайське']):+.3f}")
беремо в ознаки  : ['рік', 'памʼять_число', 'вік_акаунта', 'ціна', 'ціни_немає']
НЕ беремо        : ['скарг'] — значення зʼявляється після відповіді

кореляція кожної ознаки з таргетом:
  рік              +0.037
  памʼять_число    +0.078
  вік_акаунта      -0.101
  ціна             -0.012
  ціни_немає       +0.330
  скарг            +0.887

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


20 · Що ми знайшли¶

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

In [32]:
знахідки = [
    ("дублікати", "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 — дослідити межу¶

Кореляція Пірсона не бачить нелінійного звʼязку. Є проста міра, яка бачить: кореляційне відношення — частка розкиду таргета, яку пояснює розбиття ознаки на кошики.

Реалізуй її самотужки:

  1. розбий ознаку на k кошиків рівної наповненості (pd.qcut);
  2. порахуй загальну дисперсію таргета й середню дисперсію всередині кошиків;
  3. відношення 1 − внутрішня / загальна і є шуканою мірою.

Порахуй її для пар ціна → шахрайське й вік_акаунта → шахрайське і порівняй із кореляцією Пірсона для тих самих пар.

Зроблено, якщо: для пари ціна → шахрайське кореляція Пірсона близька до нуля, а твоя міра помітно більша за нуль; і ти пояснив словами, чому так, і як міра поводиться при k = 2 та при k = 100.