Практика · NumPy для ML¶

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

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

  1. Зберемо матрицю ознак X і вектор таргета y із сирих колонок.
  2. Поміряємо, скільки коштує список Python проти масиву — у байтах і в мілісекундах.
  3. Порахуємо статистики по осях і побачимо, чим axis=0 відрізняється від axis=1.
  4. Відмасштабуємо ознаки транслюванням і доведемо, що вийшло те саме, що в бібліотеки.
  5. Відберемо рядки масками й знайдемо звʼязок між віком акаунта й шахрайством.
  6. Спіймаємо пастку «копія чи подання» на живому прикладі.
  7. Побачимо, як три дірки NaN псують середнє на 1200 значень і як це лікується.

Усе відтворюване: генератор випадкових чисел один і той самий — np.random.default_rng(42).

In [1]:
import time

import numpy as np
from sklearn.preprocessing import MinMaxScaler

print("numpy:", np.__version__)
numpy: 2.5.1

1 · Дошка оголошень¶

Спершу — сирі колонки, такі, якими їх віддала б база даних. Сім полів: model, year, condition, memory_gb, account_age_days, price, is_fraud.

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

In [2]:
rng = np.random.default_rng(42)          # одне зерно на весь зошит — щоб числа повторювались
кількість = 1200

моделі = np.array(["Galaxy A52", "iPhone 11", "Redmi Note 10", "Pixel 6"])
модель_код = rng.integers(0, 4, кількість)          # яка модель у кожному оголошенні
рік = rng.integers(2016, 2024, кількість)
стан = rng.integers(1, 6, кількість)                 # 1 — на запчастини, 5 — як новий
памʼять = rng.choice([32, 64, 128, 256], кількість)
вік_акаунта = rng.integers(1, 1500, кількість)

# базова вартість кожної моделі, до якої додаються надбавки
база = np.array([4200.0, 9800.0, 3600.0, 7100.0])
ціна = (база[модель_код]
        + (рік - 2016) * 640          # кожен рік свіжості додає
        + (стан - 3) * 850            # стан рахуємо від середнього, тому «мінус три»
        + памʼять * 11
        + rng.normal(0, 700, кількість))
ціна = np.round(np.clip(ціна, 500, None), 0)

# шахраї трапляються вчетверо частіше серед акаунтів, молодших за два місяці
шанс_шахрайства = 0.04 + 0.30 * (вік_акаунта < 60)
шахрай = rng.random(кількість) < шанс_шахрайства
ціна[шахрай] = np.round(ціна[шахрай] * rng.uniform(0.28, 0.45, шахрай.sum()), 0)
is_fraud = шахрай.astype(np.int64)

print("оголошень:", кількість, "· шахрайських:", is_fraud.sum())
print("перші три:", моделі[модель_код[:3]], рік[:3], ціна[:3])
оголошень: 1200 · шахрайських: 67
перші три: ['Galaxy A52' 'Pixel 6' 'Redmi Note 10'] [2023 2019 2023] [8423. 9899. 2814.]

2 · Матриця ознак X і вектор y¶

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

np.column_stack складає одновимірні масиви поруч як стовпці — саме те, що треба. Одразу приводимо все до float64: цілочисельна матриця ознак підкидає сюрпризи (побачимо їх у розділі 9).

In [3]:
X = np.column_stack([рік, стан, памʼять, вік_акаунта]).astype(np.float64)
y = ціна

print("X.shape =", X.shape, "· рядок = оголошення, стовпець = ознака")
print("y.shape =", y.shape)
print("X.dtype =", X.dtype, "· X.nbytes =", X.nbytes, "байтів")
print()
print("X[2] — усе, що ми знаємо про третє оголошення:", X[2])
print("X[:, 1] — ознака «стан» у всіх оголошеннях:", X[:, 1][:8], "...")
print()
# найчастіша помилка новачка — коли ці два числа розʼїхались
assert X.shape[0] == y.shape[0], "X і y мають різну кількість рядків!"
print("✅ X.shape[0] == y.shape[0]:", X.shape[0])
X.shape = (1200, 4) · рядок = оголошення, стовпець = ознака
y.shape = (1200,)
X.dtype = float64 · X.nbytes = 38400 байтів

X[2] — усе, що ми знаємо про третє оголошення: [2023.    4.   32.  332.]
X[:, 1] — ознака «стан» у всіх оголошеннях: [1. 3. 4. 3. 2. 5. 2. 4.] ...

✅ X.shape[0] == y.shape[0]: 1200

3 · Скільки коштує список¶

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

In [4]:
import sys

ціни_список = y.tolist()                 # той самий стовпець, але списком Python
ціни_масив = y                           # і масивом

байтів_список = sys.getsizeof(ціни_список) + sum(sys.getsizeof(ч) for ч in ціни_список)
байтів_масив = ціни_масив.nbytes

print(f"список: {байтів_список:>8} байтів  ({байтів_список / len(y):.1f} на число)")
print(f"масив:  {байтів_масив:>8} байтів  ({байтів_масив / len(y):.1f} на число)")
print(f"різниця: у {байтів_список / байтів_масив:.2f} раза")
print()
print("звідки 32 байти: 8 на адресу в комірці списку +", sys.getsizeof(1.5), "на сам обʼєкт float")
список:    38456 байтів  (32.0 на число)
масив:      9600 байтів  (8.0 на число)
різниця: у 4.01 раза

звідки 32 байти: 8 на адресу в комірці списку + 24 на сам обʼєкт float

4 · Цикл проти векторизації¶

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

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

In [5]:
розмір = 50_000
проба_масив = rng.normal(9000, 2500, розмір)
проба_список = проба_масив.tolist()
середнє = float(проба_масив.mean())
розмах = float(проба_масив.max() - проба_масив.min())


def циклом():
    """Так це написав би той, хто масивів не знає."""
    результат = []
    for значення in проба_список:
        результат.append((значення - середнє) / розмах)
    return результат


def вектором():
    """Та сама арифметика, віддана масиву цілком."""
    return (проба_масив - середнє) / розмах


def найкращий_час(функція, прогонів=7):
    """Найкращий час із кількох прогонів — стабільніший за середній."""
    найкращий = float("inf")
    for _ in range(прогонів):
        початок = time.perf_counter()
        функція()
        найкращий = min(найкращий, time.perf_counter() - початок)
    return найкращий


час_циклу = найкращий_час(циклом)
час_вектора = найкращий_час(вектором)

print(f"цикл Python:       {час_циклу * 1000:8.3f} мс")
print(f"векторна операція: {час_вектора * 1000:8.3f} мс")
print(f"різниця:           у {час_циклу / час_вектора:.0f} разів")
print()
print("твоє число буде іншим: заміри залежать від машини, від її завантаження й навіть")
print("від того, як операційна система видає памʼять під проміжний масив. Якщо вийшло")
print("менше — це нормально. Важить порядок, а не точна цифра.")
цикл Python:          3.572 мс
векторна операція:    0.051 мс
різниця:           у 70 разів

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

Результати мають збігатися до останнього знака — це та сама арифметика, просто виконана двома способами. Перевіримо це, а не повіримо на слово.

In [6]:
assert np.allclose(циклом(), вектором()), "цикл і векторизація дали різне!"
print("✅ цикл і векторизація дають однаковий результат")
print("перші три значення:", вектором()[:3])
✅ цикл і векторизація дають однаковий результат
перші три значення: [0.18962257 0.07982514 0.0326693 ]

5 · Статистики по осях¶

axis=k — це вісь, уздовж якої рухається підсумовування, і саме вона зникає з форми. На матриці ознак це дві дуже різні дії:

  • axis=0 — рухаємось згори вниз по рядках; зникають оголошення, лишаються ознаки;
  • axis=1 — рухаємось зліва направо по стовпцях; зникають ознаки, лишаються оголошення.
In [7]:
назви = ["рік", "стан", "памʼять", "вік акаунта"]

середні_ознак = X.mean(axis=0)        # (1200, 4) -> (4,)
розкид_ознак = X.std(axis=0)
мінімуми = X.min(axis=0)
максимуми = X.max(axis=0)

print(f"{'ознака':<14}{'середнє':>10}{'розкид':>10}{'мін':>9}{'макс':>9}{'розмах':>9}")
for номер, назва in enumerate(назви):
    розмах_ознаки = максимуми[номер] - мінімуми[номер]
    print(f"{назва:<14}{середні_ознак[номер]:>10.2f}{розкид_ознак[номер]:>10.2f}"
          f"{мінімуми[номер]:>9.0f}{максимуми[номер]:>9.0f}{розмах_ознаки:>9.0f}")
print()
print("форма результату:", середні_ознак.shape, "— вісь 0 зникла")
ознака           середнє    розкид      мін     макс   розмах
рік              2019.48      2.30     2016     2023        7
стан                2.99      1.42        1        5        4
памʼять           116.53     84.59       32      256      224
вік акаунта       759.15    427.16        1     1499     1498

форма результату: (4,) — вісь 0 зникла

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

А тепер те саме по другій осі — і одразу видно, чому сирі ознаки по рядку усереднювати марно.

In [8]:
середні_оголошень = X.mean(axis=1)     # (1200, 4) -> (1200,)

print("форма результату:", середні_оголошень.shape, "— цього разу зникла вісь 1")
print("перші пʼять:", середні_оголошень[:5].round(2))
print()
print("а ось вік акаунта в тих самих пʼятьох:", X[:5, 3])
print("числа майже збігаються: вік акаунта в сотні разів більший за решту")
print("ознак і тягне середнє на себе. Форма правильна, зміст — ні.")
форма результату: (1200,) — цього разу зникла вісь 1
перші пʼять: [751.5  761.25 597.75 835.25 829.  ]

а ось вік акаунта в тих самих пʼятьох: [ 918.  895.  332. 1188. 1037.]
числа майже збігаються: вік акаунта в сотні разів більший за решту
ознак і тягне середнє на себе. Форма правильна, зміст — ні.

Таргет y одновимірний, тому осі йому не потрібні — там просто y.mean().

In [9]:
print(f"середня ціна: {y.mean():.1f} грн")
print(f"медіана:      {np.median(y):.1f} грн")
print(f"найдешевше:   {y.min():.0f} грн · найдорожче: {y.max():.0f} грн")
середня ціна: 9300.3 грн
медіана:      9203.0 грн
найдешевше:   677 грн · найдорожче: 18165 грн

6 · Масштабування транслюванням¶

Приведімо всі ознаки до проміжку від нуля до одиниці: відняти мінімум стовпця й поділити на його розмах. Обидва — масиви форми (4,), а X має форму (1200, 4). Транслювання доповнює коротшу форму одиницею зліва, розтягує її на всі 1200 рядків — і кожне число застосовується до свого стовпця.

In [10]:
розмахи = максимуми - мінімуми
X_шкала = (X - мінімуми) / розмахи      # (1200, 4) - (4,) -> (1200, 4)

print("форма після масштабування:", X_шкала.shape)
print("мінімуми стовпців:", X_шкала.min(axis=0))
print("максимуми стовпців:", X_шкала.max(axis=0))
print()
print("рядок 0 до масштабування: ", X[0])
print("рядок 0 після:            ", X_шкала[0].round(3))
форма після масштабування: (1200, 4)
мінімуми стовпців: [0. 0. 0. 0.]
максимуми стовпців: [1. 1. 1. 1.]

рядок 0 до масштабування:  [2.023e+03 1.000e+00 6.400e+01 9.180e+02]
рядок 0 після:             [1.    0.    0.143 0.612]

Тепер обовʼязкова перевірка: те, що ми зробили руками, — це рівно те, що робить MinMaxScaler зі scikit-learn. Усередині бібліотеки немає магії, там та сама пара «відняти мінімум, поділити на розмах».

In [11]:
бібліотечний = MinMaxScaler().fit_transform(X)

assert np.allclose(X_шкала, бібліотечний), "наше масштабування розійшлося з бібліотечним!"
print("✅ збігається з MinMaxScaler")
print("найбільша розбіжність:", np.abs(X_шкала - бібліотечний).max())
✅ збігається з MinMaxScaler
найбільша розбіжність: 4.057865155004947e-14

Помилка, яку робить кожен¶

А тепер спробуємо відняти середнє рядка, а не стовпця. Форми (1200, 4) і (1200,) не узгоджуються: коротшу доповнюють одиницею зліва, і четвірка стикається з тисячею двомастами. Клітинка нижче падає навмисно — прочитай текст помилки, ти бачитимеш його часто.

In [12]:
X - X.mean(axis=1)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[12], line 1
----> 1 X - X.mean(axis=1)

ValueError: operands could not be broadcast together with shapes (1200,4) (1200,) 

Ліки — keepdims=True: попросити агрегацію не викидати вісь, а лишити її довжиною 1. Тоді форма буде (1200, 1), з кінця зустрінуться 4 і 1, одиниця розтягнеться — і все спрацює.

In [13]:
середні_рядків = X.mean(axis=1, keepdims=True)

print("без keepdims:", X.mean(axis=1).shape, "— вісь викинуто")
print("з keepdims:  ", середні_рядків.shape, "— вісь лишилась довжиною 1")

відхилення = X - середні_рядків
print("результат віднімання:", відхилення.shape, "— форма X збереглась")
без keepdims: (1200,) — вісь викинуто
з keepdims:   (1200, 1) — вісь лишилась довжиною 1
результат віднімання: (1200, 4) — форма X збереглась

7 · Маски: відбір рядків¶

Порівняння над масивом повертає масив відповідей — маску. Її головна цінність у тому, що маскою можна індексувати: X[маска] залишить рядки, навпроти яких True.

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

In [14]:
дешеві = y < np.median(y)             # маска довжиною 1200

print("тип маски:", дешеві.dtype)
print("перші шість:", дешеві[:6])
print("скільки таких оголошень:", дешеві.sum())
print("яка їх частка:", дешеві.mean().round(3))
print()
print("X[дешеві].shape =", X[дешеві].shape, "· y[дешеві].shape =", y[дешеві].shape)
print("обидві вибірки лишились узгодженими — це головне")
тип маски: bool
перші шість: [ True False  True False False False]
скільки таких оголошень: 600
яка їх частка: 0.5

X[дешеві].shape = (600, 4) · y[дешеві].shape = (600,)
обидві вибірки лишились узгодженими — це головне

Тепер знахідка. Ми не знаємо, як влаштований генератор, і питаємо дані: чи повʼязаний вік акаунта з шахрайством? Маска над однією ознакою, .mean() над іншою — і відповідь у два рядки.

In [15]:
вік = X[:, 3]                          # четвертий стовпець матриці ознак
молоді = вік < 60                      # акаунт молодший за два місяці

print("акаунтів молодших за 60 днів:", молоді.sum())
print("серед них шахрайських оголошень:", is_fraud[молоді].mean().round(3))
print("серед решти акаунтів:          ", is_fraud[~молоді].mean().round(3))
print()
print("різниця в", round(is_fraud[молоді].mean() / is_fraud[~молоді].mean()), "разів —")
print("вік акаунта явно повʼязаний із шахрайством")
акаунтів молодших за 60 днів: 42
серед них шахрайських оголошень: 0.476
серед решти акаунтів:           0.041

різниця в 12 разів —
вік акаунта явно повʼязаний із шахрайством

Умови поєднуються операторами &, |, ~ — і кожну обовʼязково беруть у дужки. Звичні and та or тут не працюють: вони хочуть звести масив до одного True/False.

In [16]:
підозрілі = (is_fraud == 1) & (вік < 60)

print("шахрайських із молодим акаунтом:", підозрілі.sum())
print()
print(f"середня ціна шахрайського оголошення: {y[is_fraud == 1].mean():.0f} грн")
print(f"середня ціна чесного:                 {y[is_fraud == 0].mean():.0f} грн")
print()
# вибірка за номерами (fancy indexing): рядки в потрібному порядку
номери = np.array([2, 0, 7, 3])
print("X[номери] — чотири рядки в заданому порядку:")
print(X[номери])
шахрайських із молодим акаунтом: 20

середня ціна шахрайського оголошення: 3375 грн
середня ціна чесного:                 9651 грн

X[номери] — чотири рядки в заданому порядку:
[[2.023e+03 4.000e+00 3.200e+01 3.320e+02]
 [2.023e+03 1.000e+00 6.400e+01 9.180e+02]
 [2.021e+03 4.000e+00 1.280e+02 1.338e+03]
 [2.022e+03 3.000e+00 1.280e+02 1.188e+03]]

8 · Випадковість, яку можна повторити¶

Перемішування потрібне майже завжди: рядки в базі часто лежать не випадково, і брати перші 900 «на навчання» небезпечно. Мішають номери, а потім тими самими номерами переставляють і X, і y — інакше пари розʼїдуться.

In [17]:
порядок = rng.permutation(len(y))      # 1200 номерів у випадковому порядку

X_міш = X[порядок]
y_міш = y[порядок]                     # ТІ САМІ номери, інакше все зламається

print("перші пʼять номерів:", порядок[:5])
print()
# пари мають лишитись цілими: рядок під новим номером 0 — це старий рядок порядок[0]
assert np.array_equal(X_міш[0], X[порядок[0]]), "рядок поїхав!"
assert y_міш[0] == y[порядок[0]], "таргет поїхав!"
print("✅ пари «ознаки → відповідь» лишились цілими")
перші пʼять номерів: [848 180 461 795 276]

✅ пари «ознаки → відповідь» лишились цілими
In [18]:
# вибірка без повернення: жодного рядка не візьмемо двічі
проба = rng.choice(len(y), size=200, replace=False)

print("розмір вибірки:", len(проба), "· різних номерів:", len(np.unique(проба)))
print()
# два незалежні генератори з тим самим зерном дають ту саму послідовність
перший = np.random.default_rng(42).integers(0, 100, size=5)
другий = np.random.default_rng(42).integers(0, 100, size=5)
print("генератор із зерном 42:", перший)
print("він же ще раз:         ", другий)
assert np.array_equal(перший, другий), "відтворюваність зламалась!"
print("✅ те саме зерно — та сама послідовність")
розмір вибірки: 200 · різних номерів: 200

генератор із зерном 42: [ 8 77 65 43 43]
він же ще раз:          [ 8 77 65 43 43]
✅ те саме зерно — та сама послідовність

9 · Пастка: копія чи подання¶

Найтихіша помилка теми. Зріз масиву не копіює даних — він створює подання (view) на ту саму памʼять. Запис у подання змінює оригінал, і жодного попередження не буде.

Розділимо дошку на дві частини й «полагодимо» першу.

In [19]:
запасна_копія = X.copy()               # щоб потім усе повернути

тренувальні = X[:900]                  # це ПОДАННЯ, а не копія
print("np.shares_memory(X, тренувальні):", np.shares_memory(X, тренувальні))

до = X[0, 3]
тренувальні[0, 3] = 0                  # правимо, як здається, «свою» частину
після = X[0, 3]

print(f"X[0, 3] було {до:.0f}, стало {після:.0f} — оригінал змінився")
assert після == 0, "а мало б зіпсуватись!"
print("✅ пастка спрацювала")
np.shares_memory(X, тренувальні): True
X[0, 3] було 918, стало 0 — оригінал змінився
✅ пастка спрацювала

А тепер те саме з .copy() — і оригінал лишається цілим.

In [20]:
X = запасна_копія.copy()               # відновлюємо дошку

тренувальні = X[:900].copy()           # тепер це власний блок памʼяті
print("np.shares_memory(X, тренувальні):", np.shares_memory(X, тренувальні))

тренувальні[0, 3] = 0
print(f"X[0, 3] = {X[0, 3]:.0f} — оригінал цілий")
assert X[0, 3] == 918, "копія не вберегла оригінал!"
print("✅ .copy() розірвав звʼязок")
print()
print("і ще одна деталь: маска й список номерів завжди дають копію")
print("np.shares_memory(X, X[дешеві]):", np.shares_memory(X, X[дешеві]))
np.shares_memory(X, тренувальні): False
X[0, 3] = 918 — оригінал цілий
✅ .copy() розірвав звʼязок

і ще одна деталь: маска й список номерів завжди дають копію
np.shares_memory(X, X[дешеві]): False

10 · Дірки в даних¶

Реальні дані неповні: у частини оголошень поле не заповнене. Дірку позначають особливим значенням np.nan, і воно вміє заражати все, чого торкнеться: одна дірка на тисячу значень робить nan із усього підсумку.

In [21]:
вік_з_дірками = X[:, 3].copy()
вік_з_дірками[[7, 103, 940]] = np.nan   # три оголошення без даних про акаунт

print("скільки дірок:", np.isnan(вік_з_дірками).sum(), "на", len(вік_з_дірками), "значень")
print()
print("звичайне середнє:", вік_з_дірками.mean())
print("nanmean:         ", round(np.nanmean(вік_з_дірками), 2))
print("справжнє середнє:", round(X[:, 3].mean(), 2))
print()
# порівнянням дірку не знайти: nan не дорівнює навіть сам собі
print("(вік_з_дірками == np.nan).sum() =", (вік_з_дірками == np.nan).sum(), "← нуль, а дірок три")
print("np.isnan(вік_з_дірками).sum()   =", np.isnan(вік_з_дірками).sum(), "← так правильно")
скільки дірок: 3 на 1200 значень

звичайне середнє: nan
nanmean:          758.09
справжнє середнє: 759.15

(вік_з_дірками == np.nan).sum() = 0 ← нуль, а дірок три
np.isnan(вік_з_дірками).sum()   = 3 ← так правильно

11 · Цілочисельний тип і порівняння дробових¶

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

In [22]:
вік_цілий = X[:, 3].astype(np.int64)

print("вік у роках через //:", (вік_цілий[:5] // 365), "← акаунт, молодший за рік, став нулем")
print("вік у роках через / :", (вік_цілий[:5] / 365).round(2))
print()
вузький = вік_цілий[:5].astype(np.int16)      # діапазон -32768…32767
print("вік у int16:", вузький)
print("× 100:      ", вузький * 100, "← переповнення, без жодного попередження")
print()
print("(0.1 + 0.2) == 0.3:", (0.1 + 0.2) == 0.3)
print("np.isclose(0.1 + 0.2, 0.3):", np.isclose(0.1 + 0.2, 0.3))
вік у роках через //: [2 2 0 3 2] ← акаунт, молодший за рік, став нулем
вік у роках через / : [2.52 2.45 0.91 3.25 2.84]

вік у int16: [ 918  895  332 1188 1037]
× 100:       [ 26264  23964 -32336 -12272 -27372] ← переповнення, без жодного попередження

(0.1 + 0.2) == 0.3: False
np.isclose(0.1 + 0.2, 0.3): True

Завдання¶

🟢 Рівень 1 — База¶

Додай до матриці ознак пʼятий стовпець — ціну за гігабайт памʼяті (y / X[:, 2]), і склади нову матрицю через np.column_stack. Виведи shape, dtype і mean(axis=0) нової матриці.

Зроблено, якщо: форма стала (1200, 5), а середнє нового стовпця виведено числом.

🟡 Рівень 2 — Плюс¶

Замість масштабування «мінімум-розмах» зроби стандартизацію: відняти середнє стовпця й поділити на його стандартне відхилення (X.mean(axis=0), X.std(axis=0)). Перевір assert np.allclose(...), що середні стовпців стали нулями, а розкид — одиницями, і порівняй результат зі sklearn.preprocessing.StandardScaler.

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

🔴 Рівень 3 — Виклик¶

Напиши функцію поділ(X, y, частка_тесту=0.25, зерно=0), яка перемішує рядки власним np.random.default_rng(зерно) і повертає чотири масиви: X_train, X_test, y_train, y_test. Функція не має міняти переданих X і y — доведи це assert-ом, порівнявши їх із копією, зробленою до виклику. Прогони функцію з пʼятьма різними зернами й подивись, наскільки коливається середня ціна в тестовій частині.

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