Машинне навчання · Блок 2 · Тема 07

Прямокутник чисел

Модель не бачить таблиці з назвами колонок. Вона бачить матрицю X і вектор y — і більше нічого. Ця тема про те, як звести дані до цієї форми й рахувати над нею так, щоб не чекати.

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

Наскрізний приклад лишається тим самим, що й у всьому курсі: дошка оголошень про вживані телефони. Тисяча двісті оголошень, у кожного сім полів — model, year, condition, memory_gb, account_age_days, price, is_fraud. У темі 02 ми домовились, що частина цих полів — ознаки, а одне з них — те, що ми хочемо передбачити. Зараз ми доведемо цю домовленість до байтів.

Що вважається відомим. Сам масив — що це таке, як його створити, як індексувати, що таке dtype і звідки взагалі береться транслювання — ми розібрали в темі 33 курсу Python. Тут ми на основи не повертаємось. Тут той самий масив розглянуто з іншого боку: як посудину, у якій живуть дані для навчання, і як калькулятор, що рахує над ними. Якщо якийсь синтаксис здасться незнайомим — він пояснений там.

01 / ФормаФорма, у якій живуть дані

Бібліотеки машинного навчання приймають дані однаково: scikit-learn, XGBoost, LightGBM і десяток інших мають той самий метод навчання — fit(X, y). За цими двома буквами стоїть жорстка домовленість:

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

>>> X.shape (1200, 4) # 1200 оголошень, 4 числові ознаки >>> y.shape (1200,) # 1200 цін — по одній на оголошення >>> X.dtype dtype('float64') >>> X[2] array([2023., 4., 32., 332.]) # третє оголошення цілком >>> X[:, 1] # ознака «стан» для всіх оголошень array([1., 3., 4., ..., 3., 2., 4.], shape=(1200,))

Читай ці два рядки як речення. X[2] — «усе, що ми знаємо про третє оголошення»: 2023 рік, стан 4, 32 гігабайти, акаунт віком 332 дні. X[:, 1] — «ознака стан, якою вона є в усій дошці». Перше потрібно, коли робиш передбачення для одного оголошення; друге — коли вивчаєш ознаку.

Схема 1 · Що модель бачить замість таблиці

Ті самі пʼять оголошень і три ролі колонок: ознаки, відповідь і те, що поки лишається текстом.

Одна таблиця — і вже дві різні задачі. Візьмеш стовпцем відповідей price — вийде регресія: передбачити ціну. Візьмеш is_fraud — вийде класифікація: розпізнати шахрайське оголошення. Дані ті самі, X той самий, міняється лише y. Колонка model у X поки не потрапила: там текст, а масив тримає числа одного типу.

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

>>> одна = X[:, 2] # памʼять, форма (1200,) >>> одна.reshape(-1, 1).shape # -1 означає «порахуй сам» (1200, 1)

І друга умова, на якій спотикається кожен новачок: X.shape[0] має дорівнювати y.shape[0]. Якщо десь по дорозі ти відфільтрував рядки в X, але забув відфільтрувати y, бібліотека скаже Found input variables with inconsistent numbers of samples: [1180, 1200] — і це найдобріша з можливих помилок, бо вона хоча б є. Гірший сценарій — коли довжини збіглися, а порядок ні: тоді ніхто нічого не скаже, а модель вчитиметься на випадкових парах.

02 / Ціна виборуЧому не списки

Прямокутник чисел можна тримати й у вкладеному списку: [[2023, 1, 64, 918], [2019, 3, 128, 895], …]. Виглядає так само, читається так само. Різниця виявляється на двох питаннях: скільки це важить і скільки триває.

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

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

середнє = sum(ціни) / len(ціни) розмах = max(ціни) - min(ціни) нові = [] # варіант зі списком for ціна in ціни: нові.append((ціна - середнє) / розмах) нові = (ціни_масив - середнє) / розмах # варіант із масивом

Інтерактив 1 · Список проти масиву: час і памʼять

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

цикл
вектор
швидше у
памʼяті менше
Заміри: Python 3.12, NumPy 2.5, чотириядерний настільний процесор; найкращий час із багатьох прогонів. Абсолютні числа на твоїй машині будуть інші — важить лише порядок величини. І не чекай рівної, монотонної кривої: заміри часу сильно залежать від того, чим ще зайнятий процесор, від розміру його кеша й від того, як система видає памʼять. Той самий вимір, повторений за годину, легко дає удвічі інше співвідношення. Надійне тут одне: векторизація виграє десятки разів, і що більший масив, то помітніший виграш у памʼяті. Памʼять, на відміну від часу, рахується точно: рівно вчетверо на будь-якому розмірі, 32 байти на число проти 8.

Для матриці ознак це означає ось що. Наші 1200 × 4 — це 38 400 байтів у масиві й 230 456 байтів у вкладеному списку: шестикратна різниця, бо там до тридцяти двох байтів на число додається ще й окремий список-обгортка на кожен рядок. На такому розмірі це дрібниця. Але дошка оголошень із мільйоном записів і сотнею ознак — це 0.8 ГБ проти понад 3 ГБ, і другий варіант просто не поміститься в памʼять ноутбука. Вибір структури перестає бути питанням смаку рівно тоді, коли даних стає багато, — а в машинному навчанні їх стає багато завжди.

03 / ВекторизаціяВекторизація

Головна ідея цієї теми вміщується в одне речення: замість того щоб самому обходити елементи, віддай усю операцію масиву цілком. Запис (ціни - середнє) / розмах не описує дію над одним числом — він описує дію над усіма ста тисячами. Це й зветься векторизацією (vectorization).

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

Таблиця 1 · Що коштує одне число

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

крокцикл у Pythonвекторна операція
дістати числоузяти адресу з комірки, сходити за нею в купу, знайти обʼєктпрочитати вісім байтів за адресою «початок + k×8»
зрозуміти типщоразу спитати обʼєкт, який він, і знайти метод множення для цієї пари типіводин раз на весь масив: dtype уже відомий
порахувативиклик методу з інтерпретатораінструкція процесора всередині циклу, написаного на C
покласти результатстворити новий обʼєкт-число, завести лічильник посилань, дописати адресу в списокзаписати вісім байтів у заздалегідь виділений блок
скільки разівусе перелічене — на кожен елементперші два — один раз, решта — на кожен елемент
Уся економія — в останньому рядку. Цикл на C не питає типів, бо тип у масиві один на всіх, і не створює обʼєктів, бо в масиві їх немає. Додатковий бонус: числа лежать поруч, тому процесор бере їх пачками по чотири-вісім штук і множить пачку однією інструкцією — це зветься SIMD. Для розкиданих по купі обʼєктів такий трюк неможливий у принципі.
Векторизація — це не паралельність. Поширена помилка: «швидше, бо рахує на всіх ядрах». Ні. Звичайна операція над масивом виконується в одному потоці, на одному ядрі. Прискорення дають зняття накладних витрат інтерпретатора, суцільна памʼять і векторні інструкції процесора — усе те, що в таблиці вище. Кілька ядер NumPy справді задіює, але лише у важких лінійно-алгебраїчних операціях на кшталт множення матриць, які всередині віддаються бібліотеці BLAS. Різниця важлива практично: якщо чекати паралельності від X * 2, то й діагностувати повільний код будеш не там.

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

04 / ОсіКуди зникає вісь

Масив уміє рахувати підсумки: sum, mean, std, min, max, argmax. Без аргументів кожен із них згортає весь масив в одне число. З аргументом axis починається місце, де плутаються всі, — тому одразу формулювання, яке знімає плутанину: axis=k — це вісь, уздовж якої рухається підсумовування, і саме вона зникає з форми результату.

На матриці ознак це перекладається в дві дуже різні за змістом дії:

Інтерактив 2 · Куди зникає вісь

Перші пʼять рядків нашої матриці. Перемикай вісь — і дивись, що згортається й яка форма лишається.

агрегація
виклик
форма
чисел
Підказка, яка ніколи не підводить: дивись на форму, а не на слова. (5, 4) при axis=0 втрачає перше число й стає (4,); при axis=1 втрачає друге й стає (5,). Тому «середнє по оголошеннях» — це axis=0, хоч інтуїція часто підказує одиницю: оголошення справді лежать уздовж осі 0, і саме тому по ній ми й рухаємось.

Ось як це виглядає на всій дошці. Чотири числа з axis=0 — це паспорт наших ознак, і кожне з них ще знадобиться:

>>> X.mean(axis=0).round(2) array([2019.48, 2.99, 116.53, 759.15]) >>> X.std(axis=0).round(2) array([ 2.3 , 1.42, 84.59, 427.16]) >>> X.min(axis=0) array([2016., 1., 32., 1.]) >>> X.max(axis=0) array([2023., 5., 256., 1499.])

Читається це так: середній телефон на дошці — 2019 року, стану «3 з 5», зі 117 гігабайтами, виставлений акаунтом віком два роки. А ще одразу видно те, що зіпсує нам життя в наступному розділі: розмахи колонок відрізняються в сотні разів. У року розмах сім, у стану — чотири, у памʼяті — 224, у віку акаунта — 1498.

Таргет y одновимірний, тому осі йому не потрібні — там просто y.mean(). Середня ціна на дошці — 9300 грн, медіана — 9203 грн, і те, що вони майже збіглися, каже, що розподіл не перекошений одиничними мільйонними лотами.

Для тих, кому мало: keepdims. Агрегація викидає вісь, і це зазвичай зручно — але не тоді, коли результат треба відняти назад від матриці. X.mean(axis=1) дає (1200,), і транслювання з (1200, 4) не спрацює (розділ 05 пояснить чому). Аргумент keepdims=True просить лишити на місці зниклої осі одиницю: X.mean(axis=1, keepdims=True) дає (1200, 1), і все стає на місця. Правило просте: агрегував по осі й збираєшся віднімати назад — став keepdims=True.

05 / ТранслюванняТранслювання в роботі

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

мінімуми = X.min(axis=0) # форма (4,) розмахи = X.max(axis=0) - мінімуми # форма (4,) X_шкала = (X - мінімуми) / розмахи # форма (1200, 4)

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

У нашому випадку (4,) стає (1, 4), одиниця розтягується на 1200 рядків, і кожне з чотирьох чисел віднімається від свого стовпця. Саме те, що треба: кожна ознака масштабується власним мінімумом і власним розмахом. Ніякого копіювання при цьому не відбувається — NumPy просто ходить по тих самих чотирьох числах 1200 разів.

А тепер помилка, яку робить кожен. Уяви, що ти хочеш відняти не середнє ознаки, а середнє рядка:

>>> X - X.mean(axis=1) ValueError: operands could not be broadcast together with shapes (1200,4) (1200,)

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

Інтерактив 3 · Що з чим віднімається

Обери, від чого віднімаємо і що. Форми вирівнюються праворуч — саме так їх звіряє NumPy.

A — від чого
B — що віднімаємо
форма результату
Три поєднання варто перебрати обовʼязково. (1200, 4) з (1200,) падає, хоч тисяча двісті є в обох, — бо звіряння йде з кінця. (1200, 4) з (1200, 1) працює, і це та сама пара, лише з keepdims=True. А (1200, 1) з (1200,) не падає — і саме тому найнебезпечніша: NumPy мовчки збудує матрицю 1200 × 1200, тобто 1 440 000 чисел і майже 11 МБ там, де ти очікував стовпчик.
Правило, що рятує від мовчазної помилки. Транслювання не запитує, чи ти цього хотів, — воно перевіряє лише сумісність форм. Тому після кожної операції, де форми різні, варто дивитись на shape результату, а не на те, чи не було помилки. Якщо ти віднімав щось від матриці ознак і форма перестала бути (n, k) — сталось не те, що ти планував, навіть якщо код не впав.

Ще один момент, важливий надалі. Мінімум і розмах — це числа, пораховані по даних. Якщо ти порахуєш їх по всій дошці, а потім поділиш її на навчальну й тестову частини (тема 03), то в масштабуванні вже сидітиме інформація з тестової частини — і твоя оцінка якості стане трохи оптимістичнішою за правду. Правильний порядок протилежний: спершу поділ, потім підсумки по навчальній частині, потім застосування цих самих чисел до тестової. Докладно про це — далі в курсі, у темі про передобробку; поки що досить памʼятати сам порядок.

06 / МаскиМаски й вибірка рядків

Порівняння над масивом теж векторизоване: питання, задане масиву, повертається масивом відповідей. Масив із dtype('bool') називають маскою, і його головна цінність у тому, що маскою можна індексувати.

>>> дешеві = y < np.median(y) # маска довжиною 1200 >>> дешеві[:6] array([ True, False, True, False, False, False]) >>> дешеві.sum() np.int64(600) # скільки оголошень дешевші за медіану >>> X[дешеві].shape (600, 4) # ті самі рядки матриці ознак

Зверни увагу на останній рядок — у ньому вся суть. Маска побудована по таргету, а застосована до матриці ознак. Це працює, бо довжина маски дорівнює кількості рядків, і NumPy розуміє її як «залиш рядки, навпроти яких True». Так само можна відібрати й самі відповіді: y[дешеві]. Дві вибірки лишаться узгодженими одна з одною — те, чого ми вимагали в розділі 01.

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

>>> вік = X[:, 3] # вік акаунта >>> молоді = вік < 60 >>> молоді.sum() np.int64(42) # акаунтів молодших за два місяці >>> is_fraud[молоді].mean().round(3) np.float64(0.476) # серед них шахрайських — 47.6 % >>> is_fraud[~молоді].mean().round(3) np.float64(0.041) # серед решти — 4.1 %

Різниця майже в дванадцять разів — це вже не спостереження, а знахідка: вік акаунта явно повʼязаний із шахрайством. Прийом, яким вона отримана, вартий окремої уваги. Маска з dtype('bool') у Python поводиться як нулі й одиниці, тому .sum() над нею рахує скільки, а .mean()яка частка. Це найкоротший спосіб порахувати відсоток чого завгодно, і в наступних темах ти писатимеш його щодня.

Складна умова виглядає так само буденно:

>>> підозрілі = (is_fraud == 1) & (вік < 60) >>> підозрілі.sum() np.int64(20) >>> y[is_fraud == 1].mean().round(0) np.float64(3375.0) # середня ціна шахрайського оголошення >>> y[is_fraud == 0].mean().round(0) np.float64(9651.0) # середня ціна чесного

Шахрайські оголошення в середньому втричі дешевші — 3375 грн проти 9651 грн. Ознака «ціна», яку ми брали як таргет для регресії, виявилась сильною ознакою для іншої задачі. Такі перехресні спостереження — звичайна річ, і саме заради них дані розглядають руками, перш ніж будувати щось складне.

Поряд із масками є ще вибірка за номерами (fancy indexing): замість умови подають масив індексів. Це те, чим користуються, коли рядки треба взяти в конкретному порядку — наприклад, у перемішаному:

>>> номери = np.array([2, 0, 7, 3]) >>> X[номери].shape (4, 4) >>> X[номери, 1] # ознака «стан» у цих чотирьох оголошеннях array([4., 1., 4., 3.])
І маска, і список номерів дають копію. Це важливо памʼятати разом із розділом 08: звичайний зріз X[:900] копії не робить, а X[маска] і X[номери] — роблять завжди. Логіка проста: вигляд можливий тільки там, де потрібні елементи лежать із рівним кроком, а відібрані маскою рядки розкидані як завгодно. Практичний наслідок подвійний: такий відбір коштує памʼяті (на половині мільйонної дошки — сотні мегабайтів), зате він безпечний — запис у нього оригіналу не зачепить.

07 / ВипадковістьВипадковість, яку можна повторити

Випадкові числа в машинному навчанні потрібні постійно: перемішати рядки перед поділом на частини, узяти підвибірку для швидкого експерименту, згенерувати дані для перевірки власного коду. І щоразу діє вимога, яка на перший погляд суперечить самому слову «випадковий»: результат має повторюватись.

Причина суто практична. Без зафіксованого зерна (seed) твої числа не відтворить ніхто — включно з тобою завтра. Ти напишеш, що якість вийшла 0.87, колега запустить той самий код і отримає 0.84, і ви півдня зʼясовуватимете, хто помилився, хоча не помилився ніхто.

>>> rng = np.random.default_rng(42) # 42 — зерно, будь-яке ціле >>> rng.integers(0, 100, size=5) array([ 8, 77, 65, 43, 43]) >>> np.random.default_rng(42).integers(0, 100, size=5) array([ 8, 77, 65, 43, 43]) # те саме зерно — та сама послідовність

Користуватись треба саме default_rng, а не старим np.random.seed(42) з наступними np.random.rand. Різниця не косметична. Старий інтерфейс тримає один глобальний стан на всю програму: будь-яка бібліотека, яка теж смикне np.random — а їх смикають десятки, — зсуне твою послідовність, і відтворюваність зникне без жодної зміни у твоєму коді. default_rng(42) повертає власний незалежний генератор: скільки їх створиш, стільки й буде, і кожен живе своїм життям.

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

порядок = rng.permutation(len(y)) # 1200 номерів у випадковому порядку X_міш = X[порядок] y_міш = y[порядок] # ТІ САМІ номери — інакше все зламається

Другий — вибірка без повернення, коли треба взяти частину даних і жодного рядка не взяти двічі:

>>> проба = rng.choice(len(y), size=200, replace=False) >>> len(np.unique(проба)) 200 # усі різні, як і просили
Чого зерно не робить. Фіксоване зерно потрібне для відтворюваності, а не для покращення результату. Якщо якість помітно стрибає від зміни зерна — це не привід підібрати вдале число, а сигнал, що даних замало або поділ нестабільний. Правильна реакція — прогнати кілька зерен і подивитись на розкид: якщо результат від зерна до зерна гуляє більше, ніж різниця, яку ти намагаєшся виміряти, то міряти поки що нічого. Систематичні способи це робити ми розберемо далі в курсі.

08 / ПасткиПастки

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

Зріз — це подання, а не копія

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

Схема 2 · Подання й копія на матриці ознак

Один і той самий зріз, узятий двома способами. Різниця видно лише тоді, коли в нього щось записати.

Найтихіша форма цієї пастки — функція. Передав масив у функцію, записав щось у його зріз — і змінився масив у того, хто викликав. Жодного return не потрібно, жодного попередження не буде. Правило: функція або явно міняє переданий масив на місці, і це написано в її назві, або починає з дані = вхід.copy(). Мовчазна середина — джерело багатогодинного пошуку.

У роботі з даними ця пастка має улюблену форму. Ти ділиш дошку на дві частини, потім масштабуєш навчальну — і мовчки псуєш усю матрицю:

тренувальні = X[:900] # ПОДАННЯ, не копія тренувальні[0, 3] = 0 # правимо, як здається, «свою» частину X[0, 3] # 0.0 — оригінал теж змінився >>> np.shares_memory(X, X[:900]) True >>> np.shares_memory(X, X[:900].copy()) False

Цілочисельне ділення й переповнення

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

>>> вік = np.array([918, 895, 332]) >>> вік // 365 # «скільки повних років акаунту» array([2, 2, 0]) # третій акаунт став нульовим >>> малий = вік.astype(np.int16) >>> малий * 100 array([ 26264, 23964, -32336], dtype=int16)

Девʼятсот вісімнадцять помножити на сто дало 26 264, а 332 — узагалі відʼємне число. Жодного винятку, жодного попередження. Практичне правило для матриці ознак: тримай X у float64. Один .astype(np.float64) одразу після збирання матриці знімає обидві проблеми, а платою буде щонайбільше кілька зайвих байтів на число.

NaN отруює агрегати

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

>>> вік = X[:, 3].copy() >>> вік[[7, 103, 940]] = np.nan # три оголошення без даних >>> вік.mean() np.float64(nan) # три дірки на 1200 зіпсували підсумок >>> np.nanmean(вік).round(2) np.float64(758.09) # версія, яка дірки пропускає >>> np.isnan(вік).sum() np.int64(3) # шукати дірки порівнянням марно

Найважливіше тут — nan не дорівнює сам собі. Вираз вік == np.nan поверне суцільні False, і дірки не знайдуться; питати треба np.isnan. І ще: у цілочисельному масиві nan зберегти неможливо, тому масив із дірками завжди дробовий — ще один аргумент за float64.

Порівняння дробових

Дробові числа зберігаються скінченною кількістю бітів, тому арифметика над ними дає крихітні похибки. Перевіряти рівність оператором == — марно:

>>> (0.1 + 0.2) == 0.3 False >>> np.isclose(0.1 + 0.2, 0.3) np.True_ >>> np.allclose(X_шкала.min(axis=0), 0) # перевірка всього масиву одним рядком True

Пара isclose / allclose — це стандартний спосіб перевіряти себе. Саме нею в практиці ми доводитимемо, що масштабування, зроблене руками, збігається з бібліотечним.

Таблиця 2 · Пастки, які не викидають помилки

Читай як чек-лист: щось не сходиться — пройдись згори вниз.

пасткаяк проявляєтьсяперевірка й ліки
зріз — це поданнязміна «шматка» тихо міняє весь масивnp.shares_memory(a, b) · .copy()
цілочисельний dtype// обрізає, вузький тип переповнюєтьсяX.dtype · X.astype(np.float64)
NaN в данихmean і sum дають nannp.isnan(a).sum() · np.nanmean
порівняння дробовихa == b дає False на рівних числахnp.isclose · np.allclose
транслювання спрацювало не такпомилки немає, форма результату чужадивись на .shape після кожної операції
X і y розʼїхалисьдовжини різні або порядок різнийlen(X) == len(y) · відбирай обидва однією маскою
Перші чотири рядки — про NumPy, останні два — про машинне навчання. Спільне в них те, що жоден не супроводжується повідомленням про помилку: програма доходить до кінця й видає числа. Тому єдиний надійний захист — перевіряти самому: shape після кожного перетворення форми, dtype після складання матриці, isnan після завантаження даних.

09 / ПідсумокЩо забрати з теми

Уся тема тримається на одному спостереженні: дані для навчання — це прямокутник чисел, і рахувати над ним треба цілком, а не поелементно. Далі — самі наслідки.

І тепер помітна дірка. Ми весь час памʼятали окремо, що стовпець 3 — це вік акаунта, а колонка model взагалі лишилась за бортом, бо в ній текст. Реальна таблиця неоднорідна: у ній поруч стоять назва моделі, дата, число й позначка «так/ні». Масив такого не вміє — його стихія однорідні числа.

Наступна тема додає над масивом рівно те, чого йому бракує: імена колонок, різні типи в різних стовпцях і зручні способи згрупувати, звести й подивитись. Це бібліотека pandas, і разом із нею ми візьмемось за розвідувальний аналіз — той етап, на якому в дані дивляться, перш ніж щось із ними робити. Усе, що ти прочитав тут, там працює далі: pandas усередині — це ті самі масиви NumPy. Якщо синтаксис таблиць хочеться побачити заздалегідь, він розібраний у темі 34 курсу Python.

Далі в практиці. У practice.ipynb ти збереш матрицю ознак із сирих колонок і перевіриш її форму; заміряєш цикл проти векторизації власним секундоміром і побачиш свої числа; порахуєш статистики по обох осях; відмасштабуєш ознаки транслюванням і доведеш np.allclose, що вийшло те саме, що в бібліотеки; відбереш рядки масками; спіймаєш пастку подання на живому прикладі — і на тому, що оригінал змінився, і на тому, що з .copy() він цілий; побачиш, як три дірки псують середнє на 1200 значень.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.