Практика · NumPy: масиви¶

Супровід до лекції lecture.html · тест: quiz.html · домашнє: homework.md

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

Що зробимо:

  1. зберемо масив і роздивимось його паспорт: shape, ndim, size, dtype, nbytes
  2. порівняємо памʼять масиву з памʼяттю списку тих самих чисел
  3. створимо масиви без списків: zeros, arange, linspace, default_rng
  4. візьмемо двовимірні зрізи — рядок, стовпець, прямокутник
  5. спіймаємо пастку вигляду: доведемо assert-ом, що запис у зріз змінив оригінал
  6. заміряємо цикл проти векторизації власним timeit
  7. збудуємо таблицю відхилень транслюванням і звіримо її з порахованою вручну
  8. пройдемось по осях і по булевих масках

Мережею не користуємось, усі випадкові числа — з np.random.default_rng(42).

1 · Готуємо інструмент¶

Перевіримо версії — числа в замірах нижче залежать від них, і твої можуть відрізнятись.

In [1]:
import sys
import timeit
import tracemalloc

import numpy as np

print("Python:", sys.version.split()[0])
print("NumPy: ", np.__version__)
Python: 3.12.2
NumPy:  2.5.1

2 · Журнал продажів¶

Той самий масив, що в лекції. Рядок — магазин, стовпець — день тижня. Списки магазини й дні тримаємо окремо: масив про підписи не знає нічого (саме цю дірку залатає наступна тема про pandas).

In [2]:
магазини = ["Центр", "Вокзал", "Кампус", "Парк"]
дні = ["пн", "вт", "ср", "чт", "пт", "сб", "нд"]

продажі = 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],   # Парк   — живе саме на вихідних
])

print(продажі)
[[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]]

Паспорт масиву¶

Чотири головні числа плюс два про памʼять. Зверни увагу: nbytes — це рівно size × itemsize, тобто самі дані без службового заголовка.

In [3]:
print("shape   :", продажі.shape, "— рядків × стовпців")
print("ndim    :", продажі.ndim, "— вимірів")
print("size    :", продажі.size, "— елементів усього")
print("dtype   :", продажі.dtype, "— тип КОЖНОГО елемента")
print("itemsize:", продажі.itemsize, "Б — на один елемент")
print("nbytes  :", продажі.nbytes, "Б — на весь масив")

# перевіряємо тотожність, про яку йшлося в лекції
assert продажі.nbytes == продажі.size * продажі.itemsize, "nbytes рахується інакше?"
assert продажі.size == продажі.shape[0] * продажі.shape[1], "size — це добуток shape"
print("✅ nbytes = size × itemsize, size = добуток shape")
shape   : (4, 7) — рядків × стовпців
ndim    : 2 — вимірів
size    : 28 — елементів усього
dtype   : int64 — тип КОЖНОГО елемента
itemsize: 8 Б — на один елемент
nbytes  : 224 Б — на весь масив
✅ nbytes = size × itemsize, size = добуток shape

3 · Скільки насправді коштує список¶

Порівняємо памʼять чесно — не sys.getsizeof (він бачить лише масив посилань), а tracemalloc, який рахує ще й мільйон обʼєктів-чисел за цими посиланнями.

In [4]:
СКІЛЬКИ = 1_000_000

tracemalloc.start()
числа_списком = [номер for номер in range(СКІЛЬКИ)]
_, пік_списку = tracemalloc.get_traced_memory()
tracemalloc.stop()
del числа_списком          # звільняємо памʼять перед другим заміром

tracemalloc.start()
числа_масивом = np.arange(СКІЛЬКИ)
_, пік_масиву = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f"список  : {пік_списку:>10,} Б  =  {пік_списку/2**20:6.2f} МБ")
print(f"масив   : {пік_масиву:>10,} Б  =  {пік_масиву/2**20:6.2f} МБ")
print(f"різниця : у {пік_списку/пік_масиву:.1f} раза")
print()
print("nbytes масиву:", числа_масивом.nbytes, "Б — рівно 8 байтів на число, без залишку")
список  : 40,460,584 Б  =   38.59 МБ
масив   :  8,019,326 Б  =    7.65 МБ
різниця : у 5.0 раза

nbytes масиву: 8000000 Б — рівно 8 байтів на число, без залишку

4 · Шість способів створити масив¶

Головне тут: усе, крім np.array, будує масив без проміжного списку — одразу потрібного розміру.

In [5]:
print("zeros(5)          :", np.zeros(5))
print("ones((2,3), int64):", np.ones((2, 3), dtype=np.int64).tolist())
print("full((2,2), 7)    :", np.full((2, 2), 7).tolist())
print("arange(0, 10, 2)  :", np.arange(0, 10, 2), "— крок 2, права межа НЕ входить")
print("linspace(0, 1, 5) :", np.linspace(0, 1, 5), "— 5 точок, права межа входить")

# arange із дробовим кроком — джерело сюрпризів через похибку з теми 04
дробовий = np.arange(1, 1.3, 0.1)
print()
print("arange(1, 1.3, 0.1) дав", len(дробовий), "числа замість трьох:", дробовий)
print("останнє з них =", repr(дробовий[-1]), "← права межа, якої тут бути не мало")
print("linspace(1, 1.2, 3) натомість дає рівно", len(np.linspace(1, 1.2, 3)), "точки:", np.linspace(1, 1.2, 3))
zeros(5)          : [0. 0. 0. 0. 0.]
ones((2,3), int64): [[1, 1, 1], [1, 1, 1]]
full((2,2), 7)    : [[7, 7], [7, 7]]
arange(0, 10, 2)  : [0 2 4 6 8] — крок 2, права межа НЕ входить
linspace(0, 1, 5) : [0.   0.25 0.5  0.75 1.  ] — 5 точок, права межа входить

arange(1, 1.3, 0.1) дав 4 числа замість трьох: [1.  1.1 1.2 1.3]
останнє з них = np.float64(1.3000000000000003) ← права межа, якої тут бути не мало
linspace(1, 1.2, 3) натомість дає рівно 3 точки: [1.  1.1 1.2]

Випадкові числа з фіксованим зерном¶

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

In [6]:
генератор = np.random.default_rng(42)
вибірка = генератор.integers(0, 100, size=5)
print("перший генератор :", вибірка)

# новий генератор із тим самим зерном дає ту саму послідовність
повтор = np.random.default_rng(42).integers(0, 100, size=5)
print("другий генератор :", повтор)

assert np.array_equal(вибірка, повтор), "однакове зерно має давати однакові числа!"
print("✅ зерно 42 відтворюється")
перший генератор : [ 8 77 65 43 43]
другий генератор : [ 8 77 65 43 43]
✅ зерно 42 відтворюється

5 · Індекс і зріз у двох вимірах¶

Кома розділяє виміри: до неї — рядки, після — стовпці. Двокрапка сама по собі означає «увесь цей вимір».

In [7]:
print("продажі[2, 5]  =", продажі[2, 5], "— Кампус у суботу")
print("продажі[0]     =", продажі[0], "— увесь тиждень Центру")
print("продажі[:, 5]  =", продажі[:, 5], "— субота в усіх магазинах")
print()
print("продажі[1:3, 4:7] — Вокзал і Кампус за пт-нд:")
print(продажі[1:3, 4:7])
print()
print("продажі[:, -2:] — усі магазини, тільки вихідні:")
print(продажі[:, -2:])
продажі[2, 5]  = 40 — Кампус у суботу
продажі[0]     = [180 195 175 205 240 120  90] — увесь тиждень Центру
продажі[:, 5]  = [120 155  40 240] — субота в усіх магазинах

продажі[1:3, 4:7] — Вокзал і Кампус за пт-нд:
[[160 155 130]
 [190  40  25]]

продажі[:, -2:] — усі магазини, тільки вихідні:
[[120  90]
 [155 130]
 [ 40  25]
 [240 260]]

Перевіримо себе¶

Форма зрізу має відповідати тому, що ми просили. Це найшвидший спосіб зловити помилку в індексах: не дивитись на числа, а глянути на shape.

In [8]:
assert продажі[2, 5] == 40, "комірка [2,5] — це Кампус у суботу"
assert продажі[0].shape == (7,), "рядок — одновимірний масив із семи чисел"
assert продажі[:, 5].shape == (4,), "стовпець — одновимірний масив із чотирьох чисел"
assert продажі[1:3, 4:7].shape == (2, 3), "прямокутник 2 рядки × 3 стовпці"
print("✅ усі форми зрізів такі, як очікувалось")
✅ усі форми зрізів такі, як очікувалось

6 · Пастка вигляду¶

Найважливіша частина теми. У темі 06 зріз списку був копією — оригінал у безпеці. У NumPy усе навпаки, і зараз ми це доведемо, а не просто прочитаємо.

Спершу подивимось, як поводиться список.

In [9]:
тиждень_списком = [180, 195, 175, 205, 240, 120, 90]
частина_списку = тиждень_списком[1:4]      # зріз списку — НОВА структура
частина_списку[0] = 0

print("список після запису в зріз:", тиждень_списком)
assert тиждень_списком[1] == 195, "зріз списку не мав зачепити оригінал"
print("✅ оригінал-список цілий — саме так нас навчила тема 06")
список після запису в зріз: [180, 195, 175, 205, 240, 120, 90]
✅ оригінал-список цілий — саме так нас навчила тема 06

А тепер той самий код над масивом. Зверни увагу: рядки коду буквально ті самі, змінився лише тип контейнера.

In [10]:
тиждень = np.array([180, 195, 175, 205, 240, 120, 90])
частина = тиждень[1:4]                     # зріз масиву — ВИГЛЯД на ту саму памʼять
частина[0] = 0

print("масив після запису в зріз:", тиждень)

# головний assert теми: оригінал ЗМІНИВСЯ, і це не помилка, а нормальна робота NumPy
assert тиждень[1] == 0, "запис у зріз мав змінити оригінал — зріз масиву це вигляд!"
assert np.shares_memory(тиждень, частина), "вигляд і оригінал ділять один блок памʼяті"
print("✅ оригінал змінився — пастку спіймано")
масив після запису в зріз: [180   0 175 205 240 120  90]
✅ оригінал змінився — пастку спіймано

Ліки: .copy()¶

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

In [11]:
тиждень_2 = np.array([180, 195, 175, 205, 240, 120, 90])
копія = тиждень_2[1:4].copy()              # просимо копію явно
копія[0] = 0

print("масив після запису в КОПІЮ зрізу:", тиждень_2)

assert тиждень_2[1] == 195, ".copy() мав розірвати звʼязок з оригіналом"
assert not np.shares_memory(тиждень_2, копія), "копія не ділить памʼять з оригіналом"
print("✅ з .copy() оригінал цілий")
масив після запису в КОПІЮ зрізу: [180 195 175 205 240 120  90]
✅ з .copy() оригінал цілий

Як перевірити, що перед тобою¶

Атрибут base у вигляду вказує на масив-власника даних, а в самостійного дорівнює None. Але надійніше питати прямо — np.shares_memory.

І окремо запамʼятай: індексація списком номерів або маскою повертає копію, бо потрібні елементи лежать не з рівним кроком.

In [12]:
основа = np.arange(10)

print("зріз          base is основа :", основа[2:6].base is основа)
print("зріз .copy()  base is None   :", основа[2:6].copy().base is None)
print("список номерів ділить памʼять:", np.shares_memory(основа, основа[[1, 3, 5]]))
print("маска ділить памʼять         :", np.shares_memory(основа, основа[основа > 5]))

assert np.shares_memory(основа, основа[2:6]), "звичайний зріз — вигляд"
assert not np.shares_memory(основа, основа[[1, 3, 5]]), "індексація списком — копія"
print("✅ зріз — вигляд, «розумна» індексація — копія")
зріз          base is основа : True
зріз .copy()  base is None   : True
список номерів ділить памʼять: False
маска ділить памʼять         : False
✅ зріз — вигляд, «розумна» індексація — копія

7 · Векторизація: заміряємо самі¶

Одна й та сама дія — помножити кожне число на 1.2 — трьома способами. Числа будуть свої, бо залежать від машини; важлива пропорція.

In [13]:
ДОВЖИНА = 200_000
ціни_списком = [float(номер) for номер in range(ДОВЖИНА)]
ціни_масивом = np.arange(ДОВЖИНА, dtype=np.float64)


def через_цикл():
    "Класичний for з накопиченням у список — так пишуть без NumPy."
    результат = []
    for ціна in ціни_списком:
        результат.append(ціна * 1.2)
    return результат


def через_включення():
    "Спискове включення з теми 13 — коротше, але цикл усередині той самий."
    return [ціна * 1.2 for ціна in ціни_списком]


def через_масив():
    "Векторна дія: жодного циклу в Python, увесь обхід усередині NumPy."
    return ціни_масивом * 1.2


# найкращий із семи прогонів — щоб випадкове гальмо системи не зіпсувало картину
час_циклу = min(timeit.repeat(через_цикл, number=1, repeat=7))
час_включення = min(timeit.repeat(через_включення, number=1, repeat=7))
час_масиву = min(timeit.repeat(через_масив, number=1, repeat=7))

print(f"for + append : {час_циклу*1000:9.3f} мс")
print(f"включення    : {час_включення*1000:9.3f} мс")
print(f"масив * 1.2  : {час_масиву*1000:9.3f} мс")
print()
print(f"масив швидший за включення у {час_включення/час_масиву:.0f} разів")
for + append :     9.496 мс
включення    :     8.121 мс
масив * 1.2  :     0.085 мс

масив швидший за включення у 95 разів

Обовʼязкова перевірка: результат той самий¶

Швидкість нічого не варта, якщо відповідь інша. Порівняємо поелементно.

In [14]:
assert np.allclose(через_включення(), через_масив()), "векторна дія дала інші числа!"
print("✅ включення і векторна дія дають однаковий результат")
✅ включення і векторна дія дають однаковий результат

Чому цикл по масиву ще повільніший¶

Це виглядає парадоксом, але має просте пояснення: у масиві немає готових обʼєктів, і Python мусить створювати новий numpy.float64 на кожен елемент.

In [15]:
час_циклу_по_масиву = min(timeit.repeat(lambda: [ціна * 1.2 for ціна in ціни_масивом],
                                        number=1, repeat=5))

print(f"включення по списку : {час_включення*1000:8.2f} мс")
print(f"включення по масиву : {час_циклу_по_масиву*1000:8.2f} мс")
print(f"векторна дія        : {час_масиву*1000:8.2f} мс")
print()
print(f"цикл по масиву у {час_циклу_по_масиву/час_включення:.1f} раза повільніший за цикл по списку")
включення по списку :     8.12 мс
включення по масиву :    22.34 мс
векторна дія        :     0.09 мс

цикл по масиву у 2.8 раза повільніший за цикл по списку

8 · Транслювання¶

Спершу проста дія: сім коефіцієнтів знижки на всі чотири магазини одразу. Форми (4, 7) і (7,) поєднуються, бо остання вісь у них однакова.

In [16]:
знижка = np.array([1.0, 1.0, 1.0, 1.0, 0.9, 0.8, 0.8])   # пт -10 %, вихідні -20 %
виторг = продажі * знижка

print("форма продажів:", продажі.shape, " форма знижки:", знижка.shape)
print("форма результату:", виторг.shape)
print()
print("Центр після знижки:", виторг[0])
форма продажів: (4, 7)  форма знижки: (7,)
форма результату: (4, 7)

Центр після знижки: [180. 195. 175. 205. 216.  96.  72.]

Коли транслювання падає — і як це полагодити¶

Хочемо для кожного дня побачити відхилення від середнього по цьому магазину. Перша спроба падає, бо (4,) перетворюється на (1, 4) і четвірка стикається з сімкою.

In [17]:
середнє_магазину = продажі.mean(axis=1)
print("середнє кожного магазину:", середнє_магазину.round(2))
print("його форма:", середнє_магазину.shape)

try:
    продажі - середнє_магазину
except ValueError as помилка:
    print()
    print("ValueError:", помилка)
середнє кожного магазину: [172.14 145.   165.71 122.86]
його форма: (4,)

ValueError: operands could not be broadcast together with shapes (4,7) (4,) 

Ліки — попросити агрегацію не викидати вісь, а лишити її довжиною 1. Тоді форма буде (4, 1) — стовпець, і транслювання спрацює саме собою.

In [18]:
середнє_стовпцем = продажі.mean(axis=1, keepdims=True)
print("з keepdims=True форма:", середнє_стовпцем.shape)

відхилення = продажі - середнє_стовпцем
print()
print("наскільки кожен день кращий за середній тиждень свого магазину:")
for номер, назва in enumerate(магазини):
    print(f"  {назва:<7}", відхилення[номер].round(1))
з keepdims=True форма: (4, 1)

наскільки кожен день кращий за середній тиждень свого магазину:
  Центр   [  7.9  22.9   2.9  32.9  67.9 -52.1 -82.1]
  Вокзал  [ -5. -10.   5.   0.  15.  10. -15.]
  Кампус  [  44.3   64.3   59.3   74.3   24.3 -125.7 -140.7]
  Парк    [-62.9 -67.9 -52.9 -57.9 -12.9 117.1 137.1]

Перевірка «наша реалізація = бібліотечна»¶

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

In [19]:
відхилення_вручну = []
for рядок in продажі:
    середнє_рядка = sum(рядок) / len(рядок)          # чистий Python, без NumPy
    відхилення_вручну.append([число - середнє_рядка for число in рядок])

assert np.allclose(відхилення, відхилення_вручну), "транслювання розійшлося з ручним рахунком!"
print("✅ транслювання = ручні цикли, до останнього знака")
✅ транслювання = ручні цикли, до останнього знака

9 · Осі: axis=0 проти axis=1¶

axis=k — це вісь, уздовж якої йде підсумовування, і саме вона зникає з форми. (4, 7) при axis=0 стає (7,), при axis=1 — (4,).

In [20]:
по_днях = продажі.sum(axis=0)        # склали магазини, лишились дні
по_магазинах = продажі.sum(axis=1)   # склали дні, лишились магазини

print("сума по днях     ", по_днях, " форма", по_днях.shape)
print("сума по магазинах", по_магазинах, " форма", по_магазинах.shape)
print()
for назва, чашки in zip(дні, по_днях):
    print(f"  {назва}: {чашки}")
сума по днях      [590 615 620 655 700 555 505]  форма (7,)
сума по магазинах [1205 1015 1160  860]  форма (4,)

  пн: 590
  вт: 615
  ср: 620
  чт: 655
  пт: 700
  сб: 555
  нд: 505

Обидва підсумки складаються в те саме загальне число — інакше десь помилка з осями.

In [21]:
усього = продажі.sum()
print("усього чашок за тиждень:", усього)

assert по_днях.sum() == усього, "сума по днях має дати те саме загальне число"
assert по_магазинах.sum() == усього, "сума по магазинах — теж"
assert по_днях.shape == (7,) and по_магазинах.shape == (4,), "вісь мала зникнути з форми"
print("✅ обидва розрізи узгоджені й форми правильні")
усього чашок за тиждень: 4240
✅ обидва розрізи узгоджені й форми правильні

argmax повертає номер, а не значення¶

У парі з осями це найзручніший спосіб відповісти на питання «а коли саме».

In [22]:
найкращий_день = продажі.argmax(axis=1)     # номер стовпця для кожного рядка
найкращі_чашки = продажі.max(axis=1)

for назва, номер, чашки in zip(магазини, найкращий_день, найкращі_чашки):
    print(f"{назва:<7} найкращий день — {дні[номер]}, {чашки} чашок")

# argmax і max мають вказувати на одне й те саме
for рядок, номер, чашки in zip(продажі, найкращий_день, найкращі_чашки):
    assert рядок[номер] == чашки, "argmax вказав не на максимум!"
print()
print("✅ argmax і max узгоджені")
Центр   найкращий день — пт, 240 чашок
Вокзал  найкращий день — пт, 160 чашок
Кампус  найкращий день — чт, 240 чашок
Парк    найкращий день — нд, 260 чашок

✅ argmax і max узгоджені

10 · Булеві маски¶

Умова над масивом повертає масив відповідей. Ним можна індексувати, його можна підсумовувати, і .mean() над ним дає частку.

In [23]:
тиждень_центру = продажі[0]
маска = тиждень_центру > 150

print("тиждень Центру:", тиждень_центру)
print("маска > 150   :", маска, "  dtype:", маска.dtype)
print("відібрані     :", тиждень_центру[маска])
print()
print("скільки таких днів:", маска.sum())
print("яка їх частка    :", маска.mean().round(3))
print("np.where          :", np.where(маска, тиждень_центру, 0))
тиждень Центру: [180 195 175 205 240 120  90]
маска > 150   : [ True  True  True  True  True False False]   dtype: bool
відібрані     : [180 195 175 205 240]

скільки таких днів: 5
яка їх частка    : 0.714
np.where          : [180 195 175 205 240   0   0]

Умови поєднуються через &, |, ~ — і кожну обовʼязково беруть у дужки. Слово and тут не працює й падає з промовистою помилкою.

In [24]:
середні_дні = тиждень_центру[(тиждень_центру > 100) & (тиждень_центру < 200)]
print("дні між 100 і 200:", середні_дні)

try:
    тиждень_центру[(тиждень_центру > 100) and (тиждень_центру < 200)]
except ValueError as помилка:
    print()
    print("а зі словом and:", помилка)
дні між 100 і 200: [180 195 175 120]

а зі словом and: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Маска над двовимірним масивом¶

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

In [25]:
вдалі_дні = продажі > 200
print("усі значення понад 200:", продажі[вдалі_дні], " форма:", продажі[вдалі_дні].shape)
print()
print("скільки вдалих днів у кожного магазину:")
for назва, скільки in zip(магазини, вдалі_дні.sum(axis=1)):
    print(f"  {назва:<7} {скільки}")

# маску можна перевірити прямим підрахунком
кількість_вручну = sum(1 for рядок in продажі for число in рядок if число > 200)
assert вдалі_дні.sum() == кількість_вручну, "маска порахувала не те, що ручний цикл"
print()
print("✅ маска = ручний підрахунок")
усі значення понад 200: [205 240 210 230 225 240 240 260]  форма: (8,)

скільки вдалих днів у кожного магазину:
  Центр   2
  Вокзал  0
  Кампус  4
  Парк    2

✅ маска = ручний підрахунок

11 · Для тих, кому мало: dtype і nan¶

Тісний тип економить памʼять, але мовчки переповнюється. Жодного винятку не буде — просто інше число.

In [26]:
малі = np.array([100, 50, 25], dtype=np.int8)     # діапазон -128…127
print("int8 [100, 50, 25] * 3 =", малі * 3, "← сто помножити на три дало 44")
print()

for тип in (np.int8, np.int16, np.int32, np.int64, np.float32, np.float64):
    заготовка = np.zeros(1_000_000, dtype=тип)
    print(f"  {np.dtype(тип).name:>8}: {заготовка.itemsize} Б на елемент, "
          f"мільйон = {заготовка.nbytes/2**20:5.2f} МБ")
int8 [100, 50, 25] * 3 = [  44 -106   75] ← сто помножити на три дало 44

      int8: 1 Б на елемент, мільйон =  0.95 МБ
     int16: 2 Б на елемент, мільйон =  1.91 МБ
     int32: 4 Б на елемент, мільйон =  3.81 МБ
     int64: 8 Б на елемент, мільйон =  7.63 МБ
   float32: 4 Б на елемент, мільйон =  3.81 МБ
   float64: 8 Б на елемент, мільйон =  7.63 МБ

Точність — той самий компроміс, але для дробових. float32 удвічі економніший, проте на довгих сумах помилка накопичується.

In [27]:
точна_сума = 999_999 * 1_000_000 // 2      # формула суми арифметичної прогресії

сума_32 = float(np.arange(1_000_000, dtype=np.float32).sum())
сума_64 = float(np.arange(1_000_000, dtype=np.float64).sum())

print("точне значення :", точна_сума)
print("float32        :", сума_32, " похибка:", точна_сума - сума_32)
print("float64        :", сума_64, " похибка:", точна_сума - сума_64)

assert сума_64 == точна_сума, "float64 має впоратись точно"
assert сума_32 != точна_сума, "а float32 — ні, і це очікувано"
print()
print("✅ float64 порахував точно, float32 промахнувся на", точна_сума - сума_32)
точне значення : 499999500000
float32        : 499999506432.0  похибка: -6432.0
float64        : 499999500000.0  похибка: 0.0

✅ float64 порахував точно, float32 промахнувся на -6432.0

І наостанок — дірки в даних. Уявімо, що в суботу Кампус не працював і числа немає. np.nan заражає будь-який підсумок, тому для таких масивів є окрема родина функцій.

In [28]:
кампус = продажі[2].astype(float)   # nan живе лише у світі float, тому переводимо тип
кампус[5] = np.nan                  # у суботу даних немає

print("Кампус із діркою:", кампус)
print("mean()      :", кампус.mean(), "← одна дірка зіпсувала весь підсумок")
print("np.nanmean():", round(float(np.nanmean(кампус)), 2), "← версія, яка дірки пропускає")
print()
print("np.nan == np.nan :", np.nan == np.nan, "← шукати дірки порівнянням марно")
print("np.isnan(кампус) :", np.isnan(кампус))

assert np.isnan(кампус.mean()), "звичайне середнє мало стати nan"
assert not np.isnan(np.nanmean(кампус)), "nanmean мав дірку пропустити"
print()
print("✅ nan поводиться так, як обіцяла лекція")
Кампус із діркою: [210. 230. 225. 240. 190.  nan  25.]
mean()      : nan ← одна дірка зіпсувала весь підсумок
np.nanmean(): 186.67 ← версія, яка дірки пропускає

np.nan == np.nan : False ← шукати дірки порівнянням марно
np.isnan(кампус) : [False False False False False  True False]

✅ nan поводиться так, як обіцяла лекція

12 · Завдання¶

Роби просто в цьому зошиті — додай клітинки нижче.

🟢 Рівень 1 — База¶

Побудуй масив витрати тієї самої форми (4, 7): скільки грошей кожен магазин витратив за кожен день (візьми числа з голови або з default_rng(42)). Порахуй витрати.sum(axis=0) і витрати.sum(axis=1) і поясни словами в markdown-клітинці, що означає кожен із двох масивів.

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

🟡 Рівень 2 — Плюс¶

Порахуй частку кожного магазину в продажах кожного дня: скільки відсотків денного підсумку припадає на цей магазин. Знадобиться sum(axis=0, keepdims=True) і транслювання.

Зроблено, якщо: сума кожного стовпця результату дорівнює 1.0 — перевір це assert np.allclose(частки.sum(axis=0), 1.0).

🔴 Рівень 3 — Виклик¶

Напиши функцію нормалізувати(масив), яка віднімає від кожного рядка його середнє і ділить на його стандартне відхилення (std). Зроби так, щоб вона не псувала переданий масив, і доведи це assert-ом. Потім спеціально зламай її — прибери захист — і покажи, що оригінал зіпсувався.

Зроблено, якщо: після виклику правильної версії assert np.array_equal(продажі, копія_до_виклику) проходить, а після виклику зламаної — падає (спіймай це через try/except AssertionError).