Блок «Фундамент» був про слова. Обʼєкт, ознака, таргет, навчальна й тестова частини, метрика — це поняття, якими зручно розмовляти про задачу. Але між розмовою й обчисленням стоїть питання, яке ми досі обходили: у якому саме вигляді дані потрапляють у памʼять і що з ними там можна робити швидко. Відповідь на першу половину майже безальтернативна, а різниця між «швидко» й «повільно» в другій — десятки разів.
Наскрізний приклад лишається тим самим, що й у всьому курсі: дошка оголошень
про вживані телефони. Тисяча двісті оголошень, у кожного сім полів —
model, year, condition, memory_gb,
account_age_days, price, is_fraud. У
темі 02 ми домовились, що частина
цих полів — ознаки, а одне з них — те, що ми хочемо передбачити. Зараз ми доведемо цю
домовленість до байтів.
dtype і звідки взагалі береться транслювання — ми
розібрали в темі 33 курсу Python.
Тут ми на основи не повертаємось. Тут той самий масив розглянуто з іншого боку: як
посудину, у якій живуть дані для навчання, і як калькулятор, що рахує над ними. Якщо
якийсь синтаксис здасться незнайомим — він пояснений там.01 / ФормаФорма, у якій живуть дані
Бібліотеки машинного навчання приймають дані однаково: scikit-learn,
XGBoost, LightGBM і десяток інших мають той самий метод
навчання — fit(X, y). За цими двома буквами стоїть жорстка домовленість:
X— двовимірний масив. Один рядок — один обʼєкт (у нас: одне оголошення). Один стовпець — одна ознака. Велика літера натякає на матрицю.y— одновимірний масив. Стільки чисел, скільки рядків уX, іy[i]— це відповідь дляX[i]. Мала літера натякає на вектор.
Домовленість не випливає з математики — її просто прийняли всі. Але саме тому вона важлива: щойно твої дані набули цієї форми, будь-яка бібліотека курсу починає з ними працювати без перекладача.
Читай ці два рядки як речення. 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.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: відняти середнє й поділити на розмах. Спершу так, як це написав би той, хто масивів не знає, потім так, як це пишуть насправді.
Інтерактив 1 · Список проти масиву: час і памʼять
Посунь повзунок і подивись, як росте розрив. Числа справжні — заміряні заздалегідь, не пораховані формулою.
Для матриці ознак це означає ось що. Наші 1200 × 4 — це 38 400 байтів у
масиві й 230 456 байтів у вкладеному списку: шестикратна різниця, бо там до тридцяти двох
байтів на число додається ще й окремий список-обгортка на кожен рядок. На такому розмірі
це дрібниця. Але дошка оголошень із мільйоном записів і сотнею ознак — це 0.8 ГБ проти
понад 3 ГБ, і другий варіант просто не поміститься в памʼять ноутбука. Вибір структури перестає бути питанням смаку рівно тоді,
коли даних стає багато, — а в машинному навчанні їх стає багато завжди.
03 / ВекторизаціяВекторизація
Головна ідея цієї теми вміщується в одне речення: замість того щоб самому
обходити елементи, віддай усю операцію масиву цілком. Запис
(ціни - середнє) / розмах не описує дію над одним числом — він описує дію над
усіма ста тисячами. Це й зветься векторизацією (vectorization).
Різниця в часі виглядає майже неправдоподібною, тому варто розібрати, звідки вона береться. Не з магії й не з паралельності — про це нижче окремо.
Таблиця 1 · Що коштує одне число
Дві колонки — це та сама арифметика, виконана двома способами. Порівнюй рядок за рядком.
| крок | цикл у Python | векторна операція |
|---|---|---|
| дістати число | узяти адресу з комірки, сходити за нею в купу, знайти обʼєкт | прочитати вісім байтів за адресою «початок + k×8» |
| зрозуміти тип | щоразу спитати обʼєкт, який він, і знайти метод множення для цієї пари типів | один раз на весь масив: dtype уже відомий |
| порахувати | виклик методу з інтерпретатора | інструкція процесора всередині циклу, написаного на C |
| покласти результат | створити новий обʼєкт-число, завести лічильник посилань, дописати адресу в список | записати вісім байтів у заздалегідь виділений блок |
| скільки разів | усе перелічене — на кожен елемент | перші два — один раз, решта — на кожен елемент |
X * 2, то й діагностувати
повільний код будеш не там.Практичне правило звучить категорично, і воно справді категоричне: якщо ти
пишеш for по рядках матриці ознак — майже напевно є векторний спосіб зробити
те саме. Причому цикл саме по масиву гірший за цикл по списку: беручи елемент зі
списку, Python отримує готовий обʼєкт, а беручи з масиву — голі байти, які доводиться
загортати в новий обʼєкт на кожному кроці. Масив швидкий рівно доти, доки дані не
перетинають межу між C і Python; кожна ітерація for перетинає її двічі.
04 / ОсіКуди зникає вісь
Масив уміє рахувати підсумки: sum, mean, std,
min, max, argmax. Без аргументів кожен із них
згортає весь масив в одне число. З аргументом axis починається місце, де
плутаються всі, — тому одразу формулювання, яке знімає плутанину:
axis=k — це вісь, уздовж якої рухається підсумовування, і
саме вона зникає з форми результату.
На матриці ознак це перекладається в дві дуже різні за змістом дії:
axis=0— рух згори вниз, по рядках. Зникають обʼєкти, лишаються ознаки: з(1200, 4)виходить(4,). Це характеристика ознаки: середнє значення, розкид, мінімум і максимум колонки.axis=1— рух зліва направо, по стовпцях. Зникають ознаки, лишаються обʼєкти: з(1200, 4)виходить(1200,). Це характеристика обʼєкта: одне число на кожне оголошення.
Інтерактив 2 · Куди зникає вісь
Перші пʼять рядків нашої матриці. Перемикай вісь — і дивись, що згортається й яка форма лишається.
(5, 4) при axis=0 втрачає перше число й стає
(4,); при axis=1 втрачає друге й стає (5,).
Тому «середнє по оголошеннях» — це axis=0, хоч інтуїція часто підказує
одиницю: оголошення справді лежать уздовж осі 0, і саме тому по ній ми й
рухаємось.Ось як це виглядає на всій дошці. Чотири числа з axis=0 — це паспорт
наших ознак, і кожне з них ще знадобиться:
Читається це так: середній телефон на дошці — 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 / ТранслюванняТранслювання в роботі
Тепер зробимо те, заради чого рахували підсумки. Розмахи ознак відрізняються в сотні разів, і для багатьох методів це проблема: ознака, виміряна у великих числах, просто своїм масштабом переважить решту. Ліки — привести всі колонки до спільної шкали. Найпростіший спосіб: відняти мінімум і поділити на розмах, щоб кожна колонка лягла в проміжок від нуля до одиниці.
Зупинись на середньому рядку. Ми віднімаємо масив із чотирьох чисел від матриці
1200 × 4 — і це працює. Механізм зветься транслюванням
(broadcasting), і його правило перевіряється з кінця форм, справа наліво:
коротшу форму доповнюють одиницями зліва, потім порівнюють вісь за віссю, і довжини мають
або збігатися, або одна з них має дорівнювати одиниці.
У нашому випадку (4,) стає (1, 4), одиниця розтягується на
1200 рядків, і кожне з чотирьох чисел віднімається від свого стовпця. Саме те, що
треба: кожна ознака масштабується власним мінімумом і власним розмахом. Ніякого копіювання
при цьому не відбувається — NumPy просто ходить по тих самих чотирьох числах 1200 разів.
А тепер помилка, яку робить кожен. Уяви, що ти хочеш відняти не середнє ознаки, а середнє рядка:
Причина рівно в правилі. (1200,) доповнюється зліва й стає
(1, 1200); порівняння йде з кінця, і 4 стикається з 1200. Тисяча двісті — не
чотири й не одиниця, отже ValueError. Ліки — keepdims=True з
попереднього розділу: тоді форма буде (1200, 1), з кінця зустрінуться 4 і 1,
одиниця розтягнеться, і все спрацює.
Інтерактив 3 · Що з чим віднімається
Обери, від чого віднімаємо і що. Форми вирівнюються праворуч — саме так їх звіряє NumPy.
(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') називають маскою, і його
головна цінність у тому, що маскою можна індексувати.
Зверни увагу на останній рядок — у ньому вся суть. Маска побудована по
таргету, а застосована до матриці ознак. Це працює, бо
довжина маски дорівнює кількості рядків, і NumPy розуміє її як «залиш рядки, навпроти яких
True». Так само можна відібрати й самі відповіді: y[дешеві]. Дві
вибірки лишаться узгодженими одна з одною — те, чого ми вимагали в розділі 01.
Умови поєднуються операторами & (і), | (або),
~ (не), і кожну умову обовʼязково беруть у дужки. Звичні and та
or тут не працюють: вони хочуть звести масив до одного
True/False, а він на це не здатен. Ось запитання, заради якого
взагалі дивляться на дані:
Різниця майже в дванадцять разів — це вже не спостереження, а знахідка: вік акаунта
явно повʼязаний із шахрайством. Прийом, яким вона отримана, вартий окремої уваги.
Маска з dtype('bool') у Python поводиться як нулі й одиниці, тому
.sum() над нею рахує скільки, а .mean() — яка
частка. Це найкоротший спосіб порахувати відсоток чого завгодно, і в наступних темах
ти писатимеш його щодня.
Складна умова виглядає так само буденно:
Шахрайські оголошення в середньому втричі дешевші — 3375 грн проти 9651 грн. Ознака «ціна», яку ми брали як таргет для регресії, виявилась сильною ознакою для іншої задачі. Такі перехресні спостереження — звичайна річ, і саме заради них дані розглядають руками, перш ніж будувати щось складне.
Поряд із масками є ще вибірка за номерами (fancy indexing): замість умови подають масив індексів. Це те, чим користуються, коли рядки треба взяти в конкретному порядку — наприклад, у перемішаному:
X[:900] копії не робить, а
X[маска] і X[номери] — роблять завжди. Логіка проста: вигляд
можливий тільки там, де потрібні елементи лежать із рівним кроком, а відібрані маскою
рядки розкидані як завгодно. Практичний наслідок подвійний: такий відбір коштує памʼяті
(на половині мільйонної дошки — сотні мегабайтів), зате він безпечний — запис у нього
оригіналу не зачепить.07 / ВипадковістьВипадковість, яку можна повторити
Випадкові числа в машинному навчанні потрібні постійно: перемішати рядки перед поділом на частини, узяти підвибірку для швидкого експерименту, згенерувати дані для перевірки власного коду. І щоразу діє вимога, яка на перший погляд суперечить самому слову «випадковий»: результат має повторюватись.
Причина суто практична. Без зафіксованого зерна (seed) твої числа не відтворить ніхто — включно з тобою завтра. Ти напишеш, що якість вийшла 0.87, колега запустить той самий код і отримає 0.84, і ви півдня зʼясовуватимете, хто помилився, хоча не помилився ніхто.
Користуватись треба саме default_rng, а не старим
np.random.seed(42) з наступними np.random.rand. Різниця не
косметична. Старий інтерфейс тримає один глобальний стан на всю програму:
будь-яка бібліотека, яка теж смикне np.random — а їх смикають десятки, — зсуне
твою послідовність, і відтворюваність зникне без жодної зміни у твоєму коді.
default_rng(42) повертає власний незалежний генератор: скільки їх створиш,
стільки й буде, і кожен живе своїм життям.
Два прийоми, які знадобляться найчастіше. Перший — перемішування рядків. Мішають
номери, а не самі дані, і потім однаковими номерами переставляють і
X, і y, щоб пари не розʼїхались:
Другий — вибірка без повернення, коли треба взяти частину даних і жодного рядка не взяти двічі:
08 / ПасткиПастки
Далі — чотири речі, на яких горять усі, включно з тими, хто пише на NumPy роками. Усі чотири обʼєднує одна властивість: вони не викидають помилки. Код працює, числа виходять, і лише результат не той.
Зріз — це подання, а не копія
У списку зріз створює нову структуру, і оригінал у безпеці. У масиві все навпаки: зріз не копіює жодного байта, він створює подання (view) — новий обʼєкт, який дивиться на ту саму памʼять. Запис у подання — це запис у вихідний масив.
Схема 2 · Подання й копія на матриці ознак
Один і той самий зріз, узятий двома способами. Різниця видно лише тоді, коли в нього щось записати.
return не потрібно, жодного попередження не буде. Правило: функція або
явно міняє переданий масив на місці, і це написано в її назві, або починає з
дані = вхід.copy(). Мовчазна середина — джерело багатогодинного
пошуку.У роботі з даними ця пастка має улюблену форму. Ти ділиш дошку на дві частини, потім масштабуєш навчальну — і мовчки псуєш усю матрицю:
Цілочисельне ділення й переповнення
Якщо ознаки склали з цілих, масив буде цілочисельним — і поводитиметься не так, як
очікує людина. Оператор // обріже дробову частину без попередження, а тісний
тип мовчки піде по колу:
Девʼятсот вісімнадцять помножити на сто дало 26 264, а 332 — узагалі відʼємне число.
Жодного винятку, жодного попередження. Практичне правило для матриці ознак:
тримай X у float64. Один
.astype(np.float64) одразу після збирання матриці знімає обидві проблеми, а
платою буде щонайбільше кілька зайвих байтів на число.
NaN отруює агрегати
Реальні дані завжди неповні: у частини оголошень поле не заповнене. Дірку в дробовому
масиві позначають особливим значенням np.nan — «не число». Воно вміє одну
неприємну річ: заражати все, чого торкнеться.
Найважливіше тут — nan не дорівнює сам собі. Вираз
вік == np.nan поверне суцільні False, і дірки не знайдуться;
питати треба np.isnan. І ще: у цілочисельному масиві nan
зберегти неможливо, тому масив із дірками завжди дробовий — ще один аргумент за
float64.
Порівняння дробових
Дробові числа зберігаються скінченною кількістю бітів, тому арифметика над ними дає
крихітні похибки. Перевіряти рівність оператором == — марно:
Пара isclose / allclose — це стандартний спосіб перевіряти
себе. Саме нею в практиці ми доводитимемо, що масштабування, зроблене руками, збігається
з бібліотечним.
Таблиця 2 · Пастки, які не викидають помилки
Читай як чек-лист: щось не сходиться — пройдись згори вниз.
| пастка | як проявляється | перевірка й ліки |
|---|---|---|
| зріз — це подання | зміна «шматка» тихо міняє весь масив | np.shares_memory(a, b) · .copy() |
цілочисельний dtype | // обрізає, вузький тип переповнюється | X.dtype · X.astype(np.float64) |
NaN в даних | mean і sum дають nan | np.isnan(a).sum() · np.nanmean |
| порівняння дробових | a == b дає False на рівних числах | np.isclose · np.allclose |
| транслювання спрацювало не так | помилки немає, форма результату чужа | дивись на .shape після кожної операції |
X і y розʼїхались | довжини різні або порядок різний | len(X) == len(y) · відбирай обидва однією маскою |
shape після кожного перетворення форми, dtype після
складання матриці, isnan після завантаження даних.09 / ПідсумокЩо забрати з теми
Уся тема тримається на одному спостереженні: дані для навчання — це прямокутник чисел, і рахувати над ним треба цілком, а не поелементно. Далі — самі наслідки.
Xдвовимірний,yодновимірний. Рядок — обʼєкт, стовпець — ознака,X.shape[0] == y.shape[0]. Одну ознаку подають стовпцем(n, 1), а не рядком(n,).- Масив проти списку: вчетверо менше памʼяті завжди (8 байтів проти 32) і десятки разів за швидкістю. На ста тисячах чисел наш замір дав 7.21 мс циклом проти 0.104 мс векторно — приблизно у 70 разів.
- Векторизація — це не паралельність. Прискорення дають зняття накладних витрат інтерпретатора, суцільна памʼять і векторні інструкції, а не кілька ядер.
axis=k— вісь, уздовж якої рахуємо, і саме вона зникає з форми.axis=0описує ознаку,axis=1описує обʼєкт. Збираєшся віднімати підсумок назад —keepdims=True.- Транслювання звіряє форми з кінця. Довжини або рівні, або одна з них одиниця. Це те, що дозволяє відняти від кожного стовпця його середнє одним рядком — і те, що мовчки будує матрицю на 11 МБ, якщо форму переплутати.
- Маска — це масив відповідей. Нею відбирають рядки
(
X[маска]), рахують кількість (.sum()) і частку (.mean()). Поєднання —&,|,~і обовʼязкові дужки. np.random.default_rng(42), а неnp.random.seed. Власний генератор замість глобального стану; зерно фіксують заради відтворюваності, а не заради кращого числа.- Чотири тихі пастки: зріз-подання, цілочисельний тип,
nanв агрегатах, порівняння дробових. Жодна з них не викидає помилки.
І тепер помітна дірка. Ми весь час памʼятали окремо, що стовпець 3 — це вік акаунта, а
колонка model взагалі лишилась за бортом, бо в ній текст. Реальна таблиця
неоднорідна: у ній поруч стоять назва моделі, дата, число й позначка «так/ні». Масив
такого не вміє — його стихія однорідні числа.
Наступна тема додає над масивом рівно те, чого йому бракує: імена колонок, різні типи в різних стовпцях і зручні способи згрупувати, звести й подивитись. Це бібліотека pandas, і разом із нею ми візьмемось за розвідувальний аналіз — той етап, на якому в дані дивляться, перш ніж щось із ними робити. Усе, що ти прочитав тут, там працює далі: pandas усередині — це ті самі масиви NumPy. Якщо синтаксис таблиць хочеться побачити заздалегідь, він розібраний у темі 34 курсу Python.
practice.ipynb ти збереш матрицю
ознак із сирих колонок і перевіриш її форму; заміряєш цикл проти векторизації власним
секундоміром і побачиш свої числа; порахуєш статистики по обох осях; відмасштабуєш ознаки
транслюванням і доведеш np.allclose, що вийшло те саме, що в бібліотеки;
відбереш рядки масками; спіймаєш пастку подання на живому прикладі — і на тому, що
оригінал змінився, і на тому, що з .copy() він цілий; побачиш, як
три дірки псують середнє на 1200 значень.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.