Pytania rekrutacyjne NumPy
32 pytań — 20 odpowiedzi dostępnych od razu
Podstawy NumPy
Czym jest NumPy i jakie problemy rozwiązuje w ekosystemie Pythona?
Odpowiedź w 30 sekund:
NumPy (ang. Numerical Python) to fundamentalna biblioteka do obliczeń numerycznych w Pythonie, dostarczająca wydajną, n-wymiarową tablicę ndarray oraz zestaw operacji wektorowych na niej. Rozwiązuje problem wolnych pętli na listach Pythona i braku jednorodnej, ciągłej w pamięci struktury danych do liczb. Jest podstawą całego ekosystemu naukowego: pandas, SciPy, scikit-learn, TensorFlow i PyTorch budują na NumPy.
Odpowiedź w 2 minuty:
Czysty Python świetnie nadaje się do skryptowania i logiki aplikacji, ale do obliczeń numerycznych ma dwie wady: lista Pythona to tablica wskaźników do rozproszonych w pamięci obiektów (każda liczba to pełnoprawny PyObject z narzutem), a operacje na elementach wymagają pętli interpretowanych instrukcja po instrukcji. Dla milionów liczb i operacji macierzowych jest to zarówno wolne, jak i pamięciożerne.
NumPy rozwiązuje to, wprowadzając typ ndarray — jednorodną, ciągłą w pamięci tablicę o ustalonym typie elementów (dtype). Dzięki temu dane mają zwartą reprezentację (jak tablica w C/Fortranie), a operacje są zwektoryzowane: zamiast pisać pętlę po elementach, wykonujemy a + b czy np.dot(A, B), a samo liczenie odbywa się w skompilowanym kodzie C/Fortran, bez narzutu interpretera Pythona.
Poza samym kontenerem danych NumPy dostarcza bogaty zestaw narzędzi: broadcasting (operacje na tablicach o różnych kształtach), funkcje matematyczne i statystyczne (mean, std, sum), algebrę liniową (numpy.linalg), transformaty Fouriera, generatory liczb losowych oraz mechanizmy indeksowania i krojenia (ang. slicing). NumPy definiuje też wspólny protokół tablicowy, dzięki któremu inne biblioteki mogą wymieniać dane bez kopiowania.
W praktyce NumPy jest „wspólnym mianownikiem" naukowego Pythona — to standard, w którym przechowuje się i przekazuje dane numeryczne między bibliotekami. Bez niego pandas, scikit-learn czy biblioteki uczenia głębokiego nie miałyby spójnej, wydajnej podstawy.
Przykład kodu:
import numpy as np
# Tworzymy tablicę z listy Pythona
dane = np.array([1.0, 2.0, 3.0, 4.0])
# Operacja zwektoryzowana — bez pętli, wykonana w C
podwojone = dane * 2 # array([2., 4., 6., 8.])
suma = dane.sum() # 10.0
srednia = dane.mean() # 2.5
# Operacje na całych tablicach naraz
a = np.array([1, 2, 3])
b = np.array([10, 20, 30])
wynik = a + b # array([11, 22, 33])
print(podwojone, suma, srednia, wynik)
Materiały
Czym jest ndarray i czym różni się od wbudowanej listy Pythona?
Odpowiedź w 30 sekund:
ndarray (ang. N-dimensional array) to podstawowy typ NumPy: jednorodna, n-wymiarowa tablica elementów tego samego typu, przechowywana w ciągłym bloku pamięci. W odróżnieniu od listy Pythona, która jest tablicą wskaźników do rozproszonych obiektów dowolnego typu, ndarray ma stały rozmiar, jeden dtype i wspiera szybkie operacje zwektoryzowane.
Odpowiedź w 2 minuty:
Lista Pythona to elastyczny, dynamiczny kontener: może przechowywać elementy różnych typów ([1, "tekst", 3.5, None]), rośnie i kurczy się, a pod spodem jest tablicą wskaźników do obiektów PyObject rozsianych po stercie. Ta elastyczność kosztuje: każdy element to osobny obiekt z nagłówkiem (typ, licznik referencji), a brak ciągłości pamięci szkodzi wydajności i utrudnia optymalizacje sprzętowe.
ndarray projektowany jest pod liczby. Przechowuje elementy jednego typu (dtype) w jednym ciągłym buforze pamięci, podobnie jak tablica w C. Tablica zna swój kształt (shape), liczbę wymiarów (ndim), typ (dtype) oraz tzw. strides — informacje, o ile bajtów przesunąć się w pamięci, by przejść do kolejnego elementu wzdłuż danej osi. Dzięki temu NumPy potrafi udostępniać podtablice i zmieniać kształt bez kopiowania danych (poprzez tzw. widoki).
Najważniejsze różnice praktyczne: rozmiar ndarray jest z reguły stały (zmiana rozmiaru tworzy nową tablicę), operacje wykonują się element po elemencie na całej tablicy naraz (a + b zamiast pętli), a sama tablica jest znacznie bardziej zwarta w pamięci. Lista wygrywa tam, gdzie potrzebna jest heterogeniczność i częste wstawianie/usuwanie elementów; ndarray wygrywa wszędzie tam, gdzie liczą się jednorodne dane numeryczne i wydajność.
flowchart LR
subgraph L["Lista Pythona"]
direction TB
LA["lista = tablica wskaznikow"]
LA --> P1["PyObject (int 1)"]
LA --> P2["PyObject (float 2.0)"]
LA --> P3["PyObject (str 'x')"]
P1 -.rozproszone w pamieci.-> P2
P2 -.-> P3
end
subgraph N["ndarray NumPy"]
direction TB
NA["ciagly blok pamieci, jeden dtype"]
NA --> B["[ 1 | 2 | 3 | 4 | 5 ] (int64, obok siebie)"]
end
Przykład kodu:
import numpy as np
lista = [1, 2, 3, 4]
tablica = np.array([1, 2, 3, 4])
# Lista akceptuje typy mieszane; ndarray ujednolica typ
mieszana = [1, "tekst", 3.5] # OK dla listy
jednorodna = np.array([1, 2, 3]) # dtype=int64
# Mnożenie listy POWIELA jej zawartość...
print(lista * 2) # [1, 2, 3, 4, 1, 2, 3, 4]
# ...a tablicy — mnoży każdy element (operacja matematyczna)
print(tablica * 2) # array([2, 4, 6, 8])
# ndarray zna swoje metadane
print(tablica.shape, tablica.ndim, tablica.dtype) # (4,) 1 int64
Materiały
Dlaczego operacje na tablicach NumPy są zwykle szybsze niż pętle na listach Pythona?
Odpowiedź w 30 sekund: Bo NumPy wykonuje operacje zwektoryzowane w skompilowanym kodzie C/Fortran na ciągłym bloku pamięci o jednorodnym typie, zamiast iterować w interpreterze Pythona. Eliminuje to narzut pętli interpretera, dynamicznego typowania i tworzenia obiektów per element, a dodatkowo zyskuje na lokalności pamięci i instrukcjach SIMD procesora.
Odpowiedź w 2 minuty:
Pętla po liście w Pythonie jest kosztowna z kilku powodów naraz. Po pierwsze, narzut interpretera: każda iteracja to wykonanie bytecode'u w pętli ewaluacyjnej maszyny wirtualnej. Po drugie, dynamiczne typowanie: przy każdej operacji a + b interpreter musi sprawdzić typy operandów i wybrać właściwą implementację. Po trzecie, opakowywanie w obiekty: wyniki to nowe obiekty PyObject alokowane na stercie, z licznikami referencji. Po czwarte, rozproszona pamięć: elementy listy leżą w różnych miejscach, więc procesor traci czas na chybienia w pamięci podręcznej (ang. cache misses).
NumPy usuwa wszystkie te narzuty dzięki wektoryzacji. Operacja taka jak a + b na tablicach uruchamia jedną wywołaną funkcję C (tzw. ufunc), która w zwartej pętli w skompilowanym kodzie przetwarza cały bufor. Typ jest znany z góry (dtype), więc nie ma sprawdzania typów per element ani tworzenia obiektów pośrednich — pętla operuje na surowych liczbach w pamięci.
Dochodzą do tego korzyści sprzętowe. Ciągły blok pamięci o jednorodnym typie oznacza świetną lokalność danych — procesor efektywnie wykorzystuje pamięć podręczną i prefetcher. Co więcej, taka pętla w C nadaje się do wektoryzacji SIMD (ang. Single Instruction, Multiple Data), gdzie jedna instrukcja procesora przetwarza wiele liczb jednocześnie. Operacje algebry liniowej dodatkowo delegowane są do zoptymalizowanych bibliotek BLAS/LAPACK, często wielowątkowych.
Warto pamiętać o niuansach. Przewaga ujawnia się przy większych tablicach — dla kilku elementów narzut samego wywołania funkcji NumPy może przeważyć. Antywzorcem jest też iterowanie po ndarray pętlą Pythona (for x in tablica), co łączy wady obu światów. Klucz to myślenie tablicami: wyrażać obliczenia jako operacje na całych tablicach, używając broadcastingu i funkcji uniwersalnych zamiast jawnych pętli.
Przykład kodu:
import numpy as np
n = 1_000_000
lista_a = list(range(n))
lista_b = list(range(n))
# Podejście "Pythonowe" — pętla interpretowana, wolne
def suma_petla(a, b):
return [x + y for x, y in zip(a, b)]
# Podejście NumPy — wektoryzacja w C, szybkie
a = np.arange(n)
b = np.arange(n)
wynik = a + b # jedna operacja na całej tablicy
# Pomiar (np. w IPythonie / Jupyterze):
# %timeit suma_petla(lista_a, lista_b) -> ~setki ms
# %timeit a + b -> ~kilka ms
# Różnica rzędu 10-100x na korzyść NumPy
print(wynik[:5]) # array([0, 2, 4, 6, 8])
Materiały
Czym jest dtype i dlaczego jednorodność typów w tablicy ma znaczenie?
Odpowiedź w 30 sekund:
dtype (ang. data type) to obiekt opisujący typ i rozmiar w bajtach pojedynczego elementu tablicy NumPy — np. int64, float32, bool czy complex128. Wszystkie elementy ndarray mają ten sam dtype, co umożliwia ciągłą, zwartą reprezentację w pamięci i wektoryzowane operacje w C bez sprawdzania typu per element.
Odpowiedź w 2 minuty:
Każda tablica NumPy ma przypisany jeden dtype, który mówi, jak interpretować bajty w buforze: czy to liczby całkowite, zmiennoprzecinkowe, logiczne, zespolone, łańcuchy o stałej długości itd., oraz ile bajtów zajmuje element. Stąd nazwy takie jak int32 (4 bajty) czy float64 (8 bajtów). Dzięki znanemu z góry typowi NumPy może przechowywać dane jako surowy, ciągły blok pamięci i wie dokładnie, gdzie kończy się jeden element, a zaczyna następny.
Jednorodność typów jest tu kluczowa wydajnościowo. Skoro każdy element ma identyczny rozmiar i interpretację, pętla w skompilowanym C może przebiegać po buforze bez żadnego sprawdzania typu — to fundament wektoryzacji i SIMD. Gdyby typy były mieszane (jak w liście), trzeba by sprawdzać każdy element osobno, co niweczyłoby przewagę wydajnościową. Jednorodność daje też zwartość pamięci: milion float64 to ok. 8 MB ciągłego bufora, podczas gdy lista tych samych liczb zajmie wielokrotnie więcej przez nagłówki obiektów i wskaźniki.
dtype niesie też ważne konsekwencje semantyczne. Typy całkowite o stałej szerokości mogą się przepełniać (ang. overflow) — int8 przechowa wartości tylko od -128 do 127. Typy zmiennoprzecinkowe mają ograniczoną precyzję (float32 vs float64) i wprowadzają błędy zaokrągleń. Dobór typu to także kompromis pamięć kontra precyzja/zakres: float32 zajmuje połowę miejsca float64, co bywa istotne w uczeniu maszynowym, ale kosztem dokładności.
Przy łączeniu tablic o różnych typach NumPy stosuje reguły promocji typów (ang. type promotion / casting), wybierając wspólny typ zdolny pomieścić oba (np. int + float → float). Warto to kontrolować jawnie przez argument dtype= przy tworzeniu tablicy lub metodę astype(), by uniknąć niespodzianek z zakresem, precyzją i zużyciem pamięci.
Przykład kodu:
import numpy as np
a = np.array([1, 2, 3])
print(a.dtype) # int64 (zależnie od platformy)
# Jawne ustawienie typu — kontrola pamięci i precyzji
b = np.array([1, 2, 3], dtype=np.float32)
print(b.dtype, b.itemsize) # float32 4 (bajty na element)
# Przepełnienie typu o stałej szerokości
c = np.array([127], dtype=np.int8)
print(c + 1) # array([-128]) — overflow!
# Promocja typów przy operacjach mieszanych
i = np.array([1, 2, 3], dtype=np.int64)
f = np.array([0.5, 0.5, 0.5], dtype=np.float64)
print((i + f).dtype) # float64
# Konwersja typu istniejącej tablicy
d = b.astype(np.int32)
print(d.dtype, d) # int32 [1 2 3]
Materiały
Jakie znaczenie ma liczba wymiarów tablicy (skalar, wektor, macierz, tablica n-wymiarowa) i jak NumPy je reprezentuje?
Odpowiedź w 30 sekund:
Liczba wymiarów (osi) tablicy określa jej strukturę: skalar (0D) to pojedyncza wartość, wektor (1D) to ciąg liczb, macierz (2D) to tabela wiersze×kolumny, a tablica n-wymiarowa (nD) uogólnia to na dowolnie wiele osi. NumPy opisuje to atrybutami ndim (liczba wymiarów) i shape (rozmiar wzdłuż każdej osi), a wszystkie te przypadki to ten sam typ ndarray.
Odpowiedź w 2 minuty:
W NumPy „wymiar" to oś (ang. axis), a liczba osi to atrybut ndim. Kształt shape to krotka podająca długość tablicy wzdłuż każdej osi. Skalar (0D) ma shape == () i jest pojedynczą wartością. Wektor (1D) ma jeden wymiar, np. shape == (5,) — to ciąg pięciu liczb. Macierz (2D) ma dwa wymiary, np. shape == (3, 4) — trzy wiersze i cztery kolumny. Tablica n-wymiarowa (nD) ma trzy lub więcej osi, np. (2, 3, 4) — przydatna do obrazów (wysokość×szerokość×kanały), serii czasowych, partii (ang. batch) danych w sieciach neuronowych.
Kluczowe jest to, że niezależnie od liczby wymiarów mamy do czynienia z jednym typem ndarray i tym samym, ciągłym buforem pamięci — wymiary to tylko sposób interpretacji tego płaskiego bufora poprzez kształt i strides. Dlatego można tanio zmieniać kształt tablicy (reshape) bez kopiowania danych: te same bajty raz widzimy jako wektor 12-elementowy, a raz jako macierz 3×4.
Liczba wymiarów ma znaczenie praktyczne, bo wiele operacji działa wzdłuż wybranej osi. Argument axis decyduje, czy np. sum sumuje kolumny, czy wiersze. Również broadcasting (dopasowywanie kształtów przy operacjach) oraz mnożenie macierzy zależą wprost od liczby i rozmiarów wymiarów. Niezrozumienie kształtu to najczęstsze źródło błędów — typowy ValueError o niezgodnych kształtach bierze się z nieświadomości, ile osi i jakiej długości ma tablica.
Warto odróżnić pojęcia: ndim to liczba osi, shape to ich długości, a size to całkowita liczba elementów (iloczyn wartości z shape). NumPy świadomie używa ogólnego ndarray zamiast osobnego typu macierzy — daje to spójny, jednolity model dla danych o dowolnej liczbie wymiarów.
Przykład kodu:
import numpy as np
skalar = np.array(7) # 0D
wektor = np.array([1, 2, 3, 4]) # 1D
macierz = np.array([[1, 2, 3],
[4, 5, 6]]) # 2D
tensor = np.arange(24).reshape(2, 3, 4) # 3D (nD)
for nazwa, t in [("skalar", skalar), ("wektor", wektor),
("macierz", macierz), ("tensor", tensor)]:
print(f"{nazwa:8} ndim={t.ndim} shape={t.shape} size={t.size}")
# skalar ndim=0 shape=() size=1
# wektor ndim=1 shape=(4,) size=4
# macierz ndim=2 shape=(2, 3) size=6
# tensor ndim=3 shape=(2, 3, 4) size=24
# Operacje wzdłuż osi (axis) zależą od liczby wymiarów
print(macierz.sum(axis=0)) # suma po kolumnach -> [5 7 9]
print(macierz.sum(axis=1)) # suma po wierszach -> [ 6 15]
# Zmiana kształtu bez kopiowania danych (ten sam bufor)
print(wektor.reshape(2, 2)) # [[1 2]
# [3 4]]
Materiały
Tworzenie tablic i ich atrybuty
Jakie znasz sposoby tworzenia tablic NumPy (array, zeros, ones, full, empty)?
Odpowiedź w 30 sekund:
Tablicę z istniejących danych (lista, krotka) tworzy się funkcją np.array(...). Do tworzenia tablic o zadanym kształcie wypełnionych konkretną wartością służą fabryki: np.zeros (same zera), np.ones (same jedynki), np.full (dowolna wartość) oraz np.empty (niezainicjowana pamięć — śmieci, bez gwarantowanej wartości). Każda z tych funkcji przyjmuje krotkę z kształtem i opcjonalny argument dtype.
Odpowiedź w 2 minuty:
Podstawowym sposobem jest np.array(obj), który konwertuje istniejący obiekt Pythona (zagnieżdżoną listę, krotkę, inną tablicę) na ndarray. NumPy sam wnioskuje wówczas wymiary i wspólny dtype, choć można go wymusić argumentem dtype=. To wybór, gdy masz już dane do umieszczenia w tablicy.
Gdy nie masz jeszcze danych, ale znasz docelowy kształt, korzystasz z fabryk. np.zeros(shape) zwraca tablicę zer (typ domyślny float64), a np.ones(shape) tablicę jedynek — oba bywają punktem startowym do akumulatorów, masek czy macierzy, które będziesz później wypełniać. np.full(shape, fill_value) to ich uogólnienie: wypełnia tablicę dowolną stałą, np. np.nan, -1 czy 255.
np.empty(shape) jest najszybsze, bo jedynie rezerwuje pamięć bez jej inicjalizacji — zawartość to przypadkowe „śmieci" pozostałe w pamięci. Używa się go tylko wtedy, gdy gwarantujesz, że nadpiszesz każdą komórkę przed odczytem; w przeciwnym razie wynik będzie nieprzewidywalny. To częsta pułapka: np.empty nie jest „szybszym np.zeros", bo nie zeruje danych.
Warto znać też warianty *_like (np.zeros_like, np.ones_like, np.full_like, np.empty_like), które tworzą nową tablicę o kształcie i typie istniejącej tablicy wzorcowej, oraz np.eye/np.identity dla macierzy jednostkowej. Domyślnym typem dla zer i jedynek jest float64, co bywa zaskakujące, gdy spodziewamy się liczb całkowitych — wtedy podaj dtype=int.
Przykład kodu:
import numpy as np
# 1. Z istniejących danych — NumPy wnioskuje kształt i dtype
a = np.array([[1, 2, 3], [4, 5, 6]])
print(a.shape, a.dtype) # (2, 3) int64
# 2. Same zera (domyślnie float64)
zera = np.zeros((2, 3))
print(zera)
# 3. Same jedynki, ale jako liczby całkowite
jedynki = np.ones((2, 3), dtype=int)
# 4. Dowolna stała wartość
pelna = np.full((2, 2), fill_value=7.5)
# 5. Niezainicjowana pamięć — UWAGA: zawiera śmieci, nie zera!
puste = np.empty((2, 2))
print(puste) # nieprzewidywalne wartości
# Warianty *_like — kształt i typ jak we wzorcu
print(np.zeros_like(a)) # zera o kształcie (2, 3), dtype int64
Materiały
Czym różnią się arange i linspace i kiedy użyłbyś którego?
Odpowiedź w 30 sekund:
np.arange(start, stop, step) generuje wartości od start do stop z zadanym krokiem, nie włączając końca przedziału (przedział otwarty z prawej). np.linspace(start, stop, num) generuje zadaną liczbę punktów równomiernie rozłożonych w przedziale, domyślnie włączając koniec. Dla liczb zmiennoprzecinkowych preferuj linspace, bo arange z krokiem ułamkowym potrafi dać nieprzewidywalną liczbę elementów przez błędy zaokrągleń.
Odpowiedź w 2 minuty:
Obie funkcje tworzą równomiernie rozłożone wartości, ale parametryzują problem z dwóch różnych stron. W arange decydujesz o kroku (odległości między sąsiednimi wartościami), a liczba elementów wychodzi z obliczeń. W linspace decydujesz o liczbie punktów (num), a krok jest wyliczany automatycznie. To fundamentalna różnica: gdy zależy ci na konkretnym kroku (np. „co 2"), naturalny jest arange; gdy zależy ci na konkretnej liczbie próbek (np. „100 punktów do wykresu"), naturalny jest linspace.
Druga różnica to włączanie końca przedziału. arange, podobnie jak range w Pythonie, nie zawiera wartości stop. linspace domyślnie zawiera stop (chyba że ustawisz endpoint=False). Dlatego np.arange(0, 10, 2) daje [0, 2, 4, 6, 8], a np.linspace(0, 10, 5) daje [0, 2.5, 5, 7.5, 10] — z końcem 10.
Najważniejszy praktyczny niuans dotyczy liczb zmiennoprzecinkowych. Krok ułamkowy w arange (np. 0.1) nie jest reprezentowany dokładnie w arytmetyce zmiennoprzecinkowej, przez co długość wyniku bywa zaskakująca, a ostatni element może nieznacznie przekroczyć lub nie dotknąć granicy. Z tego powodu dokumentacja NumPy wprost zaleca linspace dla wartości niecałkowitych — tam liczbę elementów podajesz wprost, więc jest deterministyczna. arange rezerwuj dla kroków całkowitych.
| Cecha | np.arange |
np.linspace |
|---|---|---|
| Co podajesz | Krok (step) |
Liczbę punktów (num) |
| Co jest wyliczane | Liczba elementów | Krok |
Koniec przedziału (stop) |
Wykluczony | Domyślnie włączony (endpoint=True) |
| Liczby zmiennoprzecinkowe | Ryzykowne (błędy zaokrągleń) | Zalecane, deterministyczne |
| Typowe zastosowanie | Indeksy, kroki całkowite | Próbki do wykresów, siatki |
Przykład kodu:
import numpy as np
# arange — krok 2, koniec WYKLUCZONY
print(np.arange(0, 10, 2)) # [0 2 4 6 8] — brak 10
# linspace — 5 punktów, koniec WŁĄCZONY
print(np.linspace(0, 10, 5)) # [ 0. 2.5 5. 7.5 10. ]
# linspace bez końca przedziału
print(np.linspace(0, 10, 5, endpoint=False)) # [0. 2. 4. 6. 8.]
# Pułapka arange z krokiem ułamkowym — liczba elementów bywa zaskakująca
print(np.arange(0, 1, 0.1).size) # 10, ale dla innych kroków bywa 9 lub 11
# Bezpieczna alternatywa: 11 punktów od 0 do 1 włącznie
print(np.linspace(0, 1, 11))
# linspace może zwrócić też krok (retstep=True)
wartosci, krok = np.linspace(0, 1, 5, retstep=True)
print(krok) # 0.25
Materiały
Co opisują atrybuty shape, ndim, size oraz dtype?
Odpowiedź w 30 sekund:
shape to krotka z liczbą elementów wzdłuż każdej osi (np. (3, 4) dla macierzy 3×4). ndim to liczba wymiarów (osi) — tutaj 2. size to całkowita liczba elementów, czyli iloczyn wartości z shape (tutaj 12). dtype to typ danych przechowywanych w tablicy (np. int64, float64), wspólny dla wszystkich elementów.
Odpowiedź w 2 minuty:
Te cztery atrybuty opisują metadane tablicy ndarray i są dostępne bez nawiasów (to właściwości, nie metody).
shape to krotka liczb całkowitych — każda pozycja mówi, ile elementów leży wzdłuż danej osi. Dla wektora to np. (5,), dla macierzy (3, 4) (3 wiersze, 4 kolumny), dla tablicy 3D np. (2, 3, 4). Kolejność osi ma znaczenie i jest fundamentem operacji takich jak reshape, transpose czy broadcasting.
ndim to liczba wymiarów, czyli długość krotki shape. Skalar opakowany w tablicę ma ndim == 0, wektor 1, macierz 2, a tablice wyższych rzędów odpowiednio więcej. To wygodny skrót zamiast liczenia len(a.shape).
size to całkowita liczba elementów, czyli iloczyn wszystkich liczb w shape. Dla kształtu (3, 4) to 12. Nie należy mylić tego z nbytes (rozmiarem w bajtach) ani z len(a), które zwraca jedynie rozmiar pierwszej osi.
dtype opisuje typ danych każdego elementu. NumPy wymaga jednorodności — wszystkie elementy mają ten sam typ, co umożliwia ciągły układ pamięci i szybkie operacje wektorowe. Typ niesie informacje o rodzaju (całkowity, zmiennoprzecinkowy, logiczny) i o rozmiarze w bajtach (int8, int32, int64, float32, float64). Powiązany atrybut itemsize mówi, ile bajtów zajmuje jeden element, więc size * itemsize == nbytes.
Przykład kodu:
import numpy as np
a = np.array([[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]])
print(a.shape) # (3, 4) — 3 wiersze, 4 kolumny
print(a.ndim) # 2 — dwa wymiary (== len(a.shape))
print(a.size) # 12 — 3 * 4 elementów
print(a.dtype) # int64 — wspólny typ wszystkich elementów
# Powiązane atrybuty pamięci
print(a.itemsize) # 8 — bajtów na jeden element (int64)
print(a.nbytes) # 96 — size * itemsize = 12 * 8
# Uwaga: len() to tylko pierwsza oś, nie size!
print(len(a)) # 3
Materiały
Jak działa reshape i co oznacza wymiar -1 przekazany jako argument?
Odpowiedź w 30 sekund:
reshape zmienia kształt tablicy bez zmiany jej danych — nowy kształt musi mieć tę samą liczbę elementów co stary (size się nie zmienia). Wartość -1 w jednym z wymiarów to „placeholder": każesz NumPy samodzielnie wyliczyć ten wymiar na podstawie pozostałych i całkowitej liczby elementów. Można użyć -1 tylko raz. reshape zwykle zwraca widok (tę samą pamięć), a nie kopię.
Odpowiedź w 2 minuty:
reshape(nowy_kształt) reinterpretuje ten sam ciąg danych jako tablicę o innym kształcie. Warunek konieczny: iloczyn wymiarów nowego kształtu musi być równy size tablicy. Próba przekształcenia 12 elementów w kształt (3, 5) (15 elementów) skończy się błędem ValueError. Domyślnie elementy są odczytywane w porządku C (wierszami, row-major) — możesz to zmienić argumentem order='F' (kolumnami).
Wartość -1 to wygodny sposób, by nie liczyć jednego wymiaru ręcznie. NumPy podstawia za -1 taką liczbę, aby iloczyn się zgadzał. Np. dla 12 elementów reshape(3, -1) daje kształt (3, 4), a reshape(-1, 2) daje (6, 2). Najczęstsze użycie to spłaszczenie do jednego wymiaru: reshape(-1) zamienia dowolną tablicę w wektor. -1 można podać tylko w jednym wymiarze — w przeciwnym razie układ jest niejednoznaczny i NumPy zgłosi błąd. Jeśli liczba elementów nie dzieli się równo (np. 13 elementów na reshape(2, -1)), również dostaniesz ValueError.
Istotny niuans wydajnościowy: reshape stara się zwrócić widok (view) — nową „etykietę" wskazującą na tę samą pamięć, bez kopiowania danych. To bardzo tanie. Jednak gdy układ pamięci nie pozwala na widok o żądanym kształcie (np. po wcześniejszym transpose, gdy dane nie są ciągłe), NumPy utworzy kopię. Dlatego nie należy zakładać, że modyfikacja wyniku reshape zawsze wpłynie na oryginał — czasem tak, czasem nie. Jeśli potrzebujesz pewności co do spłaszczonej kopii, użyj flatten() (zawsze kopia) zamiast ravel()/reshape(-1) (zwykle widok).
flowchart TD
A["reshape(3, -1)<br/>size = 12"] --> B{"Iloczyn znanych<br/>wymiarów dzieli size?"}
B -- "Tak: 12 / 3 = 4" --> C["Podstaw -1 = 4<br/>kształt (3, 4)"]
B -- "Nie" --> D["ValueError"]
C --> E{"Dane ciągłe<br/>w pamięci?"}
E -- "Tak" --> F["Zwróć widok<br/>(bez kopiowania)"]
E -- "Nie" --> G["Zwróć kopię"]
Przykład kodu:
import numpy as np
a = np.arange(12) # [0 1 2 ... 11], size = 12
# Jawny kształt — iloczyn musi dać 12
print(a.reshape(3, 4).shape) # (3, 4)
# -1: NumPy wylicza brakujący wymiar (12 / 3 = 4)
print(a.reshape(3, -1).shape) # (3, 4)
print(a.reshape(-1, 2).shape) # (6, 2)
# Spłaszczenie do wektora
print(a.reshape(3, 4).reshape(-1).shape) # (12,)
# -1 tylko raz — to zgłosi błąd:
# a.reshape(-1, -1) # ValueError
# reshape zwykle zwraca WIDOK — zmiana wpływa na oryginał
b = a.reshape(3, 4)
b[0, 0] = 99
print(a[0]) # 99 — to ta sama pamięć
# flatten() to zawsze KOPIA, ravel()/reshape(-1) — zwykle widok
print(a.reshape(3, 4).flatten().base is None) # True (kopia)
Materiały
Jak generować tablice z liczbami losowymi i czym np.random.default_rng (Generator) różni się od starszego API np.random?
Odpowiedź w 30 sekund:
Współczesny, zalecany sposób to utworzenie generatora rng = np.random.default_rng(seed) i wywoływanie na nim metod: rng.random(), rng.integers(), rng.normal() itd. Starsze, legacy API to globalne funkcje np.random.rand, np.random.randint, np.random.seed — działają na jednym, ukrytym stanie globalnym. default_rng oferuje lepszy algorytm PRNG (PCG64), brak ukrytego stanu globalnego i jawny, kontrolowany seed, dlatego od NumPy 1.17 jest preferowane w nowym kodzie.
Odpowiedź w 2 minuty:
Od NumPy 1.17 obowiązuje nowa architektura losowości oparta na dwóch obiektach: BitGenerator (źródło surowych losowych bitów, domyślnie PCG64) i Generator (warstwa zamieniająca bity na rozkłady — jednostajny, normalny, dwumianowy itd.). Funkcja np.random.default_rng(seed) zwraca gotowy Generator. Wywołujesz na nim metody: rng.random(rozmiar) (rozkład jednostajny [0, 1)), rng.integers(low, high, size) (liczby całkowite), rng.normal(loc, scale, size), rng.choice(...), rng.shuffle(...) i inne.
Legacy API to zestaw funkcji wprost w przestrzeni np.random: np.random.rand, np.random.randn, np.random.randint, a determinizm ustawiało się przez np.random.seed(...). Problem polega na tym, że wszystkie te funkcje współdzielą jeden globalny stan. W większym programie czy w wielu modułach trudno wtedy zapanować nad reprodukowalnością — dowolny fragment kodu (albo importowana biblioteka) może niepostrzeżenie „przesunąć" globalny generator i zmienić wyniki gdzie indziej. To klasyczne źródło trudnych do wytropienia błędów w eksperymentach ML.
Przewagi nowego API są konkretne. Po pierwsze, brak ukrytego stanu globalnego — każdy Generator jest niezależnym obiektem, który możesz przekazać tam, gdzie jest potrzebny. Po drugie, jakość i szybkość: domyślny PCG64 ma lepsze własności statystyczne niż stary Mersenne Twister z legacy API. Po trzecie, jawny i bezpieczny seeding — możesz podać liczbę, ale zalecanym wzorcem jest np.random.SeedSequence, co ułatwia rozsiewanie niezależnych strumieni (np. do obliczeń równoległych). Po czwarte, drobne, ale ważne różnice w API, np. rng.integers domyślnie wyklucza górną granicę spójnie z range, a metoda nazywa się integers, nie randint.
Reprodukowalność uzyskujesz tak samo prosto: ten sam seed daje tę samą sekwencję. Po prostu twórz osobny rng z ustalonym ziarnem na początku skryptu i używaj go wszędzie. Stare API nadal działa (dla kompatybilności wstecznej), ale w nowym kodzie dokumentacja NumPy wprost rekomenduje default_rng.
Przykład kodu:
import numpy as np
# === ZALECANE: nowoczesne API (Generator) ===
rng = np.random.default_rng(seed=42) # jawny, lokalny generator
print(rng.random(3)) # 3 liczby z [0, 1)
print(rng.integers(0, 10, size=5)) # 5 liczb całkowitych [0, 10)
print(rng.normal(loc=0, scale=1, size=3)) # rozkład normalny
print(rng.choice([10, 20, 30], size=2)) # losowy wybór z tablicy
# Reprodukowalność: ten sam seed -> ta sama sekwencja
rng_a = np.random.default_rng(0)
rng_b = np.random.default_rng(0)
print(np.array_equal(rng_a.random(5), rng_b.random(5))) # True
# Niezależne strumienie (np. do obliczeń równoległych)
ss = np.random.SeedSequence(12345)
generatory = [np.random.default_rng(s) for s in ss.spawn(3)]
# === LEGACY (starsze, do nowego kodu NIEzalecane) ===
np.random.seed(42) # globalny, współdzielony stan
print(np.random.rand(3)) # rozkład jednostajny
print(np.random.randint(0, 10, 5))
Materiały
Jak rzutować typ danych tablicy (astype) i na co uważać przy zmianie dtype?
Odpowiedź w 30 sekund:
Typ danych zmienia się metodą tablica.astype(nowy_dtype), która domyślnie zwraca nową KOPIĘ tablicy z przekonwertowanymi wartościami — oryginał pozostaje nietknięty. Główne pułapki to: utrata precyzji przy float → int (następuje obcięcie w stronę zera, nie zaokrąglenie), oraz przepełnienie/zawijanie wartości przy zbyt małym typie całkowitym (np. uint8 mieści tylko 0–255). Argument copy=False pozwala uniknąć kopii, jeśli typ i tak się nie zmienia.
Odpowiedź w 2 minuty:
astype(dtype) to standardowy sposób rzutowania. Kluczowe: domyślnie tworzy KOPIĘ danych w nowym typie i zwraca nową tablicę — w przeciwieństwie do reshape nie jest to widok. Dla dużych tablic oznacza to realny koszt pamięci, więc nie rzutuj „na wszelki wypadek" w pętli. Argument copy=False mówi NumPy, by nie kopiował, jeśli to możliwe — czyli gdy żądany typ jest identyczny z bieżącym. Gdy konwersja jest faktyczna, kopia i tak musi powstać, bo zmienia się układ bajtów.
Pierwsza pułapka to konwersja float → int. NumPy obcina część ułamkową w stronę zera (truncation), a nie zaokrągla. Zatem 1.9 daje 1, a -1.9 daje -1. Jeśli chcesz zaokrąglenia matematycznego, najpierw zastosuj np.round(...), a dopiero potem astype(int). To częste źródło cichych błędów w obliczeniach — wyniki są „prawie dobre", tylko systematycznie zaniżone.
Druga, groźniejsza pułapka to przepełnienie (overflow) przy zbyt małym typie całkowitym. Typy takie jak int8, uint8, int16 mają ograniczony zakres (uint8: 0–255, int8: −128–127). Wartość spoza zakresu zawija się modulo (wrap-around) zamiast zgłosić błąd. Konwersja 300 na uint8 daje 44 (300 mod 256), a -1 daje 255. To bywa wykorzystywane celowo (np. obrazy), ale gdy jest przypadkowe — prowadzi do trudnych do wykrycia, „magicznych" liczb. Analogicznie float → float32 traci precyzję dla bardzo dużych/małych wartości, a konwersja zespolonych na rzeczywiste odrzuca część urojoną (z ostrzeżeniem).
Praktyczne wskazówki: (1) zawsze świadomie wybieraj typ docelowy, znając zakres danych; (2) przed float → int rozważ np.round; (3) dla pewności sprawdź zakres typu przez np.iinfo(np.uint8) / np.finfo(np.float32); (4) pamiętaj, że wynik jest kopią, więc przypisz go do zmiennej (a = a.astype(...)), bo modyfikacja oryginału „w miejscu" przez astype nie zachodzi.
Przykład kodu:
import numpy as np
a = np.array([1, 2, 3])
# astype ZAWSZE zwraca nową tablicę (domyślnie kopia) — oryginał bez zmian
b = a.astype(np.float64)
print(b.base is None) # True — to niezależna kopia
print(a.dtype, b.dtype) # int64 float64
# PUŁAPKA 1: float -> int OBCINA (w stronę zera), nie zaokrągla
f = np.array([1.9, 2.1, -1.9])
print(f.astype(int)) # [ 1 2 -1] — nie [2, 2, -2]!
# Aby zaokrąglić: najpierw round
print(np.round(f).astype(int)) # [ 2 2 -2]
# PUŁAPKA 2: przepełnienie przy zbyt małym typie (uint8: 0-255)
big = np.array([300, 256, -1])
print(big.astype(np.uint8)) # [ 44 0 255] — zawijanie modulo 256!
# Sprawdzenie zakresu typu przed konwersją
print(np.iinfo(np.uint8)) # min = 0, max = 255
# copy=False unika kopii tylko, gdy typ się NIE zmienia
c = a.astype(np.int64, copy=False)
print(c is a) # True — ten sam obiekt (typ identyczny)
Materiały
Indeksowanie, wycinanie i widoki
Jak działa podstawowe indeksowanie i wycinanie (slicing) w tablicach wielowymiarowych?
Odpowiedź w 30 sekund:
Podstawowe indeksowanie w NumPy używa krotki indeksów oddzielonych przecinkami, po jednym na każdy wymiar, np. a[2, 3] dla tablicy 2D. Wycinanie (slicing) start:stop:step wybiera zakresy elementów wzdłuż każdej osi i — co kluczowe — zwraca widok współdzielący pamięć z oryginałem, a nie kopię.
Odpowiedź w 2 minuty:
W tablicach wielowymiarowych adresujemy elementy podając jeden indeks na wymiar wewnątrz jednej pary nawiasów kwadratowych: a[i, j, k]. Zapis a[i][j] też działa, ale jest wolniejszy i mniej czytelny, bo tworzy pośrednie tablice — w NumPy preferujemy formę z krotką a[i, j]. Indeksy mogą być ujemne (liczone od końca), więc a[-1] to ostatni wiersz, a a[-1, -1] to ostatni element.
Wycinanie ma postać start:stop:step, gdzie stop jest wyłączny. Pominięte wartości oznaczają domyślnie: początek tablicy, koniec tablicy i krok 1. Każdej osi można nadać własny slice, np. a[1:3, ::2] bierze wiersze 1–2 i co drugą kolumnę. Symbol ... (Ellipsis) zastępuje tyle pełnych slice'ów :, ile trzeba, aby dopełnić wymiary — a[..., 0] to ostatni wymiar zredukowany do indeksu 0. Z kolei np.newaxis (czyli None) dodaje nowy wymiar o rozmiarze 1, co przydaje się przy broadcastingu.
Najważniejsza cecha podstawowego slicingu: zwraca widok (view), czyli nowy obiekt tablicy, który wskazuje na ten sam bufor danych co oryginał. Dzięki temu wycinanie jest bardzo tanie (nie kopiuje danych), ale ma to konsekwencję — modyfikacja elementów wycinka zmienia również oryginalną tablicę. To zachowanie różni NumPy od list Pythona, gdzie slicing tworzy płytką kopię. Ważne też, że indeksowanie pojedynczym skalarnym indeksem redukuje wymiar (a[0] z 2D daje 1D), a slice go zachowuje (a[0:1] z 2D daje nadal 2D).
Przykład kodu:
import numpy as np
a = np.arange(12).reshape(3, 4) # tablica 3x4 z liczbami 0..11
print(a)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
# Indeksowanie: jeden indeks na wymiar w jednej krotce
print(a[1, 2]) # 6 - wiersz 1, kolumna 2
print(a[-1, -1]) # 11 - ostatni element (indeksy ujemne)
# Wycinanie start:stop:step na każdej osi
print(a[0:2, 1:3]) # wiersze 0-1, kolumny 1-2 -> [[1 2], [5 6]]
print(a[::2, ::2]) # co drugi wiersz i co druga kolumna
# Ellipsis i newaxis
print(a[..., 0]) # pierwsza kolumna jako wektor 1D -> [0 4 8]
print(a[:, None].shape) # dodanie osi -> (3, 1, 4)
# KLUCZOWE: slice zwraca WIDOK, modyfikacja zmienia oryginał
w = a[0, :] # widok pierwszego wiersza
w[0] = 999 # zapis do widoku...
print(a[0, 0]) # 999 - ...zmienił oryginał!
Materiały
Czym jest fancy indexing (indeksowanie tablicą indeksów) i co go odróżnia od zwykłego slicingu?
Odpowiedź w 30 sekund:
Fancy indexing (indeksowanie zaawansowane całkowitoliczbowe) polega na podaniu jako indeksu tablicy lub listy liczb całkowitych, np. a[[0, 2, 2, 1]], dzięki czemu możemy wybrać elementy w dowolnej kolejności i z powtórzeniami. W przeciwieństwie do slicingu, który zwraca widok, fancy indexing zawsze zwraca nową kopię danych, a kształt wyniku zależy od kształtu tablic indeksujących.
Odpowiedź w 2 minuty:
Fancy indexing (advanced indexing) aktywuje się, gdy zamiast pojedynczego skalara czy slice'a : przekazujemy jako indeks sekwencję liczb całkowitych — listę Pythona lub tablicę ndarray typu integer. Pozwala to wybierać dowolny zbiór elementów: w dowolnej kolejności, z powtórzeniami i według wcześniej obliczonej tablicy pozycji. Przykładowo a[[3, 0, 0, 2]] zwróci wiersze o tych numerach, dokładnie w tej kolejności.
W wielu wymiarach podajemy osobną tablicę indeksów na każdą oś, a NumPy paruje je element po elemencie (z uwzględnieniem broadcastingu). Zapis a[[0, 1, 2], [0, 1, 0]] wybierze elementy a[0,0], a[1,1], a[2,0] — czyli zwraca wektor współrzędnych, a nie podmacierz. To częsta pułapka: aby wybrać prostokątny blok przecięcia konkretnych wierszy i kolumn, trzeba użyć broadcastingu (a[rows[:, None], cols]) lub np.ix_(rows, cols). Kształt wyniku fancy indexingu jest dyktowany przez (rozgłoszony) kształt tablic indeksujących, a nie przez kształt tablicy źródłowej.
Najważniejsze różnice względem slicingu: (1) fancy indexing zwraca kopię — modyfikacja wyniku nie wpływa na oryginał (choć przypisanie do fancy indexa, np. a[[0, 2]] = 0, modyfikuje oryginał na miejscu); (2) pozwala na dowolną kolejność i powtórzenia, czego slice (regularny krok) nie umożliwia; (3) jest droższy obliczeniowo, bo alokuje nową pamięć. Slicing natomiast jest ograniczony do regularnych zakresów start:stop:step, ale za to darmowy pod względem pamięci, bo zwraca widok.
Przykład kodu:
import numpy as np
a = np.arange(10, 20) # [10 11 12 13 14 15 16 17 18 19]
# Fancy indexing: lista indeksów - dowolna kolejność, powtórzenia
print(a[[0, 3, 3, 5]]) # [10 13 13 15]
m = np.arange(12).reshape(3, 4)
# Para tablic indeksów -> wybór POJEDYNCZYCH elementów (współrzędne)
print(m[[0, 1, 2], [0, 1, 0]]) # [m[0,0], m[1,1], m[2,0]] = [0 5 8]
# Aby wybrać BLOK (przecięcie wierszy i kolumn) -> broadcasting / np.ix_
rows = np.array([0, 2])
cols = np.array([1, 3])
print(m[rows[:, None], cols]) # podmacierz 2x2
print(m[np.ix_(rows, cols)]) # to samo, czytelniej
# KLUCZOWE: fancy indexing zwraca KOPIĘ
c = a[[1, 2, 3]]
c[0] = 999
print(a[1]) # 11 - oryginał NIEzmieniony (to była kopia)
Materiały
Czym jest indeksowanie boolowskie (maska logiczna) i do czego się je wykorzystuje?
Odpowiedź w 30 sekund:
Indeksowanie boolowskie polega na przekazaniu jako indeksu tablicy wartości True/False o tym samym kształcie co dane — NumPy zwraca elementy odpowiadające pozycjom True. Służy głównie do filtrowania danych według warunku (np. a[a > 0]) i — podobnie jak fancy indexing — zwraca kopię wybranych elementów jako tablicę 1D.
Odpowiedź w 2 minuty:
Maska logiczna to tablica typu bool, najczęściej powstała z porównania wektorowego, np. a > 5, (a > 0) & (a < 10) czy np.isnan(a). Gdy użyjemy jej jako indeksu a[mask], NumPy zwraca wszystkie elementy, dla których maska ma True, spłaszczone do tablicy 1D (w kolejności C, czyli wierszami). Maska musi mieć kształt zgodny z indeksowaną osią (lub całą tablicą). To najbardziej idiomatyczny sposób filtrowania danych w NumPy — zastępuje pętle i instrukcje warunkowe wektorową operacją wykonywaną w C.
Przy łączeniu warunków trzeba używać operatorów bitowych &, |, ~ (a nie słów and, or, not, które działają na całych tablicach i rzucają błąd „truth value of an array is ambiguous"). Każdy warunek należy otoczyć nawiasami z powodu priorytetu operatorów: (a > 0) & (a < 10). Maska jest też niezwykle przydatna w przypisaniach warunkowych: a[a < 0] = 0 zeruje wszystkie ujemne elementy na miejscu, a np.where(mask, x, y) wybiera między dwiema tablicami w zależności od warunku.
Pod względem pamięci indeksowanie boolowskie należy do indeksowania zaawansowanego, więc przy odczycie a[mask] zwraca kopię — modyfikacja wyniku nie zmienia oryginału. Natomiast przypisanie do maski (a[mask] = wartość) działa na oryginale na miejscu. Powiązane narzędzia to np.nonzero(mask) (zwraca indeksy elementów True), mask.sum() (liczy spełniające warunek), a także np.any / np.all do sprawdzania, czy którykolwiek/wszystkie elementy spełniają warunek. To podstawa czyszczenia danych, wykrywania wartości odstających i selekcji w analizie numerycznej.
Przykład kodu:
import numpy as np
a = np.array([-3, 5, -1, 8, 0, 12, -7])
# Maska z porównania wektorowego
mask = a > 0
print(mask) # [False True False True False True False]
print(a[mask]) # [ 5 8 12] - tylko dodatnie, jako tablica 1D
# Łączenie warunków: operatory bitowe & | ~, KAŻDY w nawiasach
print(a[(a > 0) & (a < 10)]) # [5 8] - dodatnie i mniejsze od 10
# Przypisanie warunkowe NA MIEJSCU (modyfikuje oryginał)
a[a < 0] = 0
print(a) # [ 0 5 0 8 0 12 0]
# np.where - wybór między dwiema wartościami zależnie od warunku
b = np.array([1, -2, 3, -4])
print(np.where(b > 0, b, 0)) # [1 0 3 0] - ujemne zamienione na 0
# KLUCZOWE: odczyt maską zwraca KOPIĘ
c = a[a > 0]
c[0] = 999
print(a) # oryginał bez zmian - to była kopia
Materiały
Jaka jest różnica między widokiem (view) a kopią (copy) tablicy i dlaczego jest istotna?
Odpowiedź w 30 sekund: Widok (view) to nowy obiekt tablicy, który współdzieli ten sam bufor danych z oryginałem — zmiana danych przez widok zmienia oryginał i odwrotnie. Kopia (copy) ma własny, niezależny bufor — modyfikacje nie wpływają na źródło. Rozróżnienie jest istotne dla poprawności (uniknięcie przypadkowych mutacji) oraz wydajności (widok jest darmowy, kopia alokuje pamięć).
Odpowiedź w 2 minuty:
Tablica NumPy składa się z dwóch części: metadanych (kształt, typ, kroki/strides, przesunięcie) oraz właściwego bufora danych w pamięci. Widok to osobny obiekt ndarray z własnymi metadanymi, ale wskazujący na ten sam bufor co oryginał. Tworzą go m.in. podstawowy slicing, a.reshape(...) (gdy to możliwe), a.T, a.ravel() oraz jawne a.view(). Ponieważ dane są współdzielone, operacja jest praktycznie natychmiastowa i nie zużywa dodatkowej pamięci — ale każda modyfikacja elementów widoku jest widoczna w oryginale.
Kopia ma własny, niezależny bufor danych. Powstaje przy fancy indexingu, indeksowaniu boolowskim, większości operacji arytmetycznych (a + 1), a.flatten() oraz przy jawnym a.copy(). Modyfikacje kopii nie mają żadnego wpływu na oryginał. Kosztem jest alokacja pamięci i czas potrzebny na przekopiowanie danych.
Dlaczego to istotne? Po pierwsze — poprawność: programista, który sądzi, że pracuje na izolowanej tablicy, może przypadkiem nadpisać oryginalne dane, bo dostał widok. Po drugie — wydajność: w przetwarzaniu dużych macierzy zbędne kopie potrafią zwielokrotnić zużycie pamięci i spowolnić kod, więc czasem celowo chcemy widok. Po trzecie — istnieje pułapka znana z biblioteki pandas (SettingWithCopyWarning), która ma to samo źródło: niejasność, czy operacja zwróciła widok, czy kopię. Świadome zarządzanie tym rozróżnieniem to cecha dojrzałego kodu numerycznego: używamy .copy(), gdy potrzebujemy izolacji, i polegamy na widokach, gdy zależy nam na wydajności.
Przykład kodu:
import numpy as np
a = np.arange(6)
# WIDOK - współdzieli bufor (podstawowy slicing)
v = a[1:4]
v[0] = 100
print(a) # [ 0 100 2 3 4 5] - oryginał ZMIENIONY
# KOPIA - własny bufor
a = np.arange(6)
c = a[1:4].copy()
c[0] = 100
print(a) # [0 1 2 3 4 5] - oryginał BEZ zmian
Diagram:
flowchart TD
BUF1["wspólny bufor danych w pamięci"]
ORG["tablica oryginalna (metadane)"]
VIEW["widok (metadane)"]
BUF2["osobny bufor danych (kopia)"]
COPY["kopia (metadane)"]
ORG -->|"wskazuje na"| BUF1
VIEW -->|"wskazuje na ten sam"| BUF1
COPY -->|"wskazuje na własny"| BUF2
BUF1 -. "zmiana widzialna w obu" .- VIEW
BUF2 -. "zmiana niewidzialna dla oryginału" .- COPY
Materiały
Jak rozpoznać, czy operacja zwraca widok czy kopię, i jak świadomie wymusić kopię?
Odpowiedź w 30 sekund:
Najpewniejszym sprawdzeniem jest np.shares_memory(a, b) lub np.may_share_memory(a, b) — zwracają, czy dwie tablice współdzielą pamięć. Widok ma też atrybut .base wskazujący tablicę-źródło (kopia ma .base is None). Aby świadomie wymusić niezależny bufor, używamy jawnie metody .copy().
Odpowiedź w 2 minuty:
Ogólna zasada: podstawowy slicing zwraca widok, natomiast fancy indexing i indeksowanie boolowskie zwracają kopię. Operacje zmieniające układ bez ruszania danych (reshape, .T, ravel) zwykle zwracają widok, jeśli to wykonalne bez kopiowania; w przeciwnym razie NumPy po cichu zwróci kopię. Dlatego nie należy zgadywać — warto sprawdzić empirycznie.
Do weryfikacji służą trzy narzędzia. Atrybut b.base dla widoku wskazuje obiekt, z którego dane pochodzą (dla kopii i tablicy „świeżej" jest None). np.shares_memory(a, b) wykonuje dokładne (czasem kosztowne) sprawdzenie, czy bufory się nakładają, a np.may_share_memory(a, b) to szybsza, konserwatywna heurystyka. W praktyce do testów najczęściej używa się np.shares_memory. Dodatkowo flaga b.flags['OWNDATA'] mówi, czy tablica jest właścicielem swojego bufora — widok zwykle ma OWNDATA == False. Trzeba pamiętać, że .base bywa subtelny (np. po kilku kolejnych widokach wskazuje na pierwotny obiekt), więc np.shares_memory jest pewniejszym kryterium relacji „współdzielenia pamięci".
Aby świadomie wymusić kopię, wywołujemy jawnie a.copy() — gwarantuje to nowy, niezależny bufor. Inne sposoby uzyskania kopii to np.array(a) (domyślnie kopiuje), a.flatten() (zawsze kopia, w odróżnieniu od ravel, który zwraca widok gdy to możliwe) czy np.copy(a). W nowszych wersjach NumPy parametr copy w np.array ma trzy stany: True (zawsze kopiuj), False (nigdy nie kopiuj — rzuć błąd, gdy kopia byłaby konieczna) i None (kopiuj tylko, gdy trzeba). Świadome wymuszanie kopii jest typowym zabezpieczeniem, gdy chcemy przekazać dane do funkcji, nie ryzykując, że zmodyfikuje ona oryginał, albo gdy zamierzamy mutować wycinek bez efektów ubocznych na źródle.
Przykład kodu:
import numpy as np
a = np.arange(12).reshape(3, 4)
# 1) Slicing -> WIDOK
v = a[0:2, :]
print(np.shares_memory(a, v)) # True - współdzielą pamięć
print(v.base is a) # True - .base wskazuje oryginał
print(v.flags['OWNDATA']) # False - widok nie posiada danych
# 2) Fancy indexing -> KOPIA
f = a[[0, 1]]
print(np.shares_memory(a, f)) # False
print(f.base) # None - to kopia
# 3) Maska boolowska -> KOPIA
b = a[a > 5]
print(np.shares_memory(a, b)) # False
# 4) Świadome wymuszenie kopii z widoku
v2 = a[0:2, :].copy() # jawne .copy() = niezależny bufor
print(np.shares_memory(a, v2)) # False
v2[0, 0] = 999
print(a[0, 0]) # 0 - oryginał nietknięty
# ravel (widok, gdy można) vs flatten (zawsze kopia)
print(np.shares_memory(a, a.ravel())) # True (gdy ciągła w pamięci)
print(np.shares_memory(a, a.flatten())) # False - zawsze kopia
Diagram:
flowchart TD
START["operacja indeksująca / przekształcająca"]
Q1{"podstawowy slicing, reshape, .T, ravel?"}
Q2{"fancy indexing lub maska boolowska?"}
VIEW["WIDOK - wskazuje wspólny bufor, .base wskazuje oryginał"]
COPY["KOPIA - osobny bufor, .base is None"]
CHECK["weryfikacja: np.shares_memory(a, b)"]
FORCE["wymuszenie kopii: a.copy()"]
START --> Q1
Q1 -->|"tak (gdy bez kopiowania)"| VIEW
Q1 -->|"nie"| Q2
Q2 -->|"tak"| COPY
VIEW --> CHECK
COPY --> CHECK
CHECK -->|"potrzebna izolacja"| FORCE
Materiały
Broadcasting i operacje wektorowe
Czym jest broadcasting i jakie reguły decydują o zgodności kształtów?
Odpowiedź w 30 sekund:
Broadcasting to mechanizm NumPy, który pozwala wykonywać operacje na tablicach o różnych kształtach bez jawnego kopiowania danych. NumPy automatycznie „rozciąga" mniejszą tablicę tak, aby pasowała do większej. Kształty porównuje się wymiar po wymiarze od prawej do lewej: dwa wymiary są zgodne, gdy są równe albo gdy jeden z nich wynosi 1.
Odpowiedź w 2 minuty:
Bez broadcastingu, aby dodać skalar do każdego elementu tablicy albo dodać wektor do każdego wiersza macierzy, trzeba by ręcznie powielać dane. Broadcasting robi to za nas wirtualnie — bez fizycznej alokacji powielonych tablic w pamięci — dzięki czemu kod jest krótki, czytelny i szybki.
NumPy stosuje trzy reguły dopasowania kształtów:
- Wyrównanie od prawej. Kształty obu tablic ustawia się tak, by ich ostatnie (najbardziej prawe) wymiary były wyrównane. Jeśli jedna tablica ma mniej wymiarów, jej kształt jest z lewej uzupełniany jedynkami (brakujący wymiar traktowany jest jak
1). - Zgodność pary wymiarów. Dla każdej pozycji dwa wymiary są zgodne, gdy są równe albo gdy jeden z nich wynosi 1. Wymiar równy
1jest „rozciągany" do rozmiaru drugiego wymiaru. - Wynikowy kształt. Rozmiar wynikowy w każdym wymiarze to maksimum z dwóch porównywanych rozmiarów. Jeśli choć jedna para wymiarów jest niezgodna, NumPy zgłasza
ValueError.
Klasyczny przykład: tablica o kształcie (3,1) w połączeniu z (1,4) daje wynik (3,4) — pierwsza rozciąga się wzdłuż kolumn, druga wzdłuż wierszy.
Poniższy diagram ilustruje proces decyzyjny:
flowchart TD
A["wymiary wyrównane od prawej"] --> B{"czy rozmiary równe?"}
B -- "tak" --> OK["wymiar zgodny"]
B -- "nie" --> C{"czy jeden z nich = 1?"}
C -- "tak" --> D["rozciągnij wymiar = 1"]
D --> OK
C -- "nie" --> ERR["ValueError: niezgodne kształty"]
OK --> E["rozmiar wynikowy = max obu"]
Tabela dopasowania kształtów (3,1) + (1,4):
| Wymiar (od prawej) | Tablica A | Tablica B | Reguła | Wynik |
|---|---|---|---|---|
| ostatni (kolumny) | 1 |
4 |
jeden = 1 → rozciągnięcie | 4 |
| przedostatni (wiersze) | 3 |
1 |
jeden = 1 → rozciągnięcie | 3 |
| kształt wynikowy | (3,1) |
(1,4) |
— | (3,4) |
Przykład kodu:
import numpy as np
# kolumna (3,1) i wiersz (1,4) -> wynik (3,4)
kolumna = np.array([[10], [20], [30]]) # kształt (3,1)
wiersz = np.array([[1, 2, 3, 4]]) # kształt (1,4)
wynik = kolumna + wiersz # broadcasting do (3,4)
print(wynik.shape) # (3, 4)
print(wynik)
# [[11 12 13 14]
# [21 22 23 24]
# [31 32 33 34]]
# skalar jest rozciągany na całą tablicę (kształt () traktowany jak 1)
macierz = np.ones((2, 3))
print(macierz * 5) # każdy element pomnożony przez 5
# wektor (3,) dodany do każdego wiersza macierzy (2,3)
m = np.array([[0, 0, 0], [10, 10, 10]]) # kształt (2,3)
v = np.array([1, 2, 3]) # kształt (3,) -> wyrównane jako (1,3)
print(m + v) # [[ 1 2 3] [11 12 13]]
Materiały
Podaj przykład, kiedy broadcasting zadziała, a kiedy zakończy się błędem.
Odpowiedź w 30 sekund:
Broadcasting zadziała, gdy w każdej parze wymiarów (porównywanych od prawej) rozmiary są równe lub jeden z nich wynosi 1 — np. tablica (3,1) z wektorem (3,). Zakończy się błędem ValueError: operands could not be broadcast together, gdy istnieje para wymiarów o różnych rozmiarach, z których żaden nie jest 1 — np. (3,) z (4,).
Odpowiedź w 2 minuty:
Najprostszy sposób sprawdzenia: wypisz kształty obu tablic jeden pod drugim, wyrównaj je do prawej i przejdź kolumnami. Jeśli w każdej kolumnie liczby są równe albo któraś to 1 (lub kolumna jest pusta dla krótszego kształtu) — broadcasting przejdzie. Jeśli trafisz na kolumnę z dwiema różnymi liczbami większymi od 1 — NumPy zgłosi błąd.
Przypadek działający: (3,1) z (3,). Wektor (3,) jest wyrównany do prawej jako (1,3), więc porównujemy:
- ostatni wymiar:
1vs3→ jeden = 1 → wynik3, - przedostatni:
3vs1→ jeden = 1 → wynik3.
Wynikowy kształt to (3,3) — typowa „tabliczka", np. siatka współrzędnych albo tabliczka mnożenia.
Przypadek błędny: (3,) z (4,). Oba mają jeden wymiar, porównujemy 3 vs 4. Żadne nie jest 1 i nie są równe, więc operacja jest niemożliwa. NumPy zgłasza:
ValueError: operands could not be broadcast together with shapes (3,) (4,)
Częsta pomyłka praktyczna: chęć dodania wektora kolumnowego do wektora wierszowego o innej długości albo dodania macierzy (2,3) do wektora (2,) — ten ostatni jest wyrównywany jako (1,2), więc ostatni wymiar 3 vs 2 jest niezgodny. Rozwiązaniem bywa dodanie nowej osi (v[:, np.newaxis]), aby świadomie nadać tablicy kształt umożliwiający broadcasting.
Przykład kodu:
import numpy as np
# --- PRZYPADEK DZIAŁAJĄCY: (3,1) z (3,) -> (3,3) ---
a = np.array([[1], [2], [3]]) # kształt (3,1)
b = np.array([10, 20, 30]) # kształt (3,) -> wyrównane jako (1,3)
print((a + b).shape) # (3, 3)
print(a + b)
# [[11 21 31]
# [12 22 32]
# [13 23 33]]
# --- PRZYPADEK BŁĘDNY: (3,) z (4,) ---
x = np.array([1, 2, 3]) # kształt (3,)
y = np.array([1, 2, 3, 4]) # kształt (4,)
try:
x + y
except ValueError as e:
print(e)
# operands could not be broadcast together with shapes (3,) (4,)
# --- TYPOWA PUŁAPKA: macierz (2,3) z wektorem (2,) ---
m = np.zeros((2, 3))
v = np.array([1, 2]) # (2,) -> wyrównane jako (1,2), ostatni wymiar 3 vs 2
try:
m + v
except ValueError as e:
print(e) # ...shapes (2,3) (2,)
# poprawka: nadaj wektorowi kształt (2,1), aby pasował do wierszy
print(m + v[:, np.newaxis]) # teraz (2,1) z (2,3) -> (2,3), działa
Materiały
Czym jest wektoryzacja i dlaczego jest preferowana nad jawnymi pętlami w Pythonie?
Odpowiedź w 30 sekund:
Wektoryzacja to wykonywanie operacji na całych tablicach naraz, zamiast iterowania po pojedynczych elementach w pętli Pythona. NumPy deleguje takie operacje do zoptymalizowanych, skompilowanych funkcji w C, które działają na ciągłym bloku pamięci. Efekt to kod krótszy, czytelniejszy i zwykle kilkadziesiąt razy szybszy niż równoważna pętla for.
Odpowiedź w 2 minuty:
Pętla for w Pythonie jest kosztowna z kilku powodów: każdy obrót pętli to interpretacja bajtkodu, dynamiczne sprawdzanie typów oraz „opakowywanie" liczb w pełnoprawne obiekty Pythona (każdy int to obiekt na stercie). Przy milionach elementów ten narzut dominuje czas wykonania.
Wektoryzacja przenosi pętlę „w dół", do warstwy C. Gdy piszesz a + b na tablicach NumPy, interpreter Pythona wywołuje jedną skompilowaną funkcję, która iteruje po surowych, jednorodnie typowanych danych bez narzutu interpretera. Dodatkowo zyskujesz:
- lokalność pamięci — dane leżą w ciągłym buforze, co świetnie współpracuje z pamięcią podręczną procesora (cache),
- instrukcje SIMD — nowoczesne procesory przetwarzają kilka liczb jedną instrukcją (wektorowo), co NumPy potrafi wykorzystać,
- mniej kodu i błędów — operacja zapisana jako wyrażenie matematyczne jest trudniejsza do popsucia niż ręczna pętla z indeksami.
Wektoryzacja to nie tylko arytmetyka — obejmuje maski boolowskie (a[a > 0]), agregacje (a.sum()), funkcje uniwersalne (np.sqrt(a)) i broadcasting. Zasada przewodnia pracy z NumPy brzmi: jeśli piszesz pętlę po elementach tablicy, prawdopodobnie istnieje wektorowy odpowiednik, który jest szybszy i czytelniejszy.
Wyjątek: jeśli każdy krok zależy od poprzedniego w sposób nierozkładalny (silne zależności sekwencyjne, których nie da się wyrazić przez cumsum, cumprod itp.), wektoryzacja może nie być możliwa — wtedy rozważa się narzędzia typu Numba lub Cython.
Przykład kodu:
import numpy as np
rng = np.random.default_rng(0)
a = rng.random(1_000_000)
b = rng.random(1_000_000)
# --- WERSJA Z PĘTLĄ (wolna, dużo narzutu interpretera) ---
def suma_petla(a, b):
wynik = np.empty_like(a)
for i in range(a.size): # milion obrotów pętli w Pythonie
wynik[i] = a[i] + b[i]
return wynik
# --- WERSJA WEKTOROWA (jedno wywołanie skompilowanego kodu C) ---
def suma_wektorowo(a, b):
return a + b # cała tablica naraz
# porównanie czasu — wersja wektorowa jest zwykle dziesiątki razy szybsza
import timeit
t1 = timeit.timeit(lambda: suma_petla(a, b), number=1)
t2 = timeit.timeit(lambda: suma_wektorowo(a, b), number=1)
print(f"pętla: {t1:.4f} s")
print(f"wektorowo: {t2:.4f} s")
print(f"przyspieszenie: ~{t1 / t2:.0f}x")
# wektoryzacja obejmuje też maski i funkcje matematyczne
dane = rng.normal(size=1_000_000)
dodatnie = dane[dane > 0] # maska boolowska zamiast pętli z if
pierwiastki = np.sqrt(np.abs(dane)) # ufunc na całej tablicy
Materiały
Czym są funkcje uniwersalne (ufunc) i co je wyróżnia?
Odpowiedź w 30 sekund:
Funkcja uniwersalna (universal function, ufunc) to funkcja NumPy działająca element po elemencie na tablicach, zaimplementowana w skompilowanym C. Przykłady to np.add, np.sqrt, np.exp, np.maximum. Ufunc obsługują broadcasting, kontrolę typów (rzutowanie), tablicę wynikową przez parametr out, warunkowe stosowanie przez where, a także udostępniają metody takie jak .reduce, .accumulate i .outer.
Odpowiedź w 2 minuty:
Ufunc to fundament wektoryzacji w NumPy. Operatory takie jak +, *, < są w istocie cienkimi opakowaniami na ufunc (np.add, np.multiply, np.less). To, co je wyróżnia, to bogaty, jednolity interfejs:
- Operacje element-wise w C — pętla po elementach wykonuje się w skompilowanym kodzie, bez narzutu interpretera Pythona.
- Broadcasting — ufunc automatycznie dopasowują kształty argumentów według reguł broadcastingu.
- Parametr
out— pozwala zapisać wynik do istniejącej tablicy, oszczędzając alokację pamięci (np.np.add(a, b, out=a)modyfikujeaw miejscu). Ważne przy dużych tablicach. - Parametr
where— maska boolowska decydująca, na których elementach operacja ma być wykonana; pozostałe pozycje woutpozostają nietknięte (np. dzielenie tylko tam, gdzie mianownik jest niezerowy). - Kontrola typów (
casting,dtype) — ufunc znają zasady rzutowania i potrafią wymusić docelowy typ wyniku.
Najmocniejszą cechą są metody dołączone do każdej ufunc binarnej:
.reduce— redukuje tablicę wzdłuż osi, składając elementy operatorem (np.np.add.reduce(a)to po prostu suma,np.maximum.reduce(a)to maksimum)..accumulate— zwraca częściowe wyniki redukcji (np.np.add.accumulate(a)to suma skumulowana, odpowiedniknp.cumsum)..outer— stosuje operację do każdej pary elementów dwóch tablic, tworząc tablicę „iloczynu zewnętrznego" (np.np.multiply.outer(a, b)to tabliczka mnożenia)..reduceati.at— redukcje segmentowe oraz operacje w miejscu na wybranych indeksach (w tym z poprawną obsługą powtarzających się indeksów).
Dzięki temu jednolitemu interfejsowi można pisać bardzo zwięzły, wydajny kod numeryczny, a w razie potrzeby tworzyć własne ufunc z funkcji skalarnych za pomocą np.frompyfunc lub np.vectorize (uwaga: te ostatnie są wygodne, ale nie dają pełnej wydajności natywnego C).
Przykład kodu:
import numpy as np
a = np.array([1.0, 4.0, 9.0, 16.0])
# podstawowe użycie element-wise
print(np.sqrt(a)) # [1. 2. 3. 4.]
print(np.add(a, 100)) # broadcasting skalara
# parametr out: zapis wyniku w istniejącej tablicy (bez nowej alokacji)
bufor = np.empty_like(a)
np.multiply(a, 2, out=bufor)
print(bufor) # [ 2. 8. 18. 32.]
# parametr where: dzielimy tylko tam, gdzie mianownik != 0
licznik = np.array([10.0, 20.0, 30.0])
mianownik = np.array([2.0, 0.0, 5.0])
wynik = np.zeros_like(licznik)
np.divide(licznik, mianownik, out=wynik, where=mianownik != 0)
print(wynik) # [5. 0. 6.] (środkowy pominięty, zostaje 0)
# metody ufunc
x = np.array([1, 2, 3, 4])
print(np.add.reduce(x)) # 10 (suma)
print(np.add.accumulate(x)) # [ 1 3 6 10] (suma skumulowana)
print(np.multiply.outer(x, x))
# tabliczka mnożenia 4x4 jako iloczyn zewnętrzny
Materiały
*) od iloczynu macierzowego (@ / np.dot)?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziAgregacje, osie i manipulacja kształtem
sum, mean, min, max) i co oznacza parametr axis?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziaxis=0 i axis=1 dla tablicy dwuwymiarowej?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedzireshape, ravel/flatten oraz transpose?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziconcatenate, vstack, hstack i stack?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedzinp.newaxis / expand_dims oraz squeeze?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziWydajność, pamięć i typowe pułapki
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedzinp.nan) i dlaczego np.nan == np.nan zwraca False?
Odpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedziOdpowiedź dostępna w pełnej wersji. Odblokuj 12 pozostałych odpowiedzi i ucz się z pełnego zestawu.
Odblokuj odpowiedzi