Тридцять дві теми ми обходились тим, що дає сама мова. Списки, словники, цикли, генератори — цього досить, щоб написати майже будь-що. А тепер починається блок, у якому Python сам себе відсуває вбік: перша ж бібліотека, з якою ми познайомимось, робить те, чого мова не вміє й не намагається вміти — рахує над мільйонами чисел так, ніби Python тут ні до чого.
Наскрізний приклад у цій темі один — журнал продажів мережі кавʼярень. Чотири точки, сім днів тижня, у кожній комірці — скільки чашок продали. Ми переберемо на ньому все: створення, зрізи, векторні операції, підсумки за осями, відбір за умовою. Датасет не змінюватиметься до кінця лекції.
01 / НавіщоНавіщо ще один тип для чисел
У темі 06 ми розібрали, як влаштований список, і головна фраза звучала так: комірка списку не вміщує елемент, вона вказує на обʼєкт, який живе окремо. Саме тому в списку можуть лежати поруч число, рядок і ще один список — усі комірки однакового розміру, бо в кожній лежить не значення, а адреса на вісім байтів.
Ця гнучкість не безкоштовна. Список із чотирьох чисел — це насправді пʼять обʼєктів: сам список і чотири окремі цілих, кожне зі своїм лічильником посилань, своїм заголовком і своїм місцем у купі. Коли чисел мільйон, обʼєктів мільйон і один, а розкидані вони там, де знайшлось місце.
Масив NumPy влаштований протилежно. Він виділяє один суцільний шматок памʼяті й кладе туди самі числа, підряд, кожне однакового розміру. Ніяких обʼєктів, ніяких адрес, ніяких заголовків — просто байти.
Схема 1 · Одні й ті самі чотири числа, збережені двома способами
Ліворуч — те, що ми малювали в темі 06. Праворуч — те, що робить NumPy.
k можна порахувати арифметикою замість того, щоб її
читати.Звідси й обіцянка, яку ми перевіримо числами в розділі 06: та сама дія над мільйоном чисел у масиві виконується не на відсотки, а у десятки разів швидше — і платою за це буде відмова від права тримати в одній структурі різні типи.
02 / АнатоміяЧотири числа про масив
Тип масиву зветься ndarray — n-dimensional array, масив із
n вимірами. Про будь-який масив варто знати чотири речі, і всі чотири він розповідає
сам:
shape — кортеж довжин по кожному виміру. У ньому стільки
чисел, скільки вимірів, і саме він буде головним героєм розділів 07 і 08. Одновимірний
масив із семи чисел має форму (7,) — кома там не помилка, а нагадування, що
це кортеж з одного елемента.
dtype — тип даних, спільний для всього масиву. Не «типи
елементів», а один тип. Це пряме продовження схеми вище: усі елементи однакового
розміру, крок між ними сталий, адреса рахується множенням. Дозволити різні типи означало
б зруйнувати саме те, заради чого масив і потрібен.
Схема 2 · Адреса комірки й паспорт масиву
Той самий журнал продажів. Праворуч — усе, що масив знає про себе.
shape. Тому продажі[2, 5] означає
«рядок 2, стовпець 5» — Кампус, субота, 40 чашок.Ще два числа стають у пригоді, коли йдеться про памʼять: itemsize —
скільки байтів займає один елемент, nbytes — скільки займає весь масив.
Друге дорівнює size × itemsize і рахує тільки дані:
int64, якщо ми ніде цього не просили.
NumPy дивиться на те, що йому дали, і обирає найзагальніший тип, який усе вміщує.
Цілі — int64, є хоч одне дробове — float64 на весь масив, є
хоч один рядок — усе стає рядками. Останнє особливо підступне:
np.array([1, 2, "три"]) дає масив із dtype('<U21'), тобто
рядків, і одиниця там уже не число, а текст «1».03 / СтворенняЯк зробити масив
Способів багато, але щодня потрібні шість. Перший — з готового списку, ним ми вже скористались:
Решта пʼять створюють масив без списка — одразу потрібного розміру. Це
принципово: збудувати мільйон елементів списком, щоб потім віддати їх у
np.array, означає заплатити за список повну ціну й аж тоді заощадити.
Пара arange / linspace варта окремої уваги, бо їх постійно
плутають. arange задає крок і не включає правий кінець —
точно як range з теми 12.
linspace задає кількість точок і правий кінець включає.
Для дробових кроків правильний завжди другий, і ось чому:
Причина — похибка з теми 04: 0.1 не
зберігається точно, три таких кроки дають трохи менше за 0.3, і остання точка
проскакує всередину. Скільки чисел поверне arange із дробовим кроком,
наперед не скаже ніхто. linspace такої проблеми не має принципово:
кількість точок ти задаєш сам.
Шостий спосіб — випадкові числа. Тут у NumPy є старий інтерфейс
(np.random.rand, np.random.seed) і новий, і користуватись треба
новим:
default_rng, а не np.random.seed.
Старий інтерфейс тримає один глобальний стан на всю програму: будь-яка
бібліотека, яка теж смикне np.random, зсуне твою послідовність — і результат
перестане відтворюватись без жодних змін у твоєму коді. default_rng(42)
повертає власний незалежний генератор: скільки їх створиш, стільки й буде.04 / ДоступІндекс і зріз у двох вимірах
Синтаксис уже знайомий із теми 05 і
теми 06: номери з нуля, відʼємні відлічують з кінця,
зріз початок:кінець не включає кінець. Нове тут одне — кома.
Вона розділяє виміри, і те, що стоїть до неї, стосується рядків, а після — стовпців:
40. Починаючи з NumPy 2, окремий
елемент показує себе разом із типом: np.int64(40). Це те саме число — воно
додається, порівнюється й друкується як звичайне (print(продажі[2, 5]) дасть
40). Тип у записі лише нагадує, що перед тобою елемент масиву з фіксованим
dtype, а не звичайний Python-int, який росте необмежено. Різниця
не косметична — до неї ми повернемось у розділі 10.Двокрапка сама по собі означає «весь цей вимір цілком». Тому [:, 5]
читається як «всі рядки, стовпець 5», і саме так дістають колонку. Спроба написати
продажі[5] замість цього дасть IndexError, бо рядків усього
чотири.
Зрізи працюють по обох вимірах одночасно й дають прямокутний шматок таблиці:
У вкладеному списку такої вибірки просто не існує: таблиця[1:3] дасть
два рядки цілком, а щоб узяти з них ще й стовпці, довелося б писати включення по кожному
рядку. Кома в дужках — не косметика, а окрема можливість.
05 / ВиглядЗріз — це вікно, а не копія
А тепер найважливіше в цій темі. У темі 06 ми
з полегшенням зʼясували, що зріз списку — це нова структура: змінюй її
скільки завгодно, оригінал у безпеці. Ба більше, a[:] було одним із чотирьох
офіційних способів зробити копію.
У NumPy усе рівно навпаки, і це не виняток, а правило:
Зріз масиву не копіює жодного байта. Він створює новий обʼєкт-ndarray,
який дивиться на ту саму памʼять, лише з іншого місця й, можливо, з іншим
кроком. Такий обʼєкт називають виглядом (view), і запис у нього — це
запис у вихідний масив.
Інтерактив 1 · Вигляд, копія і зріз списку — поруч
Обери спосіб, потім тисни «наступний крок» і дивись, що станеться з оригіналом.
частина[0] = 0 над тими самими числами. У списку він чіпає лише
зріз, у масиві — оригінал. Середній варіант показує ліки: .copy() виділяє
новий блок памʼяті й розриває звʼязок. Рядок «спільна памʼять» — це те, що відповідає
np.shares_memory(тиждень, частина).Ліки одні — попросити копію явно. Метод зветься так само, як у списку, але робить
принципово інше: не поверхневу копію посилань, а новий блок памʼяті з новими
числами. А перевірити, вигляд перед тобою чи самостійний масив, можна двома
способами: атрибут base у вигляду вказує на масив-власника даних, а пряме
питання np.shares_memory відповідає без здогадок.
return не потрібно, жодного попередження не буде. Правило просте: функція
або явно міняє переданий масив на місці (і це написано в її назві та
документації), або починає з дані = вхід.copy(). Мовчазна середина — джерело
багатогодинного пошуку.Навіщо так? Масив на мільйон чисел — це 8 МБ. Якби кожен зріз копіював дані, вираз
дані[10:-10].sum() витрачав би 8 МБ і кілька мілісекунд, щоб просто
подивитись на шматок. Вигляд коштує близько сотні байтів: усередині нього лише адреса
початку, форма й крок.
a[[0, 2]]) або булевою маскою (розділ 09), NumPy змушений зібрати
розкидані елементи разом — і повертає копію. Логіка та сама:
вигляд можливий тільки там, де потрібні елементи лежать із рівним кроком.06 / ВекторизаціяВекторизація
Тепер найприємніше. Щоб додати до всіх цін 20 %, у чистому Python потрібен цикл або включення з теми 13. У NumPy цикл не потрібен:
Запис масив * 1.2 не множить масив на число в математичному сенсі —
він застосовує множення до кожного елемента й повертає новий масив.
Так само поводяться +, -, /, **,
порівняння й функції на кшталт np.sqrt, np.log,
np.round. Це і зветься векторизацією (vectorization).
Два масиви теж поєднуються поелементно. Ось акція «вихідні дешевші», записана одним рядком — сім коефіцієнтів застосовуються до всіх чотирьох магазинів:
Але головний аргумент — не стислість, а час. Ось одна й та сама дія
(× 1.2 над кожним числом) трьома способами, на чотирьох розмірах даних:
Схема 3 · Скільки коштує та сама дія
Найкращий із пʼятнадцяти прогонів, CPython 3.12, NumPy 2.5, звичайний ноутбук.
Звідки береться різниця в сто разів? З того, що включення робить на кожному кроці й чого не робить масив.
На кожен із мільйона елементів: узяти з комірки списку адресу, сходити за нею в купу, дістати обʼєкт, спитати в нього тип, знайти метод множення для цієї пари типів, покликати його, створити новий обʼєкт-число, покласти адресу в новий список. Мільйон разів.
Один раз: подивитись у dtype, зрозуміти, що це мільйон
float64 підряд, виділити вихідний блок і віддати керування циклу,
написаному на C. Далі — мільйон множень без жодної перевірки типу, бо тип уже
відомий і в кожного елемента однаковий.
Оскільки числа лежать поруч, процесор бере їх пачками по чотири-вісім штук і множить одну пачку однією інструкцією (це зветься SIMD). Для розкиданого списку такий трюк неможливий у принципі — числа не в сусідніх байтах.
Практичний висновок звучить категорично, і він справді категоричний: якщо ти
пишеш for по елементах масиву NumPy — майже напевно є векторний спосіб
зробити те саме. Цикл по масиву не просто не швидший за цикл по списку, а
приблизно вдвічі повільніший (чому — у розділі 10).
07 / ТранслюванняТранслювання
У прикладі зі знижкою щойно сталося щось непояснене. Масив продажі має
форму (4, 7), масив знижка — (7,). Форми різні, а
множення спрацювало й дало (4, 7). Механізм, який це зробив, зветься
транслюванням (broadcasting), і це головна ідея NumPy після самого
масиву.
Правило одне й перевіряється воно з кінця форм — справа наліво:
- Коротшу форму доповнюють одиницями зліва:
(7,)стає(1, 7). - Далі порівнюють вісь за віссю. Довжини рівні — усе гаразд.
Одна з них дорівнює одиниці — цей масив розтягується вздовж осі,
повторюючи своє єдине значення. Будь-яке інше поєднання —
ValueError.
Розтягування — фігура мови: NumPy нічого не копіює, він просто ходить по тій самій памʼяті кілька разів (крок уздовж розтягнутої осі дорівнює нулю). Тому транслювання безкоштовне за памʼяттю навіть тоді, коли результат у сто разів більший за вихідний масив.
Інтерактив 2 · Дві форми: поєднаються чи впаде
Обери форму зліва й форму справа. Суцільні клітинки — справжні дані, пунктирні — те, що NumPy повторює.
(4, 7) з
(4,) падає, хоч четвірка є в обох — бо порівняння йде з кінця, і
4 стикається з 7. (4, 1) з (7,) дає (4, 7),
хоч жодна форма на неї не схожа — розтягуються обидва масиви одразу.
А (3, 4) з (1, 7) падає одразу на останній осі: 4 проти 7.Найчастіше транслювання потрібне, щоб порівняти кожен рядок із його власним підсумком. Скажімо, ми хочемо побачити відхилення кожного дня від середнього по цьому магазину. Перша спроба падає:
Причина рівно та, що на схемі: (4,) перетворюється на (1, 4),
і четвірка стикається з сімкою. Нам треба, щоб середні стали стовпцем,
а не рядком, — тобто набули форми (4, 1). Способи два:
Другий варіант кращий: keepdims=True просить агрегацію не викидати вісь,
а лишити її довжиною 1. Тоді форма зберігається як (4, 1) і транслювання
працює саме собою, а в коді немає числа 4, яке доведеться правити разом із даними.
08 / Осіaxis=0 і axis=1
Масив уміє рахувати підсумки: sum, mean, min,
max, std, argmax. Без аргументів кожна з них
складає весь масив в одне число:
Цікаве починається з аргументом axis — і саме тут спотикаються всі. Ось
формулювання, яке знімає плутанину назавжди: axis=k — це вісь,
уздовж якої рухається підсумовування, і саме вона зникає з форми результату.
Схема 4 · Одна таблиця, два різні підсумки
Стрілки показують, уздовж чого рухається додавання.
(4, 7) при axis=0 втрачає перше число й стає
(7,); при axis=1 втрачає друге й стає (4,).
Тому «сума по магазинах» — це axis=1, хоч інтуїція часто підказує нуль:
магазини — це вісь 0, але ми йдемо вздовж днів, щоб отримати число на кожен
магазин.З цими двома рядками журнал одразу починає говорити. Пʼятниця — найкращий день мережі (700 чашок), неділя — найгірший (505). Найбільше продає Кампус? Ні: Центр, 1205 проти 1160, — і це попри те, що в суботу з неділею Кампус майже стоїть.
Так само працюють і решта агрегацій. argmax вертає не значення, а
номер — і в парі з осями це найзручніший спосіб відповісти на питання
«коли саме»:
k
завжди перетворює форму (d₀, …, dk, …, dn)
на ту саму форму без dk. Це правило працює
однаково для будь-якої кількості вимірів: для картинки форми
(висота, ширина, 3) запис картинка.mean(axis=2) усереднює три
канали кольору й дає чорно-білу (висота, ширина). А keepdims=True
з розділу 07 — це прохання лишити на місці зниклої осі одиницю, щоб результат далі
транслювався на вихідний масив.09 / МаскиБулеві маски
Порівняння теж векторизоване, і в цьому вся суть. Питання, задане масиву, повертається масивом відповідей:
Такий масив з dtype('bool') називають маскою. Сам по
собі він не дуже цікавий — цікаво те, що маскою можна індексувати. У квадратних дужках
вона означає «залиш тільки ті елементи, навпроти яких True»:
Схема 5 · Що відбувається всередині тиждень[тиждень > 150]
Три кроки, які зазвичай пишуть одним рядком.
True дорівнює одиниці, тому .sum() над маскою
рахує, скільки днів пройшли поріг, а .mean() — яка частка
днів його пройшла. Це найкоротший спосіб порахувати відсоток чого завгодно.Маска працює й на двовимірному масиві. Тільки результат там завжди одновимірний: підходящі клітинки розкидані по таблиці, прямокутника з них не складеш, тому NumPy просто вишиковує їх у рядок:
Умови поєднуються — але не тими словами, до яких ти звик з
теми 11. Замість and,
or, not тут &, |,
~, і кожну умову обовʼязково беруть у дужки:
Помилка показова. Оператор and хоче звести обидві частини до одного
True або False — а масив із семи відповідей одним логічним
значенням стати не може. Оператор & натомість працює поелементно й дає
таку саму маску з семи значень. Дужки обовʼязкові, бо & звʼязує сильніше
за > і без них вираз розпадеться не там.
І остання дрібниця, яка знадобиться постійно: np.where(умова, якщо_так,
якщо_ні) не викидає елементи, а замінює їх, зберігаючи форму:
10 / ГлибшеДля тих, кому мало
Чому dtype — це рішення, а не деталь. Мільйон чисел
коштує рівно itemsize байтів за штуку, і різниця величезна:
int8 — 0.95 МБ, int32 — 3.81 МБ, int64 — 7.63 МБ.
Для таблиці продажів, де числа не перевищують кількох сотень, int16
вистачило б із запасом і забрав би вчетверо менше памʼяті. Але тісний тип
мовчки переповнюється — і це не виняткова ситуація, а звичайна
арифметика по колу:
Сто помножити на три дало 44. Жодного винятку, жодного повідомлення — просто нове число. Практичне правило: тип звужують усвідомлено й лише тоді, коли памʼять справді тисне, і завжди із запасом на проміжні обчислення.
Точність — це той самий компроміс, тільки для дробових. У
темі 04 ми зʼясували, чому
0.1 + 0.2 != 0.3: дробові зберігаються скінченною кількістю бітів. У
float64 на мантису відведено 52 біти, у float32 — лише 23, і
похибка кожної окремої операції зростає приблизно в 109 разів.
На довгих сумах вона накопичується:
float32 удвічі економніший і на графічних процесорах помітно швидший —
саме тому в машинному навчанні ваги моделей майже завжди зберігають у ньому. Але для
підсумків, різниць близьких чисел і будь-яких грошей потрібен float64.
Чому цикл по масиву повільніший за цикл по списку. Це виглядає
парадоксом: масив швидший, а цикл по ньому — ні. Заміри на мільйоні чисел, та сама
дія × 1.2:
Причина в тому, що в масиві немає обʼєктів. Беручи елемент зі списку,
включення отримує готовий обʼєкт — досить скопіювати адресу. Беручи елемент із масиву,
воно бачить голі байти, і Python зобовʼязаний загорнути їх у свіжий
numpy.float64: виділити памʼять, заповнити заголовок, завести лічильник
посилань. Мільйон разів створити й одразу викинути. Масив швидкий рівно доти, доки дані
не перетинають межу між C і Python — а кожен for по ньому перетинає її на
кожному кроці.
Дірки в даних і numpy.nan. Реальні вимірювання бувають
неповними: датчик мовчав, магазин не працював. Для таких дірок у дробових масивах є
особливе значення np.nan — «не число» (not a number). Воно вміє одну
неприємну річ: заражати все, чого торкнеться.
Пари nansum, nanmean, nanmax існують саме для
цього. Дві деталі, які колись зекономлять годину: np.nan не дорівнює сам
собі (шукати дірки порівнянням марно — для цього є np.isnan), і в
цілочисельному масиві його зберегти неможливо, тож масив із дірками завжди дробовий.
11 / ПідсумокЩо забрати з теми
Уся тема виростає з одного рішення: зберігати самі числа підряд замість посилань на розкидані обʼєкти. Далі — самі наслідки.
- Суцільний блок дає швидкість і вимагає одного типу. Мільйон цілих:
список — 38.6 МБ і мільйон обʼєктів, масив
int64— 7.63 МБ і один блок.dtype— один на весь масив, і змінити його на місці не можна. - Масив описують чотири числа:
shape,ndim,size,dtype— плюсnbytes, коли важлива памʼять. - Створювати краще одразу потрібного розміру:
zeros,ones,full,arange(крок, кінець не входить),linspace(кількість, кінець входить),default_rng(42). Кома в дужках розділяє виміри:a[:, 5]— цілий стовпець. - Зріз масиву — вигляд, а не копія. Це протилежність тому, як
поводиться список: запис у зріз змінює оригінал. Ліки —
.copy(), перевірка —np.shares_memory. Індексація списком номерів і маскою натомість завжди дає копію. - Векторизація замість циклу. Та сама дія на ста тисячах елементів:
6.3 мс включенням проти 0.036 мс масивом — у 176 разів швидше.
forпо масиву не просто зайвий, а вдвічі повільніший заforпо списку. - Транслювання перевіряється справа наліво: довжини або рівні, або
одна з них одиниця, інакше
ValueError. Щоб відняти підсумок рядка, потрібен стовпець —keepdims=True. axis=k— вісь, уздовж якої рахуємо, і саме вона зникає з форми.(4, 7)→axis=0→(7,);(4, 7)→axis=1→(4,).- Умова над масивом дає масив. Маска відбирає
(
a[a > 5]), рахує (.sum()), дає частку (.mean()). Поєднання —&,|,~і обовʼязкові дужки.
І тепер помітна дірка. Ми весь час памʼятали окремо, що рядок 2 — це Кампус, а стовпець 5 — субота, і переводили номери в назви руками. Масив про підписи не знає нічого — його стихія однорідні числа. Але реальні дані однорідними не бувають: у тій самій таблиці стоять і назва міста, і дата, і виторг. Тема 34 додає над масивом саме те, чого йому бракує, — імена рядків і стовпців та різні типи в різних колонках. Усе, що ти щойно вивчив, там працює далі: pandas усередині — це ті самі масиви NumPy.
practice.ipynb ти збереш той
самий журнал продажів; зміряєш nbytes проти памʼяті списку; візьмеш
двовимірні зрізи; спіймаєш пастку вигляду assert-ом — і на тому, що
оригінал змінився, і на тому, що з .copy() він цілий; заміряєш
цикл проти векторизації власним timeit і побачиш свої числа; збудуєш
таблицю відхилень транслюванням і доведеш, що вона збігається з порахованою вручну;
пройдешся по осях і по масках.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.