Laboratorium Statystycznej Eksploracji Danych


LABORATORIUM 0


Wprowadzenie do języka Python

  • 1. Cel wprowadzenia
  • 2. Środowisko
  • 3. Kalkulator
  • 4. Przypisanie
  • 5. Typy zmiennych
    • 5.1 Zmienne całkowite i zmiennoprzecinkowe
    • 5.2 Zmienne logiczne
    • 5.3 Zmienne tekstowe
  • 6. Struktury
    • 6.1 Listy
    • 6.2 Zakresy (range)
    • 6.3 Krotki
    • 6.4 Zbiory
    • 6.5 Słowniki
  • 7. Instrukcje sterujące
    • 7.1 Pętla for
    • 7.2 Pętla while
    • 7.3 Instrukcja warunkowa
  • 8. Funkcje
    • 8.1 Funkcja anonimowa (lambda)
  • 9. Funkcje map() i filter() oraz listy składane (list comprehensions)
    • 9.1 Funkcja map()
    • 9.2 Funkcja filter()
    • 9.3 List comprehensions
  • 10. Wykresy
  • 11. Moduł numpy
    • 11.1 Wektory
    • 11.2 Macierze
    • 11.3 Ufunc i wykonywanie operacji element po elemencie, wektoryzacja
    • 11.4 Funkcje modułu numpy
    • 11.5 Podmofuł random
    • 11.6 Indeksowanie logiczne
  • 12. Pakiet pandas
    • 12.1 Szeregi
    • 12.2 Ramki danych
    • 12.3 Odwoływanie się do ramki danych
    • 12.4 Tworzenie masek
    • 12.5 Tworzenie podsumowań i raportów
  • 13. Pakiet seaborn
  • 14. Dodatkowe uwagi
    • 14.1 Użycie operatorów dodawania oraz mnożenia w przypadku list i zmiennych tekstowych
    • 14.2 Tworzenie kopii list zagnieżdżonych oraz kopii macierzy w numpy
    • 14.3 Funkcje anonimowe w tworzeniu funkcji funkcji
    • 14.4 Wektoryzacja funkcji w numpy

1. Cel wprowadzenia ¶

Ten krótki kurs nie ma, bynajmniej, na celu nauki języka Python, tzn. systematycznego wprowadzenia do języka, począwszy od podstaw, a skończywszy na zaawansowanych tematach. Kurs ma raczej pokazać tematykę przydatną do przedmiotu Laboratorium Statystycznej Eksploracji danych.

Z tego też powodu dla osób znających język Python to wprowadzenie może być dość dziwne, gdyż część tematów została potraktowana w najlepszym wypadku powierzchownie. W szczególności nie będziemy zastanawiać się szczegółowo nad zbiorami, słownikami i krotkami, lub też kompletnie nie ruszymu kwestii programowania obiektowego.

Podstawowe rzeczy, na których nam zależy to:

  • typy zmiennych w Pythonie oraz instrukcje sterujące,
  • listy, odwoływanie się do nich i ich tworzenie (w szczególności za pomocą list składanych - list comprehensions)
  • tworzenie najprostych wykresów za pomocą pakietu matplotlib,
  • pakiet numpy, czyli łatwe tworzenie macierzy oraz praca na nich,
  • pakiet **pandas",
  • pakiet seaborn

Milczące założenie przy tym kursie jest takie, że równamy do osób, które nigdy nie miały styczności z pythonem.

2. Środowisko ¶

Do pracy potrzebujemy:

  • środowiska Python 3.8 lub wyżej,
  • środowiska Jupyter,
  • na pewno bibliotek numpy, pandas, seaborn, scikit-learn

przy czym oczywiście Jupyter nie jest obowiązkowy, na dobrą sprawę mogą Panstwo korzystać z notatnika i linii komend albo jakigoś bardziej wysublimowanego edytora.

Zakładam, że jeśli ktoś korzysta z linuksa, to albo już jest zanzajomiony z pythonem albo sobie z tym bez problemu poradzi (szczególnie, że python jest zwykle domyślnie instalowany w dystrybucji). Jeśli chodzi o Win10 oraz Win11, to chyba najprostsza opcja jest taka:

  • ściągnąć i zainstalowac najnowszą wersję Pythona dla Win (np. za pomocą tego tutoriala, kroki 1-3),
  • worzyć linię komend i wpisać "pip install jupyter", co zainstaluje jupytera,
  • instalować potrzebne pakiety za pomocą "pip install numpy pandas seaborn scikit-learn", choć na pewno część z nich (np. numpy czy pandas) jest instalowana wraz z jupyterem

Oczywiście, można zainstalować zamiast tego anacondę lub i ogólnie jupyterlab. W każdym razie po tych krokach wpisanie "jupyter notebook" w lini komend lub power shellu powinno dać efekt w postaci odpalenia okna przeglądarki, w której pojawi się strona związana z jupyterem.

3. Kalkulator ¶

Nasz notatnik może zostać potraktowany jak wielki kalkulator. W tym celu musimy jedynie wpisać do komórki instrukcję taką jak

1 + 1

i zatwierdzić ją kombinacją SHIFT+ENTER

In [1]:
1 + 1
Out[1]:
2

Żeby kalkulator miał głębszy sens, potrzebne są funkcje matematyczne. Te akurat nie są domyślnie uruchamiane w Pythonie - potrzebujemy do tego odrębnej biblioteki, które noszą tu nazwę "modułów". Uruchamianie modułów może odbywać się na kilka sposobów (UWAGA! można to przeczytać później):

  • import nazwa_modułu - importujemy cały moduł i następnie odwołujemy się do jego funkcji poprzez nazwa_modułu.nazwa_funkcji
  • import nazwa_modułu as inna_nazwa - działa tak samo jak powyżej (czyli odwołujemy się jako inna_nazwa.nazwa_funkcji, ale jest stosowane zwykle wtedy, gdy nazwa modułu jest bardzo długa
  • from nazwa_modułu import nazwa_funkcji - w ten sposób nie musimy podawać już nazwy modułu, po prostu używamy wszędzie nazwa_funkcji(),
  • from nazwa_modułu import * - importujemy wszystkie funkcje z modułu - nie jest to uznawane za najlepszy sposób w przypadku długich kodów, ciężko się wtedy zorientować, czy dana funkcja została definiowana przez użytkownika, czy pochodzi z modułu.

W naszym przypadku skorzystamy z modułu math, za pomoca pierwszej opcji; beziemy mogli się dzięki temu odwoływac do takich funkcji jak math.sin() czy math.exp():

In [2]:
import math
In [3]:
math.sin(10) + math.cos(10)
Out[3]:
-1.383092639965822

Warto przy tym wiedzieć, że w module są też popularne stałe np. math.pi, jak również math.inf (czyli nieskończoność), zauważając, że ta ostatnia działa w wyrażeniach artymetycznych na zasadzie granicy, tzn:

In [4]:
math.exp(-math.inf)
Out[4]:
0.0

gdyż $\lim_\limits{x \rightarrow \infty} \mathbf{e}^{-x} = 0$

4. Przypisanie¶

Jedną z najważnijszych instrukcji jest przypisanie, czyli ogólnie zrecz mówiąc nadanie zmiennej wartości. W języku Python (w odróżnieniu od np. języka C) nie deklarujemy uprzednio typu zmiennej - jest on narzucany poprzez jej przypisanie. Wpisanie nazwy zmiennej zatwierdzenie SHIFT+ENTER wypisuje jej zawartość. Przy okazji możemy się przekonać, że pojedyncza komórka może pomiescić więcej niż jedną linię kodu - do kolejnej linii przechodzimy za pomocą ENTER i dopiero całość zatwierdzamy SHIFT+ENTER:

In [5]:
x = 1
In [6]:
x
Out[6]:
1
In [7]:
x = 128
x
Out[7]:
128

Wypisywanie zawartości zmiennej poprzez jej podanie jest jednym sposobem; innym jest użycie funkcji print(), w której po przecinkach możemy podać kolejne zmienne, wypisując je jednocześnie. Możemy też jednocześnie przypisać kilka zmiennych, podając je po przecinku po lewej stronie przypisania, a po prawej, również po przecinku, podać wartości:

In [8]:
x, y = 2, 128
print(x, y)
2 128

5. Typy zmiennych ¶

Na naszych zajęciach będziemy wykorzystywać zmienne całkowie (int), zmiennoprzecinkowe (float) oraz logiczne (bool). Dodatkowo często korzysta sie także z typu tekstowego (str).

5.1 Zmienne całkowite i zmiennoprzecinkowe ¶

Generalnie kwestia, czy liczba jest całkowita czy zmiennoprzecinkowa nie będzie raczej spędzała nam snu z powiek - szczególnie, że konwersja pomiędzy tymi typami odbywa się automatycznie, np. jeśli podzielimy dwie liczby całkowite przez siebie, to otrzymamy liczbę zmiennoprzecinkową. Sprawdzimy to poniżej wykorzystując funkcję type():

In [9]:
x, y = 1, 2
z = x / y

print(x, y, type(x), type(y))

print(z, type(z))
1 2 <class 'int'> <class 'int'>
0.5 <class 'float'>

Oprócz standardowych operacji (+, -, *, /) można również skorzystać z operatora potęgowania (**) a także dzielenia bez reszty (//) czy też reszty z dzielenia (%):

In [10]:
2 ** 3
Out[10]:
8
In [11]:
10 // 3
Out[11]:
3
In [12]:
10 % 3
Out[12]:
1

Wyjście funkcji print() możemy modyfikować używając modyfikatorów:

In [13]:
print(f'To jest liczba z z dokładnością do 2 miejsc po przecinku {z:1.2f}, a tak wygląda w notacji naukowej {z:1.2E}')
To jest liczba z z dokładnością do 2 miejsc po przecinku 0.50, a tak wygląda w notacji naukowej 5.00E-01

5.2 Zmienne logiczne ¶

Wartości logiczne są reprezntowane przez dwie stałe True i False. Możemy je oczywiście po prostu przypisać do zmiennych (i sprawdzić, czy zmienna jest typu logicznego):

In [14]:
x = True
type(x)
Out[14]:
bool

natomiast najczęsciej będą one efektem sprawdzania warunków (patrz dalej). Na zmiennych logicznych możemy wykonywac operacje negacji (not), koniunkcji (and) czy alternatywy (or):

In [15]:
x, y = True, False
print(not x)
print(x or y)
print(x and y)
False
True
False

Należy odróżnić operator not od wykrzyknika wykorzystywanego przy sprawdzaniu nierówności, tj. jako wyrażenie != (nie równa się):

In [16]:
10 != 9
Out[16]:
True

5.3 Zmienne tekstowe ¶

Ostatnim typem są zmienne tekstowe (będące rodzajem sekwencji, patrz poniżej); w Pythonie nie ma rozróżnienia pomiędzy pojedynczym znakiem i łańcuchem, składającym się z wielu znaków:

In [17]:
x, y = "a", 'napis'

print(type(x), type(y))
<class 'str'> <class 'str'>

6. Struktury danych ¶

Python posiada dość szeroką gamę struktur danych, w których łatwo się na początku pogubić. Ogólnie, na potrzeby zajęć, możemy je podzielić na:

  • sekwencje: listy (lists), krotki (tuple, tuples), zakresy (ciągi, range); do tej części należą także zmienne tekstowe (strings), które są sekwencjami znaków,
  • zbiory (sets),
  • słowniki (dictionaries).

Większość czasu poświęcimy na omówienie list, gdyż właśnie te obiekty będą dla najbardziej przydatne podczas zajęć.

6.1 Listy ¶

Stosując odpowiedniki z języka C, możemy powiedzieć, że jest to tablica (również wielowymiarowa) wyposażona w pewne możliwości listy jednokierunkowej z języka C.

Lista to uprządkowana struktura danych (czyli kolejność jej elemntów ma znaczenie). Jest ona modyfikowalna i, co może być dość zaskakujące, nie musi zawierać elementów tego samego typu.

Z punktu widzenia zapisu jesto lista wartości (elementów) rozdzielonych przecinkami ujęta w nawiasy kwadratowe.

Listę możemy zainicjować podając jej elementy "z palca", np:

In [18]:
l = [3, -1, 8, 10, 2, -2.8]
l
Out[18]:
[3, -1, 8, 10, 2, -2.8]

Elementy listy są indeksowane od zera, a indeksy podajemy w nawiasach kwadratowych:

In [19]:
l[0]
Out[19]:
3

Nie jesteśmy ograniczeni do podawania pojedynczych indeksów - możemy użyć operatora wycinania (wykrawania, slice) czyli dwukropka : lub dwukropków :: aby określić przedział indeksów, np. x[0:2] oznacza, że chcemy wypisać dwa pierwsze elementy listy, a x[2:8:2] wypisuje elemnty listy pod indeksami 2, 4 i 6:

In [20]:
l[0:2]
Out[20]:
[3, -1]
In [21]:
l[1:5:2]
Out[21]:
[-1, 10]

Brak indeksu po lewej stronie oznacza to samo co 0, a po prawej - co koniec listy. W efekcie zapis x[:] to po prostu cała lista, a np. x[::2], to co drugi element listy:

In [22]:
l[:3]
Out[22]:
[3, -1, 8]
In [23]:
l[4:]
Out[23]:
[2, -2.8]
In [24]:
l[:]
Out[24]:
[3, -1, 8, 10, 2, -2.8]
In [25]:
l[::2]
Out[25]:
[3, 8, 2]

Listy możemy również indeksować od tyłu, podając znak minus, tzn. x[-2] oznacza, że wypisujemy drugi od końca element listy x. Oczywiście, możemy jednocześnie używać indeksów dodatnich i ujemnych:

In [26]:
l[-2]
Out[26]:
2
In [27]:
l[1:-2]
Out[27]:
[-1, 8, 10]

Tak jak została to na wstepie wspomniane, listy nie muszą być obiektem jednorodnym (choć zwykle są):

In [28]:
l1 = [True, 1, "abc", 2, -128.567]
l1
Out[28]:
[True, 1, 'abc', 2, -128.567]

6.1.1 Zmiana zawartości listy¶

Podając konkretne indeksy, jesteśmy w stanie podmienić aktualną zawartość listy, np:

In [29]:
x = [1, 2, 3]
x[1] = 5
x
Out[29]:
[1, 5, 3]

Możemy przy tym korzystać z operatora slice, aby od razu zmienić całą część listy:

In [30]:
x = [1, 2, 3, 4, 5]
x[1:3] = [128, 129]
x
Out[30]:
[1, 128, 129, 4, 5]

6.1.2 Metody związane z listami¶

Oprócz korzystania z list podobnie jak z tablic w języku C, możemy używac także funkcje bardziej kojarzących się z listami jednokierunkowymi. Funkcje te zapisywane są w postaci metod tzn nazwa_listy.funkcja(argument) i daja np. możliwość dodania elementu na koniec listy nazwa_listy.append(nowy_element), wstawienia elementu w konkretnym miejscu w liście nazwa_listy.insert(indeks, nowy_element) czy usunięcia ostatniego (lub dowlonego) elementu listy nazwa_listy.pop(indeks):

In [31]:
x = [10, 1, 20, 30, 22]
x.append(18)
x
Out[31]:
[10, 1, 20, 30, 22, 18]
In [32]:
x.insert(2, 333)
x
Out[32]:
[10, 1, 333, 20, 30, 22, 18]
In [33]:
x.pop()
Out[33]:
18
In [34]:
x
Out[34]:
[10, 1, 333, 20, 30, 22]
In [35]:
x.pop(4)
Out[35]:
30

6.1.3 Kopiowanie list¶

W wielu przypadkach potrzebujemy stworzyć kopię danej listy i dalej na niej pracować, zachowując oryginalną listę nietkniętną. Wykonanie operacji przypisania a=b na poziomie list tworzy połączenie pomiedzy tymi obiektami, tzn każda zmiana elementów będzie widoczna w obu listach:

In [36]:
a = [1, 2, 3]
b = a
print(a, b)
[1, 2, 3] [1, 2, 3]
In [37]:
a[0] = -100
b[2] = 128
print(a, b)
[-100, 2, 128] [-100, 2, 128]

Należy przy tym pamiętać, że jeśli stworzymy listę na nowo, to połączenie zostanie zerwane:

In [38]:
b = [1, 2, 3]
print(a, b)
[-100, 2, 128] [1, 2, 3]

Aby otrzymać kopię zgodną z naszymi pierwotnymi założeniami (tzn. niezależną od oryginału) należy skorzystać z metody copy():

In [39]:
a = [1, 2, 3]
b = a.copy()
a[0] = 128
print(a, b)
[128, 2, 3] [1, 2, 3]

Uwaga! Dla list zagnieżdżonych (patrz poniżej) sytuacja się komplikuje.

6.1.4 Listy zagnieżdżone¶

Częściowo można je traktować jak tablice wielowymiarowe w języku C. Odwołujemy się do nich podobnie, pierwszy nawias kwadratowy odnosi się do pierwszego poziomu zagnieżdżenia, drugi do drugiego etc:

In [40]:
A = [[1, 2, 3], [4, 5, 6, [7, 8]]]
A
Out[40]:
[[1, 2, 3], [4, 5, 6, [7, 8]]]
In [41]:
print(A[0], A[1])
[1, 2, 3] [4, 5, 6, [7, 8]]
In [42]:
print(A[1][3], A[1][3][0])
[7, 8] 7

6.2 Zakresy (range) ¶

Zakresy, realizowane poprzez funkcję range() są bardzo przydatnymi obiektami, gdyż, jak sama nazwa wskazuje, umożliwiają stworzenie ciągu liczb (będziemy później z nich korzystać przy wykonywaniu pętli). Ogólna idea jest taka, że range() tworzy sekwencje - nie jest to lista sensu stricto - ale możemy się do niej odwoływać podobnie jak do listy, z tą różnicą, że nie jest ona modyfikowalna. Wywołanie funkcji range(10) tworzy sekwencję liczb od 0 do 9, natomiast range(-5, 5, 2) stworzy ciąg liczb od -5 do 5 z krokiem 2:

In [43]:
s = range(10)
s
Out[43]:
range(0, 10)
In [44]:
s[5]
Out[44]:
5

Jeśli tego potrzebujemy, możemy zrzutować obiekt typu range na listę:

In [45]:
list(range(0, 10))
Out[45]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

6.3 Krotki (tuple) ¶

Krotki są w zasadzie bardzo podobne do list - to, co je wydanie odróżnia, to fakt, że są niezmienne (niemodyfikowalne). Zapisujemy korzystając z nawiasów okrągłych (w opozycji do listy) lub po prostu wymieniając elementy po przecinku. Elementy nie muszą być tego samego typu, a krotki, podobnie jak listy, można zagnieżdżać:

In [46]:
x = (0, 1, 4)
x
Out[46]:
(0, 1, 4)
In [47]:
y = 1, 8, "jem", (2, 3)
y
Out[47]:
(1, 8, 'jem', (2, 3))
In [48]:
y[3][0]
Out[48]:
2

6.4 Zbiory ¶

Zbiory są "nieuporządkowanymi kolekcjami unikatowych elementów". Oznacza to tyle, że (1) tej struktury nie można indeksować, (2) w zbiorze nie mogą się znaleźć dwa takie same elementy. Zbioty formalnie są niezmienne pod tym względem, że nie mozemy podmienić żadnego elementu (za pomocą np. przypisania), z drugiej jednak strony możemy do zbioru dołożyć nowe rzeczy lub też je stąd usunać:

In [49]:
s1 = {127, True, "ab", 127}
s1
Out[49]:
{127, True, 'ab'}
In [50]:
s1.add("efg")
s1
Out[50]:
{127, True, 'ab', 'efg'}
In [51]:
s1.discard(True)
s1
Out[51]:
{127, 'ab', 'efg'}

6.5 Słowniki ¶

Słowniki, zwane czasem tablicami asocjacyjnymi, można rozumieć jako zbiór par klucz-wartośc, przy czym klucze powinny być unikalne oraz niezmienne (czyli mogą nimi być liczby, znaki, wartości logiczne, krotki czy zakresy) natomiast wartości już nie. Słowniki tworzymy za pomocą nawiasów klamrowych i odwołujemy się jak do pojedynczych elementów listy, uzywając kluczy:

In [52]:
s1 = {'a': 1, 'b': 2, 'c': 3}
s1
Out[52]:
{'a': 1, 'b': 2, 'c': 3}
In [53]:
s1['c']
Out[53]:
3

Generalnie slownik może przechowywać zupełnie różne elementy:

In [54]:
s = {'a': [1, 2, [3]], 77.88: 2, True: 'jedzenie', range(5): False, (0,0,1): 8}
s
Out[54]:
{'a': [1, 2, [3]],
 77.88: 2,
 True: 'jedzenie',
 range(0, 5): False,
 (0, 0, 1): 8}
In [55]:
s[(0,0,1)]
Out[55]:
8
In [56]:
s[range(5)]
Out[56]:
False

7. Instrukcje sterujące (flow control): pętle, warunki, funkcje ¶

Ciężko sobie wyobrazić język bez instrukcji sterujących, aczkolwiek zminimalizowanie ich wykorzystania jest możliwe, jak się wkrótce przekonamy. W szczególności zwykle potrzebujmy następujących konstrukcji:

  • pętli - najczęściej for oraz while,
  • instrukcji warunkowych if else,
  • funkcji.

Opis warto zacząć od tego, że w Pythonie aby wskazać blok intrukcji używamy dwukropka oraz wcięcia - nie ma do intrukcji zamykających blok ani też klamer. Pętle, instrukcje warunkowe czy funkcje mają tę samą strukturę: zaczynają się od słowa kluczowego, następnie pojawia się warunek lub nazwa, następnie dwukropek, po którym musimy zrobić wcięcie (tabulatorem) i tam wstawić ciało pętli, warunku czy funkcji.

7.1 Pętla for ¶

Pętle for różnią się od typowych konstrukcji np. w języku C - wykonujemy ją przechodząc element po elemencie sekwencji lub zbioru. Najłatwiej odwołac się do dopiero co poznanych zakresów:

In [57]:
for i in range(10):
    print(i)
0
1
2
3
4
5
6
7
8
9

W ten sam sposób możemy wypisać elementy dowolonej listy:

In [58]:
x = [2, 4, 128, -5]

for i in x:
    print(i)
2
4
128
-5

Oczywiście moglibyśmy zrobić to bardziej "a la C", tzn np. wkorzystać funkcję len() to wyznaczenia długości listy, a następnie odwołać się do indeksów:

In [59]:
for i in range(len(x)):
    print(i, x[i])
0 2
1 4
2 128
3 -5

ale jest to uznawane za bardzo brzydki i "niepythonowy" sposób. Jeśli istotnie potrzebujemy się dostać zarówno do wartości jak i do indeksu, możemy wykorzystać funkcję enumerate(), która stworzy krotkę (czyli niemodyfikowalną listę), zawierającą obie te rzeczy. Możemy ją rozpakować do dwóch zmiennych, otrzymując indeks i wartość:

In [60]:
for indeks, wartosc in enumerate(x):
    print(indeks, wartosc)
0 2
1 4
2 128
3 -5

W przypadku słownika możemy skorzystać z funkcji items(), przechodząc po jego elemntach:

In [61]:
s = {'a': [1, 2, [3]], 77.88: 2, True: 'jedzenie', range(5): False, (0,0,1): 8}

for i, v in s.items():
    print(i, v)
a [1, 2, [3]]
77.88 2
True jedzenie
range(0, 5) False
(0, 0, 1) 8
In [ ]:
 

7.2 Pętla while ¶

Pętle while dużo bardziej przypominają konstrukcję w C - podajemy warunek, który musi być prawdą, aby pętla się wykonywała. Może to być np. typowa inkrementacja (przy okazji, w Pythonie mamy operatory +=, -= etc natomiast NIE MA operatorów ++ czy --):

In [62]:
x = 0

while x < 5:
    x += 1

x
Out[62]:
5

W Pythonie nie ma pętli do...while. Oczywiście, można ją bez problemu "emulować", ustawiając taki warunek, żeby wykonała się co najmniej raz (co jest ideą pętli do...while).

7.3 Instrukcja warunkowa ¶

Instrukcja warunkowa zaczyna się od słowa kluczowego if, po którem następuje warunek i dwukropek, a następnie (we wcięciu) to, co ma zostać wykonane, w przypadku, gdy warunek okaże się prawdziwy. Jeśli chcemy umieścić kolejny warunek, korzystamy z elif, i wreszcie, aby zaznaczyć pozostałe przypadki, używamy else. Poniższy przykłąd wykorzystuje funkcję input, która wczytuje dane wejściowe - ponieważ na wyjściu otrzymujemy obiekt tekstowy, musimy go jeszcze zrzutowac na liczbę całkowitą (UWAGA! ten kod nie jest dobrze napisany, gdyż "wywali" się, jeśli podamy coś innego niż liczbę całkowitą. Oczywiście, można to zrobić "dobrze", ale wymaga to zastosowania wyjątków etc):

In [63]:
x = int(input())

if x < 0:
    print("To liczba ujemna!")
elif x == 0:
    print("Wpisałaś/eś zero!")
else:
    print("Logicznie rozumując, x jest dodatnie!")
Wpisałaś/eś zero!

W Pythonie mamy także operator trójargumentowy (warunkowy, ternary operator), który ma postać wartość_gdy_prawda if warunek else wartość_gdy_fałsz, przy czym bez problemu możemy przypisac efekt działąnia tego opertaora do zmiennej:

In [64]:
x = 5
x = 1 if x >= 0 else -1

y = -5
y = 1 if y >= 0 else -1

print(x, y)
1 -1

8. Funkcje ¶

Ostatni przykład to dobra ilustracja po co są nam potrzebne funkcje - w powyższym przypadku dwa razy tworzyliśmy wyrażenie, które odpowiada funkcji Heavside'a $\theta(x)$. Zamiast tego moglibyśmy stworzyć funkcję i wywołać ją dwa razy, dla różnych argumentów.

Funkcję zaczynamy od słowa kluczowego def, następnie podajemy w nawiasie argument (argumenty) funkcji oraz dwukropek. Jeśli funkcja ma zwracać musimy uzyć słowa kluczowego (w zasadzie funkcji) return:

In [65]:
def heaviside(x):
    return 1 if x >= 0 else -1

print(heaviside(5), heaviside(-5))
1 -1

Nie ma zasadniczo ograniczeń na argumenty oraz wartości funkcji - może ona przymować liczby, listy etc i zwracać dowolne rzeczy:

In [66]:
def dziwna_funkcja(x):
    if type(x) == int:
        return x ** 2
    elif type(x) == str:
        return "Mamy tu napis!"
    else:
        return x
    
print(dziwna_funkcja(10))
print(dziwna_funkcja('Python'))
print(dziwna_funkcja([0, 1, 2, 3]))
100
Mamy tu napis!
[0, 1, 2, 3]

8.1 Funkcje anonimowe (lambda) ¶

Jak sama nazwa wskazuje, funkcje anonimowe... nie posiadają nazwy. Są to najczęściej dość krótkie wyrażenia, bez których można się w wielu przypadkach obyć, ale które bardzo upraszczają kod. Składnia wyrażeń lambda jest następująca lambda argument(y) : ciało_funkcji. Możemy takie wyrażenie wywołać w następujący sposób:

In [67]:
(lambda x, y: x + y)(2, 4)
Out[67]:
6

możemy także przypisac wyrażenie lambda do zmiennej, co spowoduje, że będziemy mieli praktycznie do czynienia ze zwykłą funkcją. Jest często wykorzystywane do stworzenia krótkich, prostych funkcji:

In [68]:
f = lambda x, y: x + y
f(2, 4)
Out[68]:
6

Uwaga! Funkcje w połączeniu z wyrażeniam i lambda umożliwiają tworzenie "funkcji funkcji"

9. Funkcje map() i filter() oraz listy składane (list comprehensions) ¶

Wykorzystanie operatora : oraz funkcji range() wskazuje na podobieństwo Pythona do takich języków skryptowych jak np. Matlab. Z drugiej strony, z dotychczasowych rozważań wynika, że aby stworzyć nową listę y, w której każdy element będzie podwojoną wartością odpowiedniego elementu z listy x, musimy się uciec do pętli:

In [69]:
x = range(10)
y = []

for i in x:
    y.append(2*i)

y
Out[69]:
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

9.1 Funkcja map() ¶

Jest to dość "brzydki" zapis. Zamiast tego można wykorzystać funkcję map(f, lista), która, jak wskazuje jej nazwa, dokonuje mapowania pomiędzy wejściową listą a pewną funkcją f(), którą podamy. Funkcja map() zwraca obiekt typu map, stąd zwykle dobrze jest go zrzutować na listę za pomocą funkcji list(). Możemy jawnie zdefiniować wspomnianą funkcję:

In [70]:
def f(x):
    return 2*x

l = range(10)

y = list(map(f, l))

y
Out[70]:
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

W przypadku, gdy funkcja jest prosta, możemy skorzystać z omawianych wcześniej funkcji anonimowych:

In [71]:
l = range(10)

y = list(map(lambda x: 2*x, l))

y
Out[71]:
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

9.2 Funkcja filter() ¶

Podobnie przedstawia się sprawa testowania warunków dla listy. Wyobraźmy sobie, że chcemy pobrać z listy l jedynie te elementy, które są większe niż 5. Zamiast tworzenia karkołomych konstrukcji z użyciem pętli lub funkcji map() (co jest możliwe), korzystamy z funkcji filter(funkcja_warunkowa, lista):

In [72]:
l = range(10)

y = list(filter(lambda x: x > 5, l))
y
Out[72]:
[6, 7, 8, 9]
In [73]:
def f(x, n):
    return x > n

n = 3
list(filter(lambda x: f(x, n), range(10)))
Out[73]:
[4, 5, 6, 7, 8, 9]

9.3 List comprehension ¶

Istnieje jeszcze prostszy sposób, a na pewno bardziej elegancki i chyba też częściej wykorzystywany, aby stworzyć listę, w której znajdą się elementy poddane funkcji. Są to tzw. listy składane - to wyrażenie nie jest zbyt popularne w jęzku polskim i częściej korzysta się z oryginału, czyli list comprehension. Jest konstrukcja, która składa się z nawiasów kwadratowych oraz jednolinijkowej pętli for [ wyrażenie for iterator in lista ] Poniższy kod stworzy po prostu taką samą listę jak wejściowa:

In [74]:
l = range(10)

y = [x for x in l]
y
Out[74]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

natomiast odpowiednia modyfikacja wypisze nam kwadraty:

In [75]:
y = [x**2 for x in l]
y
Out[75]:
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Oczywiście, możemy też zdefiniowac wcześniej funkcję lub wyrażenie lambda:

In [76]:
def f(x):
    return math.sin(x) - math.cos(x)
    
g = lambda x: x**2 - 2*x + 3

y1 = [f(x) for x in l]
y2 = [g(x) for x in l]
print(y1)
print(y2)
[-1.0, 0.30116867893975674, 1.325444263372824, 1.1311125046603125, -0.10315887444431626, -1.2425864601263648, -1.2395857848492917, -0.09691565562451554, 1.1348582804319953, 1.3232487471264336]
[3, 2, 3, 6, 11, 18, 27, 38, 51, 66]

10. Wykresy ¶

W Pythonie nie ma natywnej funkcji tworzącej wykresy - musimy skorzystać z jednej z bibliotek (modułów). Jedną z bardziej popularnych jest matplotlib, ale nie oznacza to, że jest to "jedyna słuszna opcja". Jeśli ktoś pracuje bardziej z danymi typu ramki danych (czyli korzysta z modułu pandas) to dużo lepszą opcją jest seaborn. My jednak skorzystamy z tej pierwszej opcji. W tym celu uruchomimy podmoduł matplotlib.pyplot i nazwiemy go (standardowo) plt, żeby nie męczyć się długą nazwą.

Idea najprostyszych wykresów 2D jest dość trywialna - korzystamy z funkcji plot, podając jako oddzielne argumenty listy zawierające współrzędne x oraz y:

In [77]:
import matplotlib.pyplot as plt

x = range(10)
y = [i**2 for i in x]

plt.plot(x, y)
Out[77]:
[<matplotlib.lines.Line2D at 0x7fd5338b5840>]

Zamiast funkcji plot() możemy korzystać ze scatter(), która prezentuje dane za pomocą punktów, aczkolwiek to samo możemy uzyskać wstawiając opcję 'o' w funkcji plot() po serii danych. Aby ustawić kolor w funkcji plot(), korzystamy c opcji c="...", podczas gdy rozmiarem punktów sterujemy za pomocą ms=.... Typ linii możemy modyfikowac dzięki ls="...". Kolejne serie danych można dodać wywołując kolejne funkcje plot() w ramach jednej komórki.

Warto też wspomnieć, że dodanie opcji label="..." umożliwi później skorzystanie z funkcji legend(), wyświetlającej legendę do rysunku, natomiast axhline() i axvline(), odpowiednio, dostarczą linie poziome i pionowe, a xlabel() i ylabel() podpisy osi.

In [78]:
x = range(-5, 6)
plt.plot(x, [x**2 for x in x], "o", c = "red", ms = 10, label = "punkty")
plt.plot(x, x, label = "linia")
plt.axhline(0, c = "gray", ls = "--")
plt.axvline(0, c = "gray", ls = "--")
plt.xlabel("x")
plt.ylabel(r"$f(x)=x^2$, $g(x)=x$")
plt.legend()
Out[78]:
<matplotlib.legend.Legend at 0x7fd53172eb90>

11. Moduł numpy ¶

Ponieważ nasze zajęcia w dużej mierze będą dotyczyły przekształceń wektorowych i macierzowych, zalecane jest wykorzystanie funkcji bezpośrednio dedykowanych do takich operacji. Mowa tu o module numpy, którego podstawową cechą jest to, że pozwala na wykonywnie operacji element po elemencie, bez odwoływania się do pętli. Innymi słowy, np. dodanie liczby 1 do każdego elementu wektora v może zostać zapisane w kodzie jako v + 1.

Niezbędne jest przy tym przekształcenie natywnego obiektu Pythona (czyli listy) do obiektu moduły numpy (czyli macierzy - array) za pomocą funkcji np.array().

In [79]:
import numpy as np

v = range(-5, 5)
u = np.array(v)

u
Out[79]:
array([-5, -4, -3, -2, -1,  0,  1,  2,  3,  4])

11.1 Wektory ¶

W tym momencie możemy już wykonać wspomniane operacje, czyli np. dodać skalar 1 do każdego elementu $u$:

In [80]:
u + 1
Out[80]:
array([-4, -3, -2, -1,  0,  1,  2,  3,  4,  5])

lub np. pomnożyć każdy element wektora $u$ przez 1.28:

In [81]:
u * 1.28
Out[81]:
array([-6.4 , -5.12, -3.84, -2.56, -1.28,  0.  ,  1.28,  2.56,  3.84,
        5.12])
In [82]:
u[0:5]
Out[82]:
array([-5, -4, -3, -2, -1])

Jeśli mamy dwa zgodne obikety wektorowe (tj. o tej samej długości) możemy je dodać do siebie:

In [83]:
s = np.array(range(5, -5, -1))
s
Out[83]:
array([ 5,  4,  3,  2,  1,  0, -1, -2, -3, -4])
In [84]:
u + s
Out[84]:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

11.2 Macierze ¶

Rozróżnienie pomiedzy wektorami i macierzami jest w tym wypadku sztuczne: de facto każdy obiekt stworzony przez funkcję np.array() jest po prostu macierzą.

In [85]:
A = np.array([[1, 2, 3],
              [4, 5, 6],
             [7, 8, 9],
             [10, 11, 12]])
A
Out[85]:
array([[ 1,  2,  3],
       [ 4,  5,  6],
       [ 7,  8,  9],
       [10, 11, 12]])

Podobnie jak w przypadku wektorów, możemy bez problemu dodawać wartości do wszystkich elementów macierzy, mnożyć

In [86]:
A + 1
Out[86]:
array([[ 2,  3,  4],
       [ 5,  6,  7],
       [ 8,  9, 10],
       [11, 12, 13]])
In [87]:
2 * A
Out[87]:
array([[ 2,  4,  6],
       [ 8, 10, 12],
       [14, 16, 18],
       [20, 22, 24]])

Kluczową kwestią jest to, że macierze numpy są inaczej indeksowane niż listy: zamiast zapisu A[i][j], mamy tu notację A[i,j]. Oznacza to, że bez problemu możemy wypisać np pierwszą kolumnę macierzy A:

In [88]:
A[:,0]
Out[88]:
array([ 1,  4,  7, 10])

dwa pierwsze wiersze:

In [89]:
A[0:2,:]
Out[89]:
array([[1, 2, 3],
       [4, 5, 6]])

albo podmacierz powstałą przez

In [90]:
A[[0, 2],[0, 2]]
Out[90]:
array([1, 9])

Sytuacja robi się trochę bardziej skomplikowana, jeśli chcemy wypisać podmacierz, składającą się z dowolnych wierszy i kolumn (np. 0 i 2 wiersz, oraz 0 i 2 kolumna). Musimy wtedy albo posłużyć się poniższą strukturą:

In [91]:
A[[0, 2],:][:,[0,2]]
Out[91]:
array([[1, 3],
       [7, 9]])

11.3 Ufunc i wykonywanie operacji element po elemencie, wektoryzacja ¶

W pakiecie numpy instnieje cały zestaw funkcji nazywanych ufunc (universal functions), których cechą jest to, że wykonają nasze operacje element po elemencie. W szczególności będą to funkcje matematyczne, znane nam z pakietu math, takie jak exp() czy sin(). Używanie wersji z pakietu numpy uwalnia nas od konieczności korzystania z list comprehensions:

In [92]:
x = np.arange(0, 2, 0.1)
np.exp(x)
Out[92]:
array([1.        , 1.10517092, 1.22140276, 1.34985881, 1.4918247 ,
       1.64872127, 1.8221188 , 2.01375271, 2.22554093, 2.45960311,
       2.71828183, 3.00416602, 3.32011692, 3.66929667, 4.05519997,
       4.48168907, 4.95303242, 5.47394739, 6.04964746, 6.68589444])

W powyższym przypadku użycie math.exp(x) spowodowałoby wyświetlenie komunikatu błędu. Możemy stworzyć własne funkcje, opierając się na innych funkcja wykonywanych element po elemencie:

In [93]:
def sinh(x):
    return 0.5*(np.exp(x) - np.exp(-x))

x = np.arange(-4, 4, 0.1)
plt.plot(x, sinh(x))
Out[93]:
[<matplotlib.lines.Line2D at 0x7fd5314d97b0>]

Uwaga! Niektóre funkcje wymagają Wektoryzacji

11.4 Funkcje modułu numpy ¶

Moduł numpy ma całą gamę przydatnych funkcji oraz właściwości przydatnych do obsługi macierzy:

  • pobieranie rozmiaru macierzy A.shape
In [94]:
A = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]])

nx, ny = A.shape
print(nx, ny)
4 3
  • wykonanie transpozycji macierzy A.T
In [95]:
A.T
Out[95]:
array([[ 1,  4,  7, 10],
       [ 2,  5,  8, 11],
       [ 3,  6,  9, 12]])
  • funkcja arange(), która podobnie to funkcji range() tworzy zakres, ale przymuje również wartości niecałkowite:
In [96]:
np.arange(-5, 5, 0.5)
Out[96]:
array([-5. , -4.5, -4. , -3.5, -3. , -2.5, -2. , -1.5, -1. , -0.5,  0. ,
        0.5,  1. ,  1.5,  2. ,  2.5,  3. ,  3.5,  4. ,  4.5])
  • funkcja linspace() dzieląca przedział na podaną liczbę podprzedziałów:
In [97]:
np.linspace(0, 5, 11)
Out[97]:
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. ])
  • funkcja reshape(), która zmienia rozmiar macierzy - bardzo wygodna przy połączeniu z arange(), możemy np, stworzyć ciąg liczb od 1 do 9 i wstawić je do macierzy 3 na 3:
In [98]:
np.arange(1, 10).reshape(3,3)
Out[98]:
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
  • operator @ wykonujący mnożenie macierzowe:
In [99]:
A = np.arange(1, 7).reshape(2, 3)
B = np.arange(6, 0, -1).reshape(3, 2)

A @ B
Out[99]:
array([[20, 14],
       [56, 41]])

Warto przy tym pamiętać, że zwykły operator mnożenia np. A * B skutkuje mnożeniem element po elemencie, więc np.

In [100]:
A * A
Out[100]:
array([[ 1,  4,  9],
       [16, 25, 36]])
  • funkcja outer() realizująca iloczyn kartezjański, czyli mnożąca "każdy z każdym":
In [101]:
np.outer(np.arange(1, 11), np.arange(1, 11))
Out[101]:
array([[  1,   2,   3,   4,   5,   6,   7,   8,   9,  10],
       [  2,   4,   6,   8,  10,  12,  14,  16,  18,  20],
       [  3,   6,   9,  12,  15,  18,  21,  24,  27,  30],
       [  4,   8,  12,  16,  20,  24,  28,  32,  36,  40],
       [  5,  10,  15,  20,  25,  30,  35,  40,  45,  50],
       [  6,  12,  18,  24,  30,  36,  42,  48,  54,  60],
       [  7,  14,  21,  28,  35,  42,  49,  56,  63,  70],
       [  8,  16,  24,  32,  40,  48,  56,  64,  72,  80],
       [  9,  18,  27,  36,  45,  54,  63,  72,  81,  90],
       [ 10,  20,  30,  40,  50,  60,  70,  80,  90, 100]])
  • funkcja pad(), która ma naprawdę skomplikowaną składnie: pozwala dołożyć do istniejącej macierzy wartości, przy czym możemy wskazać ile wierszy i kolumn zarówno przed jak i po ma zostać wypelnione. Poniższy przykład dokłada przez macierzą A jeden wiersz i dwie kolumny zer, a po niej dwa wiersze i trzy kolumny ósemek.
In [102]:
A = np.arange(1.0, 10).reshape(3, 3)
np.pad(A, ((1,2), (2,3)), constant_values=(0, 8))
Out[102]:
array([[0., 0., 0., 0., 0., 8., 8., 8.],
       [0., 0., 1., 2., 3., 8., 8., 8.],
       [0., 0., 4., 5., 6., 8., 8., 8.],
       [0., 0., 7., 8., 9., 8., 8., 8.],
       [0., 0., 8., 8., 8., 8., 8., 8.],
       [0., 0., 8., 8., 8., 8., 8., 8.]])

Najczęściej chcemy po prostu dołożyć zera, wtedy składnia się upraszcza (w tym przypadku chcemy dołozyć 2 kolumny i 2 rzędy zer, ale tylko za macierzą:

In [103]:
np.pad(A, (0,2))
Out[103]:
array([[1., 2., 3., 0., 0.],
       [4., 5., 6., 0., 0.],
       [7., 8., 9., 0., 0.],
       [0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0.]])
In [104]:
np.pad(A, (0,2))
Out[104]:
array([[1., 2., 3., 0., 0.],
       [4., 5., 6., 0., 0.],
       [7., 8., 9., 0., 0.],
       [0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0.]])
  • funkcja tile(), która powtarza daną macierz (również pojednyczą liczbę) tak, aby stworzyć macierz o określonym rozmiarze:
In [105]:
np.tile(128, (2, 3))
Out[105]:
array([[128, 128, 128],
       [128, 128, 128]])
  • wreszcie funkcje ones() i zeros(), które są szczególnym przypadkiem tile() oraz eye(), tworząca macierz z diagonalą (w tym wypadku jest to macierz kwadratowa, więc podajemy tylko jedną wartość):
In [106]:
np.ones((3, 3))
Out[106]:
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])
In [107]:
np.zeros((2, 3))
Out[107]:
array([[0., 0., 0.],
       [0., 0., 0.]])
In [108]:
np.eye(4)
Out[108]:
array([[1., 0., 0., 0.],
       [0., 1., 0., 0.],
       [0., 0., 1., 0.],
       [0., 0., 0., 1.]])

Z pewnością w trakcie zajęć często będzie wykorzystywana funkcja append(), która dokleja do istniejącej macierzy $\mathbf{A}$ wiersze append(..., axis = 0) lub kolumny append(..., axis = 1). Należy przy tym pamiętać, aby doklejny obiekt był faktycznie kolumną lub wierszem, a nie wektorem (jednowymiarowym). Dla przykładu, mamy do dyspozycji macierz $\mathbf{A}$

In [109]:
A = np.array([[1, 2], [3, 4]])
A
Out[109]:
array([[1, 2],
       [3, 4]])

i chielibyśmy do niej dokleić wektor $\mathbf{b} = (-1, -1)$ jako wiersz lub kolumnę. Naiwne zapisanie $\mathbf{b}$ jako b=np.array([-1, -1]) i użycie funkcji append() zwróci komunikat o błędzie. Zamiast tego musimy "ubrać" wektor w podwójne nawiasy kwadratowe i dopiero wykonac zamierzone operacje (oczywiście w przypadku doklejania kolumny, musimy najpierw dokonać w tym przypadku operacji transpozycji):

In [110]:
b = np.array([[-1, -1]])
np.append(A, b, axis = 0)
Out[110]:
array([[ 1,  2],
       [ 3,  4],
       [-1, -1]])
In [111]:
np.append(A, b.T, axis = 1)
Out[111]:
array([[ 1,  2, -1],
       [ 3,  4, -1]])

11.5 Podmoduł random ¶

Moduł numpy ma cały zestaw funkcji do losowania wartości z różnych rozkładów. Odwołujemy się do nich za pomocą np.random.nazwa_funkcji(). I tak np. uniform(a, b, n) umozliwia wylosowanie $n$ wartości z rozkładu jednorodnego z przedziału $[a,b)$, a normal(mu, sigma, n) - $n$ wartości z rozkładu Gaussa o średniej $\mu$ i odchyleniu $\sigma$, przy czym $n$ może być liczbą, ale równiez wymiarami macierzy:

In [112]:
np.random.uniform(0, 1, 10)
Out[112]:
array([0.52704059, 0.55280793, 0.79855673, 0.65996422, 0.61242318,
       0.85410624, 0.63622726, 0.22047794, 0.83520866, 0.55999092])
In [113]:
np.random.normal(0, 1, (4, 4))
Out[113]:
array([[-0.33840292,  0.57786622,  1.68438539,  0.84470866],
       [-0.58909465,  0.67338814, -0.46297912, -0.21658406],
       [-0.43464668, -1.21558443,  0.14506272,  0.1363524 ],
       [-1.70496161,  0.25311443, -0.2730853 , -2.46766237]])

11.6 Indeksowanie logiczne ¶

Jednym z kluczowych udogodnień związanych z pakietem numpy jest indeksowanie wektorów i macierzy za pomocą wartości logicznych. Innymi słowy, oprócz odwoływania się do elementów wektorów jako $A[0]$ etc, możemy przekazać wektor wartość logicznych długości rozpatrywanego wektora.

Przykład: dysponujemy wektorem pierwszych dzisięciu liczb naturalnych i chcemy wypisać tylko te liczby, które są większe niż 5.

In [114]:
x = np.array(range(1, 11))
x
Out[114]:
array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10])
In [115]:
ind = x > 5
ind
Out[115]:
array([False, False, False, False, False,  True,  True,  True,  True,
        True])
In [116]:
x[ind]
Out[116]:
array([ 6,  7,  8,  9, 10])

Oczywiście, nie musimy dokonywać pośredniego kroku stworzenia wektora ind - możemy po prostu wykonać:

In [117]:
x[x > 5]
Out[117]:
array([ 6,  7,  8,  9, 10])

Przykład: wylosuj 10000 punktów z rozkladu jednorodnego w przestrzeni dwuwymiarowej i narysuj je, zaznaczając jednym kolorem takie punkty, które mieszczą się w kole o promieniu 1, a innym te, które tego warunku nie spełniają.

In [118]:
# Losowanie współrzędnej x oraz y
x = np.random.uniform(-2, 2, 10000)
y = np.random.uniform(-2, 2, 10000)

# Warunek na to, żeby punkty leżały w kole o promieniu 1
ind = x**2 + y**2 < 1

# Stworzenie rysunku o równych proporcjach
fig = plt.figure(figsize = (3,3))

# Rysowanie punktów "zewnętrznych"
plt.scatter(x[np.invert(ind)], y[np.invert(ind)], s = .5)

# Rysowanie punktów "wewnętrznych"
plt.scatter(x[ind], y[ind], s = .5)
Out[118]:
<matplotlib.collections.PathCollection at 0x7fd53152b430>

12. Moduł pandas ¶

Biblioteka pandas jest szczególnie chętnie używana przez zajmujących się uczeniem maszynowym, gdyż pozwala w łatwy sposób zrobić podobne rzeczy, co w pakiecie numpy, ale noe ogranicza do danych jednego typu. Dodatkowo, do wykorzystywanych obiektów można stosować techniki bardzo podobne do zapytań SQL (warunki, raporty, złączenia).

Generalnie w pakiecie pandas mamy dwie struktury danych: szeregi (Series) oraz ramki danych (DataFrames).

12.1 Szeregi

Pierwsza jest po prostu jednowymiarową tablicą. Aby ją uzyskać używamy funkcji Series(), która przekształci inny obiekt (np. zakres) w tę strukturę:

In [119]:
import pandas as pd

s = pd.Series(range(5))
s
Out[119]:
0    0
1    1
2    2
3    3
4    4
dtype: int64

Jak widać, oprócz samego szeregu pojawia się też dodatkowa kolumna. Jest to tzw. indeks (index) - ponieważ nie ustawialiśmy go oddzielnie, domyślnie zostały nadane kolejne liczby naturalne. Możemy to zmienić np. korzystając z własności index, przy czym w ogólności indeks nie musi być unikalny

In [120]:
s.index = ['a', 'b', 'c', 'd', 'd']
s
Out[120]:
a    0
b    1
c    2
d    3
d    4
dtype: int64

Szereg możemy też stworzyć korzystając ze słownika:

In [121]:
d = {'a': 1, 'b': 2, 'c': 3}
pd.Series(d)
Out[121]:
a    1
b    2
c    3
dtype: int64

lub ze skalara, lecz w takim przypadku musimy podać indeksy:

In [122]:
pd.Series(0, index = range(5))
Out[122]:
0    0
1    0
2    0
3    0
4    0
dtype: int64

Do szeregu mozemy się odwoływać zarówno przez pozycję, np. stosując operator slice

In [123]:
s[0:2]
Out[123]:
a    0
b    1
dtype: int64

albo korzystając z indeksu

In [124]:
s['d']
Out[124]:
d    3
d    4
dtype: int64

Generalnie na takim obiekcie możemy wykonywac podobne operacje jak w przypadku tablicy numpy, tzn np. dodawanie

In [125]:
s + s
Out[125]:
a    0
b    2
c    4
d    6
d    8
dtype: int64

mnożenie przez skalar

In [126]:
2*s
Out[126]:
a    0
b    2
c    4
d    6
d    8
dtype: int64

wstawienie jako argument do zwektoryzowanych funkcji numpy

In [127]:
np.sin(s)
Out[127]:
a    0.000000
b    0.841471
c    0.909297
d    0.141120
d   -0.756802
dtype: float64

czy indeksowanie logiczne

In [128]:
s[s > s.mean()]
Out[128]:
d    3
d    4
dtype: int64
In [ ]:
 
In [129]:
df = pd.DataFrame({'a': range(10), 'b': np.sin(range(10))})

12.2 Ramki danych ¶

Ramki danych są rozserzeniem szeregów na dwa wymiary: możemy sobie je wyobrażać jako macierze dwumymiarowe z tą różnicą, że każda kolumna może przechowywać inny typ danych. Jest to bardzo podobna struktura do typowej tabeli SQL, gdzie kolumny traktujemy jako zmienne, a wiersze jako rekordy. Od razu staje się jasne, czemu ten typ danych jest tak preferowany w ML: poszczególne wiersze będą dla nas po prostu obserwacjami, a kolumny współrzędnymi.

Ramki tworzymy podobnie jak szeregi: możemy korzystać ze słowników, list czy z samych szeregów:

In [130]:
x = [[1,2,3],[4,5,6]]
df = pd.DataFrame(x)
df.columns = ['a', 'b', 'c']
df
Out[130]:
a b c
0 1 2 3
1 4 5 6
In [131]:
df = pd.DataFrame({'a': range(10), 'b': np.sin(range(10))})
df
Out[131]:
a b
0 0 0.000000
1 1 0.841471
2 2 0.909297
3 3 0.141120
4 4 -0.756802
5 5 -0.958924
6 6 -0.279415
7 7 0.656987
8 8 0.989358
9 9 0.412118

Możemy też (i często będziemy) wykorzystwać funkcje read_table() lub read_csv() wczytujące pilki do struktury DataFrame, przy czym nie musimy pobierać lokalnie pliku, wsyatrczy, że wskażemy jego adres:

In [132]:
df = pd.read_table("https://jsienkiewicz.pl/R_podstawy/P08/cyber.dat", sep=" ")
df
Out[132]:
t date time comments neg pos emo
1 1 2012-07-11 16:44:00 330 -1.7303 1.9970 0.2667
2 2 2012-07-11 16:59:00 292 -1.7808 2.0479 0.2671
3 3 2012-07-11 17:14:00 373 -1.7641 2.0080 0.2440
4 4 2012-07-11 17:29:00 372 -1.9167 1.9704 0.0538
5 5 2012-07-11 17:44:00 359 -1.7855 2.0084 0.2228
... ... ... ... ... ... ... ...
6490 6490 2012-09-17 06:59:00 13 -1.3846 2.3077 0.9231
6491 6491 2012-09-17 07:14:00 18 -1.5000 2.1111 0.6111
6492 6492 2012-09-17 07:29:00 21 -1.7619 2.1905 0.4286
6493 6493 2012-09-17 07:44:00 18 -1.3889 2.5556 1.1667
6494 6494 2012-09-17 07:59:00 15 -1.7333 2.1333 0.4000

6494 rows × 7 columns

12.3 Odwoływanie się do ramki danych ¶

Do ramki możemy odwoływac po prostu za pomocą operatora slice:

In [133]:
df[0:5]
Out[133]:
t date time comments neg pos emo
1 1 2012-07-11 16:44:00 330 -1.7303 1.9970 0.2667
2 2 2012-07-11 16:59:00 292 -1.7808 2.0479 0.2671
3 3 2012-07-11 17:14:00 373 -1.7641 2.0080 0.2440
4 4 2012-07-11 17:29:00 372 -1.9167 1.9704 0.0538
5 5 2012-07-11 17:44:00 359 -1.7855 2.0084 0.2228

lub też podając nazwy kolumn jako listę

In [134]:
df[["t","date"]]
Out[134]:
t date
1 1 2012-07-11
2 2 2012-07-11
3 3 2012-07-11
4 4 2012-07-11
5 5 2012-07-11
... ... ...
6490 6490 2012-09-17
6491 6491 2012-09-17
6492 6492 2012-09-17
6493 6493 2012-09-17
6494 6494 2012-09-17

6494 rows × 2 columns

Możemy też skorzystać z dwóch funkcji iloc oraz loc. Pierwsza z nich daje nam mozliwość potraktowania ramki danych tak jak macierzy

In [135]:
df.iloc[0:3, 1:3]
Out[135]:
date time
1 2012-07-11 16:44:00
2 2012-07-11 16:59:00
3 2012-07-11 17:14:00

natomiast druga daje opcję odwołania się do nazw (etykiet) wierszy oraz kolumn:

In [136]:
df.loc[0:5, ["t", "emo"]]
Out[136]:
t emo
1 1 0.2667
2 2 0.2671
3 3 0.2440
4 4 0.0538
5 5 0.2228

12.4 Tworzenie masek ¶

Bardzo wygodną kwestią w pandas jest tworzenie masek, ktore można porównać do zapytań SQL. Jest to oczywiście analog to odpiwednich operacji w pakecie numpy, tzn podajemy pewien warunek, który daje efekt w wktora wartości logicznych, za pomocą którego możemy indeksować naszą ramkę danych. Jeśli np. chcielibyśmy wypsiać wszystkie rekordy df, w których wartość emo jest większa od 1.5, to po prostu wykonamy:

In [137]:
df[df["emo"] > 1.5]
Out[137]:
t date time comments neg pos emo
1995 1995 2012-08-01 11:14:00 1502 -1.2184 3.0553 1.8369
1996 1996 2012-08-01 11:29:00 1587 -1.2917 2.8563 1.5646
2011 2011 2012-08-01 15:14:00 1889 -1.1551 2.8539 1.6988
2188 2188 2012-08-03 11:29:00 1272 -1.2665 2.7909 1.5244
2319 2319 2012-08-04 20:14:00 1842 -1.2628 2.8811 1.6183
... ... ... ... ... ... ... ...
6438 6438 2012-09-16 17:59:00 22 -1.0909 2.7727 1.6818
6461 6461 2012-09-16 23:44:00 5 -1.0000 2.8000 1.8000
6463 6463 2012-09-17 00:14:00 4 -1.0000 3.2500 2.2500
6475 6475 2012-09-17 03:14:00 1 -1.0000 3.0000 2.0000
6484 6484 2012-09-17 05:29:00 2 -1.0000 3.0000 2.0000

103 rows × 7 columns

Oczywiście, te "zapytania" mogą byc bardziej rozbudowane, np. chcemy wypisać obserwacje charakteryzowane wysoki emo oraz dużą liczbą komentarzy:

In [138]:
df[(df["emo"] > 1.5) & (df["comments"] > 1000)]
Out[138]:
t date time comments neg pos emo
1995 1995 2012-08-01 11:14:00 1502 -1.2184 3.0553 1.8369
1996 1996 2012-08-01 11:29:00 1587 -1.2917 2.8563 1.5646
2011 2011 2012-08-01 15:14:00 1889 -1.1551 2.8539 1.6988
2188 2188 2012-08-03 11:29:00 1272 -1.2665 2.7909 1.5244
2319 2319 2012-08-04 20:14:00 1842 -1.2628 2.8811 1.6183
2320 2320 2012-08-04 20:29:00 1425 -1.2540 2.8800 1.6260
2396 2396 2012-08-05 15:29:00 1346 -1.3076 2.9116 1.6040
2576 2576 2012-08-07 12:29:00 1298 -1.2011 2.8606 1.6595
2592 2592 2012-08-07 16:29:00 1231 -1.3233 2.8367 1.5134
2782 2782 2012-08-09 15:59:00 1284 -1.3458 2.9026 1.5569
2805 2805 2012-08-09 21:44:00 1182 -1.3316 2.9332 1.6015
2986 2986 2012-08-11 18:59:00 1285 -1.3089 2.8770 1.5681

12.5 Tworzenie podsumowań i raportów ¶

W wielu przypadkach potrzebujemy wykoanć proste statystyki (np. sumę, średnią etc) na całym zbiorze danych. Jets to wyjątkowo łatwe zadanie z przypadku pakietu pandas, gdyż wystarczy po prostu użyć odpowiednich funkcji na całej ramce. Ponieważ nasza oryginala ramka zawiera kolumny typu data i czas, na których nie można wykonywac takich operacji, stworzmy okrojoną ramkę (parametr axis określa wg. której osi chcemy dokonac operacji - tu są to kolumny, więc

In [ ]:
 
In [139]:
df1 = df.drop(columns = ["date", "time"])
df1.mean()
Out[139]:
t           3247.500000
comments     292.765784
neg           -1.566826
pos            2.325234
emo            0.758408
dtype: float64

Jeśli zastosujemy funkcję agg() otrzymamy zagregowany raport:

In [140]:
df1[["neg", "pos","emo"]].agg([min, np.median, np.mean, max])
Out[140]:
neg pos emo
min -4.000000 0.000000 -3.000000
median -1.560450 2.333300 0.776800
mean -1.566826 2.325234 0.758408
max 0.000000 4.000000 3.000000

Możemy też skorzystać z funkcji apply(), która zastosuje wybraną przez nas funkcję to kolumn (axis = 0) lub wierszy (axis = 1). W ramach tej funkcji możemy wykorzystać dostępne już funkcje lub napisać wyrażenie lambda:

In [141]:
df1.apply(lambda x: np.sum(x**2), axis = 1)
Out[141]:
1       1.089081e+05
2       8.527544e+04
3       1.391452e+05
4       1.384076e+05
5       1.289133e+05
            ...     
6490    4.212028e+07
6491    4.213341e+07
6492    4.214651e+07
6493    4.215938e+07
6494    4.217227e+07
Length: 6494, dtype: float64

Wreszcie często musimy pogrupować nasze dane wg. jakiejś zmiennej kategorycznej i policzyć statystki w takich przedziałach - np. chcielibyśmy się przekonać czy w zależności od liczby komentarzy. Uzywamy wtedy funkcji groupby() albo w odniesieniu do całej ramki albo też do wybranych kolumn:

In [142]:
z = df1.groupby('comments')['emo'].mean()
z
Out[142]:
comments
0       0.000000
1       0.333333
2       0.710145
3       0.805966
4       0.783582
          ...   
6256    0.702500
7684    0.575000
8231    0.678400
9477    0.511700
9529    0.471700
Name: emo, Length: 1215, dtype: float64

przy czym w niektórych przypadkach dobrze jest dokonać binowania danych np. za pomocą funkcji cut():

In [143]:
df1["bins"] = pd.cut(df1.comments, bins = range(0, 11000, 1000))

df1.groupby('bins')['emo'].agg(['count', 'mean'])
Out[143]:
count mean
bins
(0, 1000] 6105 0.755859
(1000, 2000] 340 0.849546
(2000, 3000] 17 0.529035
(3000, 4000] 6 0.587217
(4000, 5000] 6 0.721933
(5000, 6000] 3 0.651267
(6000, 7000] 1 0.702500
(7000, 8000] 1 0.575000
(8000, 9000] 1 0.678400
(9000, 10000] 2 0.491700

13 Moduł seaborn ¶

Pakiet seaborn jest wyjątkowo chętnie wykorzystywany przy opracowywaniu informacji zawartych w ramkach danych, gdyż daje pewne automatyczne mozliwości odnoszenia się do poszczególnych kolumn i łączenia ich z częściami wykresu. Nie ma sepcjalnego sensu omówienie wszystkich możliwości tej biblioteki - będziemy je sukcesywnie poznawać na zajęciach - natomiast warto zrozumieć zupełne podstawy. Zaczniemy od wczytania jednego z wbudowanych zbiorów danych, dotyczącego parametrów samochodów:

In [144]:
import seaborn as sns
mpg = sns.load_dataset("mpg")
mpg
Out[144]:
mpg cylinders displacement horsepower weight acceleration model_year origin name
0 18.0 8 307.0 130.0 3504 12.0 70 usa chevrolet chevelle malibu
1 15.0 8 350.0 165.0 3693 11.5 70 usa buick skylark 320
2 18.0 8 318.0 150.0 3436 11.0 70 usa plymouth satellite
3 16.0 8 304.0 150.0 3433 12.0 70 usa amc rebel sst
4 17.0 8 302.0 140.0 3449 10.5 70 usa ford torino
... ... ... ... ... ... ... ... ... ...
393 27.0 4 140.0 86.0 2790 15.6 82 usa ford mustang gl
394 44.0 4 97.0 52.0 2130 24.6 82 europe vw pickup
395 32.0 4 135.0 84.0 2295 11.6 82 usa dodge rampage
396 28.0 4 120.0 79.0 2625 18.6 82 usa ford ranger
397 31.0 4 119.0 82.0 2720 19.4 82 usa chevy s-10

398 rows × 9 columns

Podstawowym wykresem, z którego bedziemy korzystać to replot(). Ogólna idea jest taka, że podajemy źródło danych, a następnie przypisujemy odpowiednie zmienne z ramki danych do współrzędnych. Tę samą możliwość możemy wykorzystać w przypadku np. koloru punktów:

In [145]:
sns.set_theme(style="darkgrid")
sns.relplot(data = mpg, x = "horsepower", y="mpg", hue="origin")
Out[145]:
<seaborn.axisgrid.FacetGrid at 0x7fd53155feb0>

Seaborn daje nam możliwość "warunkowania" - możemy od razu z poziomu jednej funkcji odwołać się do różnych parametrów, tworząc wykres, na którym każdy panel odpowiada określonemu poziomowi zmiennej kategorycznej (tu: kraj pochodzenia samochodu):

In [146]:
sns.relplot(data = mpg, x = "horsepower", y="mpg", col="origin", hue="origin", size="weight", height=3)
Out[146]:
<seaborn.axisgrid.FacetGrid at 0x7fd47f50a6b0>

Bardzo podobnym wykresem jest pairplot(), dzięki któremu od razu można zorientować się w wyglądzie par zależności, a także uzyskać informację nt. rozkładów zmiennych:

In [147]:
sns.pairplot(mpg[["mpg","horsepower","displacement","origin"]], hue="origin")
Out[147]:
<seaborn.axisgrid.PairGrid at 0x7fd47f2f0f10>

14. Dodatkowe uwagi ¶

14.1 Użycie operatorów dodawania oraz mnożenia w przypadku list i zmiennych tekstowych ¶

Tak jak zostało wspomniane, napisy w Pythonie są sekwencją, tak więc możemy się do nich odwoływać tak, jak do zwykłej listy, czyli oprzez idenksy i operator slice:

In [148]:
l = "To jest napis"
l[3:7]
Out[148]:
'jest'

W obu przypadkach (listy i napisy) operatory dodawania i mnożenia mają dość specyficzne działanie: operator + skleja ze sobą struktury a operator * powiela je:

In [149]:
l = "ma"
k = "tka"
l + k
Out[149]:
'matka'
In [150]:
x = [1, 2, 3]
4*x
Out[150]:
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]

14.2 Tworzenie kopii list zagnieżdżonych oraz kopii macierzy w numpy ¶

W przypadku obiektów zagnieżdżonych kopiowanie za pomocą copy() nie jest wystarczające, gdyż odnosi się jedynie do "pierwszego poziomu" i zmiany na niższym poziomie zagnieżdżenia będą widoczone w obu obiektach. Stąd też funkcja copy() nosi nazwę "płytkiej" kopii. Aby faktycznie uzyskać kopię niezależną musimy odwołać się do funkcji deepcopy() - jak sama nazwa wskazuje, tworzy ona głęboką kopię. W tym celu musimy uruchomić moduł copy:

In [151]:
import copy

A = [[1, 2, 3], [4, 5, 6, [7, 8]]]
B = A.copy()
C = copy.deepcopy(A)

B[0][0] = 128
C[0][0] = -11

print(A, "\n", B, "\n", C, "\n")
[[128, 2, 3], [4, 5, 6, [7, 8]]] 
 [[128, 2, 3], [4, 5, 6, [7, 8]]] 
 [[-11, 2, 3], [4, 5, 6, [7, 8]]] 

Ten "problem" nie występuje dla modułu numpy - nawet w przypadku wielowymiarowych macierzy wystarczy użyć funkcji copy():

In [152]:
A = np.random.uniform(0, 1, [3, 3, 3])
A
Out[152]:
array([[[0.55640334, 0.91016635, 0.75413538],
        [0.11895625, 0.95086083, 0.37339501],
        [0.5013814 , 0.32825375, 0.45533911]],

       [[0.17974257, 0.46018314, 0.31625818],
        [0.48435328, 0.53604536, 0.09058217],
        [0.17883419, 0.42074974, 0.96988318]],

       [[0.73658579, 0.49426729, 0.82439867],
        [0.54377081, 0.38610458, 0.97394541],
        [0.90262058, 0.53137441, 0.92350426]]])
In [153]:
B = A.copy()
B[2,2,2] = -100
A
Out[153]:
array([[[0.55640334, 0.91016635, 0.75413538],
        [0.11895625, 0.95086083, 0.37339501],
        [0.5013814 , 0.32825375, 0.45533911]],

       [[0.17974257, 0.46018314, 0.31625818],
        [0.48435328, 0.53604536, 0.09058217],
        [0.17883419, 0.42074974, 0.96988318]],

       [[0.73658579, 0.49426729, 0.82439867],
        [0.54377081, 0.38610458, 0.97394541],
        [0.90262058, 0.53137441, 0.92350426]]])

14.3 Funkcje anonimowe w tworzeniu funkcji funkcji ¶

Funkcje anonimowe są dość wygodne, gdy chcemy stworzyć funkcje wyższych rzędów, tzn. funkcje funkcji. Skonstruujmy np. następującą funkcję:

In [154]:
def funkcja_potegowa(alfa):
    return lambda x: x ** alfa

Jeśli teraz wywołamy funkcję funkcja_potęgowa z pewnym argumentem i przypiszemy wynik do zmiennej, to stworzmy funkcję, która będzie zawsze podnosiła argument do danej potęgi:

In [155]:
kwadrat = funkcja_potegowa(2.0)
print(kwadrat(4), kwadrat(10))
16.0 100.0
In [156]:
szescian = funkcja_potegowa(3.0)
print(szescian(4), szescian(10))
64.0 1000.0

14.4 Wektoryzacja funkcji w numpy ¶

Należy pamiętać, że nie każda funkcja, którą stworzymy, jest wykonywana element po elemencie: jeśli w ramach naszej funkcji będziemy tworzyć wektory czy tablice, to schemat nie zadziała. Rozważmy np. funkcję, która otrzymuje jako argument liczbę naturalną $n$ i liczy sumę ciągu arytmetycznego:

In [157]:
def fsum(n):
    return np.sum(np.arange(1, n + 1))

fsum(10)
Out[157]:
55

Wywołanie tej funkcji na obiekcie typu array da komunikat błędu. Aby policzyć funkcję fsum() dla każdego elementu wektora musimy ją zwektoryzować za pomocą funkcji vectorize() z pakietu numpy:

In [158]:
l = np.arange(1, 10)
v_fsum = np.vectorize(fsum)

v_fsum(l)
Out[158]:
array([ 1,  3,  6, 10, 15, 21, 28, 36, 45])