Python з нуля · Блок 8 · Тема 33

Суцільний блок чисел

Список зберігає адреси розкиданих обʼєктів. Масив NumPy зберігає самі числа поруч, одне за одним. З цієї єдиної відмінності випливає все інше: і швидкість у сто разів, і фіксований тип, і найпідступніша пастка теми.

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

Наскрізний приклад у цій темі один — журнал продажів мережі кавʼярень. Чотири точки, сім днів тижня, у кожній комірці — скільки чашок продали. Ми переберемо на ньому все: створення, зрізи, векторні операції, підсумки за осями, відбір за умовою. Датасет не змінюватиметься до кінця лекції.

магазини = ["Центр", "Вокзал", "Кампус", "Парк"] дні = ["пн", "вт", "ср", "чт", "пт", "сб", "нд"] продажі = np.array([ [180, 195, 175, 205, 240, 120, 90], # Центр — офіси, вихідні провальні [140, 135, 150, 145, 160, 155, 130], # Вокзал — рівно щодня [210, 230, 225, 240, 190, 40, 25], # Кампус — на вихідних пусто [ 60, 55, 70, 65, 110, 240, 260], # Парк — живе саме на вихідних ])

01 / НавіщоНавіщо ще один тип для чисел

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

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

Масив NumPy влаштований протилежно. Він виділяє один суцільний шматок памʼяті й кладе туди самі числа, підряд, кожне однакового розміру. Ніяких обʼєктів, ніяких адрес, ніяких заголовків — просто байти.

Схема 1 · Одні й ті самі чотири числа, збережені двома способами

Ліворуч — те, що ми малювали в темі 06. Праворуч — те, що робить NumPy.

Одна картинка пояснює обидві головні властивості масиву. Швидкість — бо процесор читає сусідні байти пачками й наперед підвантажує наступні; стрибати за адресами по всій купі вчетверо дорожче. Фіксований тип — бо крок між елементами має бути однаковий: якщо кожне число займає рівно 8 байтів, адресу елемента k можна порахувати арифметикою замість того, щоб її читати.

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

02 / АнатоміяЧотири числа про масив

Тип масиву зветься ndarrayn-dimensional array, масив із n вимірами. Про будь-який масив варто знати чотири речі, і всі чотири він розповідає сам:

>>> продажі.shape (4, 7) # форма: 4 рядки, 7 стовпців >>> продажі.ndim 2 # вимірів: таблиця — це два >>> продажі.size 28 # елементів усього: 4 × 7 >>> продажі.dtype dtype('int64') # тип КОЖНОГО елемента, один на весь масив

shape — кортеж довжин по кожному виміру. У ньому стільки чисел, скільки вимірів, і саме він буде головним героєм розділів 07 і 08. Одновимірний масив із семи чисел має форму (7,) — кома там не помилка, а нагадування, що це кортеж з одного елемента.

dtype — тип даних, спільний для всього масиву. Не «типи елементів», а один тип. Це пряме продовження схеми вище: усі елементи однакового розміру, крок між ними сталий, адреса рахується множенням. Дозволити різні типи означало б зруйнувати саме те, заради чого масив і потрібен.

Схема 2 · Адреса комірки й паспорт масиву

Той самий журнал продажів. Праворуч — усе, що масив знає про себе.

Запамʼятай напрямок осей — далі він знадобиться постійно. Вісь 0 іде вниз по рядках, вісь 1 — вправо по стовпцях, і саме в такому порядку вони записані у shape. Тому продажі[2, 5] означає «рядок 2, стовпець 5» — Кампус, субота, 40 чашок.

Ще два числа стають у пригоді, коли йдеться про памʼять: itemsize — скільки байтів займає один елемент, nbytes — скільки займає весь масив. Друге дорівнює size × itemsize і рахує тільки дані:

>>> продажі.itemsize 8 # int64 — це 64 біти, тобто 8 байтів >>> продажі.nbytes 224 # 28 × 8, і жодного зайвого байта
Чому саме int64, якщо ми ніде цього не просили. NumPy дивиться на те, що йому дали, і обирає найзагальніший тип, який усе вміщує. Цілі — int64, є хоч одне дробове — float64 на весь масив, є хоч один рядок — усе стає рядками. Останнє особливо підступне: np.array([1, 2, "три"]) дає масив із dtype('<U21'), тобто рядків, і одиниця там уже не число, а текст «1».

03 / СтворенняЯк зробити масив

Способів багато, але щодня потрібні шість. Перший — з готового списку, ним ми вже скористались:

>>> np.array([180, 195, 175]) array([180, 195, 175])

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

>>> np.zeros(5) # заготовка з нулів; за замовчуванням float64 array([0., 0., 0., 0., 0.]) >>> np.ones((2, 3), dtype=np.int64) # одиниці потрібної форми й типу array([[1, 1, 1], [1, 1, 1]]) >>> np.full((2, 2), 7) # заповнити чим завгодно array([[7, 7], [7, 7]]) >>> np.arange(0, 10, 2) # як range(), але одразу масив array([0, 2, 4, 6, 8]) >>> np.linspace(0, 1, 5) # 5 чисел від 0 до 1 ВКЛЮЧНО array([0. , 0.25, 0.5 , 0.75, 1. ])

Пара arange / linspace варта окремої уваги, бо їх постійно плутають. arange задає крок і не включає правий кінець — точно як range з теми 12. linspace задає кількість точок і правий кінець включає. Для дробових кроків правильний завжди другий, і ось чому:

>>> np.arange(1, 1.3, 0.1) array([1. , 1.1, 1.2, 1.3]) # чотири числа замість трьох! >>> np.arange(1, 1.3, 0.1)[-1] np.float64(1.3000000000000003) # права межа, якої тут бути не мало

Причина — похибка з теми 04: 0.1 не зберігається точно, три таких кроки дають трохи менше за 0.3, і остання точка проскакує всередину. Скільки чисел поверне arange із дробовим кроком, наперед не скаже ніхто. linspace такої проблеми не має принципово: кількість точок ти задаєш сам.

Шостий спосіб — випадкові числа. Тут у NumPy є старий інтерфейс (np.random.rand, np.random.seed) і новий, і користуватись треба новим:

>>> генератор = np.random.default_rng(42) # 42 — зерно, щоб числа повторювались >>> генератор.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. Старий інтерфейс тримає один глобальний стан на всю програму: будь-яка бібліотека, яка теж смикне np.random, зсуне твою послідовність — і результат перестане відтворюватись без жодних змін у твоєму коді. default_rng(42) повертає власний незалежний генератор: скільки їх створиш, стільки й буде.

04 / ДоступІндекс і зріз у двох вимірах

Синтаксис уже знайомий із теми 05 і теми 06: номери з нуля, відʼємні відлічують з кінця, зріз початок:кінець не включає кінець. Нове тут одне — кома. Вона розділяє виміри, і те, що стоїть до неї, стосується рядків, а після — стовпців:

>>> продажі[2, 5] # рядок 2, стовпець 5 — Кампус у суботу np.int64(40) >>> продажі[0] # увесь рядок 0 — тиждень Центру array([180, 195, 175, 205, 240, 120, 90]) >>> продажі[:, 5] # увесь стовпець 5 — субота в усіх магазинах array([120, 155, 40, 240])
Чому не просто 40. Починаючи з NumPy 2, окремий елемент показує себе разом із типом: np.int64(40). Це те саме число — воно додається, порівнюється й друкується як звичайне (print(продажі[2, 5]) дасть 40). Тип у записі лише нагадує, що перед тобою елемент масиву з фіксованим dtype, а не звичайний Python-int, який росте необмежено. Різниця не косметична — до неї ми повернемось у розділі 10.

Двокрапка сама по собі означає «весь цей вимір цілком». Тому [:, 5] читається як «всі рядки, стовпець 5», і саме так дістають колонку. Спроба написати продажі[5] замість цього дасть IndexError, бо рядків усього чотири.

Зрізи працюють по обох вимірах одночасно й дають прямокутний шматок таблиці:

>>> продажі[1:3, 4:7] # рядки 1-2, стовпці 4-6: Вокзал і Кампус, пт-нд array([[160, 155, 130], [190, 40, 25]]) >>> продажі[:, -2:] # усі магазини, останні два дні — вихідні array([[120, 90], [155, 130], [ 40, 25], [240, 260]])

У вкладеному списку такої вибірки просто не існує: таблиця[1:3] дасть два рядки цілком, а щоб узяти з них ще й стовпці, довелося б писати включення по кожному рядку. Кома в дужках — не косметика, а окрема можливість.

05 / ВиглядЗріз — це вікно, а не копія

А тепер найважливіше в цій темі. У темі 06 ми з полегшенням зʼясували, що зріз списку — це нова структура: змінюй її скільки завгодно, оригінал у безпеці. Ба більше, a[:] було одним із чотирьох офіційних способів зробити копію.

У NumPy усе рівно навпаки, і це не виняток, а правило:

>>> тиждень = np.array([180, 195, 175, 205, 240, 120, 90]) >>> частина = тиждень[1:4] >>> частина[0] = 0 >>> тиждень array([180, 0, 175, 205, 240, 120, 90]) # оригінал ЗМІНИВСЯ

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

Інтерактив 1 · Вигляд, копія і зріз списку — поруч

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

оригінал
частина
спільна памʼять
Порівняй перший варіант із третім — це той самий запис частина[0] = 0 над тими самими числами. У списку він чіпає лише зріз, у масиві — оригінал. Середній варіант показує ліки: .copy() виділяє новий блок памʼяті й розриває звʼязок. Рядок «спільна памʼять» — це те, що відповідає np.shares_memory(тиждень, частина).

Ліки одні — попросити копію явно. Метод зветься так само, як у списку, але робить принципово інше: не поверхневу копію посилань, а новий блок памʼяті з новими числами. А перевірити, вигляд перед тобою чи самостійний масив, можна двома способами: атрибут base у вигляду вказує на масив-власника даних, а пряме питання np.shares_memory відповідає без здогадок.

>>> частина = тиждень[1:4].copy() # окремі дані >>> частина[0] = 0 >>> тиждень array([180, 195, 175, 205, 240, 120, 90]) # цілий >>> тиждень[1:4].base is тиждень True >>> тиждень[1:4].copy().base is None True >>> np.shares_memory(тиждень, тиждень[1:4]) True
Найтихіша форма цієї пастки — функція. Якщо передати масив у функцію й записати щось у його зріз, зміниться масив у того, хто викликав. Жодного return не потрібно, жодного попередження не буде. Правило просте: функція або явно міняє переданий масив на місці (і це написано в її назві та документації), або починає з дані = вхід.copy(). Мовчазна середина — джерело багатогодинного пошуку.

Навіщо так? Масив на мільйон чисел — це 8 МБ. Якби кожен зріз копіював дані, вираз дані[10:-10].sum() витрачав би 8 МБ і кілька мілісекунд, щоб просто подивитись на шматок. Вигляд коштує близько сотні байтів: усередині нього лише адреса початку, форма й крок.

Де вигляду не буде. Правило «зріз — це вигляд» стосується звичайних зрізів із двокрапками. Щойно ти індексуєш масив списком номерів (a[[0, 2]]) або булевою маскою (розділ 09), NumPy змушений зібрати розкидані елементи разом — і повертає копію. Логіка та сама: вигляд можливий тільки там, де потрібні елементи лежать із рівним кроком.

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

Тепер найприємніше. Щоб додати до всіх цін 20 %, у чистому Python потрібен цикл або включення з теми 13. У NumPy цикл не потрібен:

ціни_список = [180.0, 195.0, 175.0] нові = [ц * 1.2 for ц in ціни_список] # включення: обходимо самі ціни_масив = np.array([180.0, 195.0, 175.0]) нові = ціни_масив * 1.2 # операція над УСІМ масивом одразу

Запис масив * 1.2 не множить масив на число в математичному сенсі — він застосовує множення до кожного елемента й повертає новий масив. Так само поводяться +, -, /, **, порівняння й функції на кшталт np.sqrt, np.log, np.round. Це і зветься векторизацією (vectorization).

Два масиви теж поєднуються поелементно. Ось акція «вихідні дешевші», записана одним рядком — сім коефіцієнтів застосовуються до всіх чотирьох магазинів:

>>> знижка = np.array([1.0, 1.0, 1.0, 1.0, 0.9, 0.8, 0.8]) >>> виторг = продажі * знижка # (4, 7) × (7,) — про це розділ 07 >>> виторг[0] array([180., 195., 175., 205., 216., 96., 72.])

Але головний аргумент — не стислість, а час. Ось одна й та сама дія (× 1.2 над кожним числом) трьома способами, на чотирьох розмірах даних:

Схема 3 · Скільки коштує та сама дія

Найкращий із пʼятнадцяти прогонів, CPython 3.12, NumPy 2.5, звичайний ноутбук.

Зверни увагу на останній рядок — пришвидшення там менше, ніж на сотні тисяч. Це не помилка вимірювання. На ста тисячах масив займає 800 КБ і цілком міститься в кеші процесора; на мільйоні це вже 8 МБ, і час починає зʼїдати не арифметика, а перевезення байтів з оперативної памʼяті. Векторизація прибирає накладні витрати Python, але не робить памʼять швидшою.

Звідки береться різниця в сто разів? З того, що включення робить на кожному кроці й чого не робить масив.

крок 1 · що робить включення

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

крок 2 · що робить масив

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

крок 3 · безкоштовний бонус

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

Практичний висновок звучить категорично, і він справді категоричний: якщо ти пишеш for по елементах масиву NumPy — майже напевно є векторний спосіб зробити те саме. Цикл по масиву не просто не швидший за цикл по списку, а приблизно вдвічі повільніший (чому — у розділі 10).

07 / ТранслюванняТранслювання

У прикладі зі знижкою щойно сталося щось непояснене. Масив продажі має форму (4, 7), масив знижка(7,). Форми різні, а множення спрацювало й дало (4, 7). Механізм, який це зробив, зветься транслюванням (broadcasting), і це головна ідея NumPy після самого масиву.

Правило одне й перевіряється воно з кінця форм — справа наліво:

  1. Коротшу форму доповнюють одиницями зліва: (7,) стає (1, 7).
  2. Далі порівнюють вісь за віссю. Довжини рівні — усе гаразд. Одна з них дорівнює одиниці — цей масив розтягується вздовж осі, повторюючи своє єдине значення. Будь-яке інше поєднання — ValueError.

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

Інтерактив 2 · Дві форми: поєднаються чи впаде

Обери форму зліва й форму справа. Суцільні клітинки — справжні дані, пунктирні — те, що NumPy повторює.

форма A
форма B
A + B
Три поєднання варто перебрати обовʼязково. (4, 7) з (4,) падає, хоч четвірка є в обох — бо порівняння йде з кінця, і 4 стикається з 7. (4, 1) з (7,) дає (4, 7), хоч жодна форма на неї не схожа — розтягуються обидва масиви одразу. А (3, 4) з (1, 7) падає одразу на останній осі: 4 проти 7.

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

>>> середнє = продажі.mean(axis=1) # середнє кожного магазину >>> середнє.shape (4,) >>> продажі - середнє ValueError: operands could not be broadcast together with shapes (4,7) (4,)

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

відхилення = продажі - середнє.reshape(4, 1) # перебудувати форму відхилення = продажі - продажі.mean(axis=1, keepdims=True) # попросити відразу стовпець

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

>>> відхилення[2].round(1) # Кампус: наскільки кожен день кращий за свій тиждень array([ 44.3, 64.3, 59.3, 74.3, 24.3, -125.7, -140.7])

08 / Осіaxis=0 і axis=1

Масив уміє рахувати підсумки: sum, mean, min, max, std, argmax. Без аргументів кожна з них складає весь масив в одне число:

>>> продажі.sum() np.int64(4240) >>> продажі.max() np.int64(260)

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

Схема 4 · Одна таблиця, два різні підсумки

Стрілки показують, уздовж чого рухається додавання.

Найкраща підказка — дивитись на форму, а не на слова. (4, 7) при axis=0 втрачає перше число й стає (7,); при axis=1 втрачає друге й стає (4,). Тому «сума по магазинах» — це axis=1, хоч інтуїція часто підказує нуль: магазини — це вісь 0, але ми йдемо вздовж днів, щоб отримати число на кожен магазин.
>>> продажі.sum(axis=0) # склали магазини, лишились дні array([590, 615, 620, 655, 700, 555, 505]) >>> продажі.sum(axis=1) # склали дні, лишились магазини array([1205, 1015, 1160, 860])

З цими двома рядками журнал одразу починає говорити. Пʼятниця — найкращий день мережі (700 чашок), неділя — найгірший (505). Найбільше продає Кампус? Ні: Центр, 1205 проти 1160, — і це попри те, що в суботу з неділею Кампус майже стоїть.

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

>>> продажі.max(axis=1) # найкращий день кожного магазину array([240, 160, 240, 260]) >>> продажі.argmax(axis=1) # НОМЕР цього дня array([4, 4, 3, 6]) >>> [дні[номер] for номер in продажі.argmax(axis=1)] ['пт', 'пт', 'чт', 'нд']
Для профі: чому вісь «зникає». Агрегація по осі k завжди перетворює форму (d₀, …, dk, …, dn) на ту саму форму без dk. Це правило працює однаково для будь-якої кількості вимірів: для картинки форми (висота, ширина, 3) запис картинка.mean(axis=2) усереднює три канали кольору й дає чорно-білу (висота, ширина). А keepdims=True з розділу 07 — це прохання лишити на місці зниклої осі одиницю, щоб результат далі транслювався на вихідний масив.

09 / МаскиБулеві маски

Порівняння теж векторизоване, і в цьому вся суть. Питання, задане масиву, повертається масивом відповідей:

>>> тиждень = продажі[0] # Центр >>> тиждень > 150 array([ True, True, True, True, True, False, False])

Такий масив з dtype('bool') називають маскою. Сам по собі він не дуже цікавий — цікаво те, що маскою можна індексувати. У квадратних дужках вона означає «залиш тільки ті елементи, навпроти яких True»:

Схема 5 · Що відбувається всередині тиждень[тиждень > 150]

Три кроки, які зазвичай пишуть одним рядком.

Другий рядок — теж масив, і з ним можна працювати як із числами. У Python True дорівнює одиниці, тому .sum() над маскою рахує, скільки днів пройшли поріг, а .mean()яка частка днів його пройшла. Це найкоротший спосіб порахувати відсоток чого завгодно.
>>> тиждень[тиждень > 150] array([180, 195, 175, 205, 240]) >>> (тиждень > 150).sum() # скільки таких днів np.int64(5) >>> (тиждень > 150).mean().round(3) # яка їх частка np.float64(0.714)

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

>>> продажі[продажі > 230] array([240, 240, 240, 260]) >>> (продажі > 200).sum(axis=1) # скільки вдалих днів у кожного магазину array([2, 0, 4, 2])

Умови поєднуються — але не тими словами, до яких ти звик з теми 11. Замість and, or, not тут &, |, ~, і кожну умову обовʼязково беруть у дужки:

>>> тиждень[(тиждень > 100) & (тиждень < 200)] array([180, 195, 175, 120]) # 205 і 240 завеликі, 90 замале >>> тиждень[(тиждень > 100) and (тиждень < 200)] ValueError: The truth value of an array with more than one element is ambiguous

Помилка показова. Оператор and хоче звести обидві частини до одного True або False — а масив із семи відповідей одним логічним значенням стати не може. Оператор & натомість працює поелементно й дає таку саму маску з семи значень. Дужки обовʼязкові, бо & звʼязує сильніше за > і без них вираз розпадеться не там.

І остання дрібниця, яка знадобиться постійно: np.where(умова, якщо_так, якщо_ні) не викидає елементи, а замінює їх, зберігаючи форму:

>>> np.where(тиждень > 150, тиждень, 0) array([180, 195, 175, 205, 240, 0, 0])

10 / ГлибшеДля тих, кому мало

Чому dtype — це рішення, а не деталь. Мільйон чисел коштує рівно itemsize байтів за штуку, і різниця величезна: int8 — 0.95 МБ, int32 — 3.81 МБ, int64 — 7.63 МБ. Для таблиці продажів, де числа не перевищують кількох сотень, int16 вистачило б із запасом і забрав би вчетверо менше памʼяті. Але тісний тип мовчки переповнюється — і це не виняткова ситуація, а звичайна арифметика по колу:

>>> малі = np.array([100, 50, 25], dtype=np.int8) # діапазон -128…127 >>> малі * 3 array([ 44, -106, 75], dtype=int8)

Сто помножити на три дало 44. Жодного винятку, жодного повідомлення — просто нове число. Практичне правило: тип звужують усвідомлено й лише тоді, коли памʼять справді тисне, і завжди із запасом на проміжні обчислення.

Точність — це той самий компроміс, тільки для дробових. У темі 04 ми зʼясували, чому 0.1 + 0.2 != 0.3: дробові зберігаються скінченною кількістю бітів. У float64 на мантису відведено 52 біти, у float32 — лише 23, і похибка кожної окремої операції зростає приблизно в 109 разів. На довгих сумах вона накопичується:

>>> float(np.arange(1_000_000, dtype=np.float32).sum()) 499999506432.0 # має бути 499 999 500 000 — розбіжність 6432 >>> float(np.arange(1_000_000, dtype=np.float64).sum()) 499999500000.0 # точно

float32 удвічі економніший і на графічних процесорах помітно швидший — саме тому в машинному навчанні ваги моделей майже завжди зберігають у ньому. Але для підсумків, різниць близьких чисел і будь-яких грошей потрібен float64.

Чому цикл по масиву повільніший за цикл по списку. Це виглядає парадоксом: масив швидший, а цикл по ньому — ні. Заміри на мільйоні чисел, та сама дія × 1.2:

включення по списку : 55 мс включення по масиву : 119 мс ← удвічі повільніше масив * 1.2 : 1.1 мс

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

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

>>> з_дірками = np.array([120.0, np.nan, 98.0, 140.0]) >>> з_дірками.mean() np.float64(nan) # одна дірка зіпсувала весь підсумок >>> np.nanmean(з_дірками) np.float64(119.33333333333333) # версія, яка дірки пропускає

Пари nansum, nanmean, nanmax існують саме для цього. Дві деталі, які колись зекономлять годину: np.nan не дорівнює сам собі (шукати дірки порівнянням марно — для цього є np.isnan), і в цілочисельному масиві його зберегти неможливо, тож масив із дірками завжди дробовий.

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

Уся тема виростає з одного рішення: зберігати самі числа підряд замість посилань на розкидані обʼєкти. Далі — самі наслідки.

І тепер помітна дірка. Ми весь час памʼятали окремо, що рядок 2 — це Кампус, а стовпець 5 — субота, і переводили номери в назви руками. Масив про підписи не знає нічого — його стихія однорідні числа. Але реальні дані однорідними не бувають: у тій самій таблиці стоять і назва міста, і дата, і виторг. Тема 34 додає над масивом саме те, чого йому бракує, — імена рядків і стовпців та різні типи в різних колонках. Усе, що ти щойно вивчив, там працює далі: pandas усередині — це ті самі масиви NumPy.

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

Далі в темі

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