Практика · NumPy для ML¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Та сама дошка оголошень про вживані телефони, що й у лекції: 1200 записів, сім колонок. Ми доведемо її до тієї форми, у якій дані приймає будь-яка бібліотека машинного навчання, і навчимось рахувати над нею так, щоб не чекати. Що зробимо:
- Зберемо матрицю ознак
Xі вектор таргетаyіз сирих колонок. - Поміряємо, скільки коштує список Python проти масиву — у байтах і в мілісекундах.
- Порахуємо статистики по осях і побачимо, чим
axis=0відрізняється відaxis=1. - Відмасштабуємо ознаки транслюванням і доведемо, що вийшло те саме, що в бібліотеки.
- Відберемо рядки масками й знайдемо звʼязок між віком акаунта й шахрайством.
- Спіймаємо пастку «копія чи подання» на живому прикладі.
- Побачимо, як три дірки
NaNпсують середнє на 1200 значень і як це лікується.
Усе відтворюване: генератор випадкових чисел один і той самий — np.random.default_rng(42).
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.
Ціна складається з базової вартості моделі, надбавки за свіжість і памʼять, надбавки за стан і випадкового розкиду. Шахрайське оголошення — це різко занижена ціна, і трапляється воно набагато частіше в акаунтів, створених щойно. Саме цей звʼязок ми потім знайдемо масками, не знаючи його наперед.
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).
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, але для списку цього мало: він поверне лише розмір
самого списку, тобто масиву адрес. Справжня ціна — це список плюс усі обʼєкти,
на які він указує.
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 і беремо найкращий із семи прогонів: найкращий час
менш чутливий до того, що в цю мить робить решта системи. Пʼятдесят тисяч чисел —
достатньо, щоб різниця була очевидною, і мало, щоб зошит не гальмував.
розмір = 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 разів твоє число буде іншим: заміри залежать від машини, від її завантаження й навіть від того, як операційна система видає памʼять під проміжний масив. Якщо вийшло менше — це нормально. Важить порядок, а не точна цифра.
Результати мають збігатися до останнього знака — це та сама арифметика, просто виконана двома способами. Перевіримо це, а не повіримо на слово.
assert np.allclose(циклом(), вектором()), "цикл і векторизація дали різне!"
print("✅ цикл і векторизація дають однаковий результат")
print("перші три значення:", вектором()[:3])
✅ цикл і векторизація дають однаковий результат перші три значення: [0.18962257 0.07982514 0.0326693 ]
5 · Статистики по осях¶
axis=k — це вісь, уздовж якої рухається підсумовування, і саме вона зникає з форми.
На матриці ознак це дві дуже різні дії:
axis=0— рухаємось згори вниз по рядках; зникають оголошення, лишаються ознаки;axis=1— рухаємось зліва направо по стовпцях; зникають ознаки, лишаються оголошення.
назви = ["рік", "стан", "памʼять", "вік акаунта"]
середні_ознак = 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 зникла
Останній стовпець таблиці — головне, що з неї варто винести. Розмахи ознак відрізняються більш ніж у двісті разів: сім років проти півтори тисячі днів. До цього ми повернемось у наступному розділі.
А тепер те саме по другій осі — і одразу видно, чому сирі ознаки по рядку усереднювати марно.
середні_оголошень = 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().
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 рядків —
і кожне число застосовується до свого стовпця.
розмахи = максимуми - мінімуми
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. Усередині бібліотеки немає магії, там та сама
пара «відняти мінімум, поділити на розмах».
бібліотечний = MinMaxScaler().fit_transform(X)
assert np.allclose(X_шкала, бібліотечний), "наше масштабування розійшлося з бібліотечним!"
print("✅ збігається з MinMaxScaler")
print("найбільша розбіжність:", np.abs(X_шкала - бібліотечний).max())
✅ збігається з MinMaxScaler найбільша розбіжність: 4.057865155004947e-14
Помилка, яку робить кожен¶
А тепер спробуємо відняти середнє рядка, а не стовпця. Форми (1200, 4) і (1200,)
не узгоджуються: коротшу доповнюють одиницею зліва, і четвірка стикається з тисячею
двомастами. Клітинка нижче падає навмисно — прочитай текст помилки, ти бачитимеш його часто.
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, одиниця розтягнеться — і все спрацює.
середні_рядків = 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.
Зверни увагу: маску ми будуємо по таргету, а застосовуємо до матриці ознак. Це працює, бо довжина маски дорівнює кількості рядків.
дешеві = 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() над іншою —
і відповідь у два рядки.
вік = 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.
підозрілі = (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 — інакше пари розʼїдуться.
порядок = 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] ✅ пари «ознаки → відповідь» лишились цілими
# вибірка без повернення: жодного рядка не візьмемо двічі
проба = 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) на ту саму памʼять. Запис у подання змінює оригінал, і жодного попередження не буде.
Розділимо дошку на дві частини й «полагодимо» першу.
запасна_копія = 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() — і оригінал лишається цілим.
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 із усього підсумку.
вік_з_дірками = 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 · Цілочисельний тип і порівняння дробових¶
Дві менші пастки, які варто побачити своїми очима. Перша: якщо матрицю склали з цілих,
// мовчки обріже дробову частину, а вузький тип піде по колу. Друга: рівність
дробових чисел оператором == перевіряти марно.
вік_цілий = 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 про незмінність входу проходить, а розкид середньої ціни
по пʼятьох зернах виведено числом і прокоментовано словами.