Laboratorium Statystycznej Eksploracji Danych
LABORATORIUM 0
Wprowadzenie do języka Python
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:
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.
Do pracy potrzebujemy:
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:
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.
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
1 + 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_funkcjiimport 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ługafrom 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():
import math
math.sin(10) + math.cos(10)
-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:
math.exp(-math.inf)
0.0
gdyż $\lim_\limits{x \rightarrow \infty} \mathbf{e}^{-x} = 0$
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:
x = 1
x
1
x = 128
x
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:
x, y = 2, 128
print(x, y)
2 128
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).
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():
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 (%):
2 ** 3
8
10 // 3
3
10 % 3
1
Wyjście funkcji print() możemy modyfikować używając modyfikatorów:
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
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):
x = True
type(x)
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):
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ę):
10 != 9
True
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:
x, y = "a", 'napis'
print(type(x), type(y))
<class 'str'> <class 'str'>
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:
Większość czasu poświęcimy na omówienie list, gdyż właśnie te obiekty będą dla najbardziej przydatne podczas zajęć.
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:
l = [3, -1, 8, 10, 2, -2.8]
l
[3, -1, 8, 10, 2, -2.8]
Elementy listy są indeksowane od zera, a indeksy podajemy w nawiasach kwadratowych:
l[0]
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:
l[0:2]
[3, -1]
l[1:5:2]
[-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:
l[:3]
[3, -1, 8]
l[4:]
[2, -2.8]
l[:]
[3, -1, 8, 10, 2, -2.8]
l[::2]
[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:
l[-2]
2
l[1:-2]
[-1, 8, 10]
Tak jak została to na wstepie wspomniane, listy nie muszą być obiektem jednorodnym (choć zwykle są):
l1 = [True, 1, "abc", 2, -128.567]
l1
[True, 1, 'abc', 2, -128.567]
Podając konkretne indeksy, jesteśmy w stanie podmienić aktualną zawartość listy, np:
x = [1, 2, 3]
x[1] = 5
x
[1, 5, 3]
Możemy przy tym korzystać z operatora slice, aby od razu zmienić całą część listy:
x = [1, 2, 3, 4, 5]
x[1:3] = [128, 129]
x
[1, 128, 129, 4, 5]
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):
x = [10, 1, 20, 30, 22]
x.append(18)
x
[10, 1, 20, 30, 22, 18]
x.insert(2, 333)
x
[10, 1, 333, 20, 30, 22, 18]
x.pop()
18
x
[10, 1, 333, 20, 30, 22]
x.pop(4)
30
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:
a = [1, 2, 3]
b = a
print(a, b)
[1, 2, 3] [1, 2, 3]
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:
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():
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.
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:
A = [[1, 2, 3], [4, 5, 6, [7, 8]]]
A
[[1, 2, 3], [4, 5, 6, [7, 8]]]
print(A[0], A[1])
[1, 2, 3] [4, 5, 6, [7, 8]]
print(A[1][3], A[1][3][0])
[7, 8] 7
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:
s = range(10)
s
range(0, 10)
s[5]
5
Jeśli tego potrzebujemy, możemy zrzutować obiekt typu range na listę:
list(range(0, 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
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ć:
x = (0, 1, 4)
x
(0, 1, 4)
y = 1, 8, "jem", (2, 3)
y
(1, 8, 'jem', (2, 3))
y[3][0]
2
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ć:
s1 = {127, True, "ab", 127}
s1
{127, True, 'ab'}
s1.add("efg")
s1
{127, True, 'ab', 'efg'}
s1.discard(True)
s1
{127, 'ab', 'efg'}
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:
s1 = {'a': 1, 'b': 2, 'c': 3}
s1
{'a': 1, 'b': 2, 'c': 3}
s1['c']
3
Generalnie slownik może przechowywać zupełnie różne elementy:
s = {'a': [1, 2, [3]], 77.88: 2, True: 'jedzenie', range(5): False, (0,0,1): 8}
s
{'a': [1, 2, [3]],
77.88: 2,
True: 'jedzenie',
range(0, 5): False,
(0, 0, 1): 8}
s[(0,0,1)]
8
s[range(5)]
False
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:
for oraz while,if else,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.
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:
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:
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:
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ść:
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:
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
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 --):
x = 0
while x < 5:
x += 1
x
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).
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):
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:
x = 5
x = 1 if x >= 0 else -1
y = -5
y = 1 if y >= 0 else -1
print(x, y)
1 -1
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:
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:
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]
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:
(lambda x, y: x + y)(2, 4)
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:
f = lambda x, y: x + y
f(2, 4)
6
Uwaga! Funkcje w połączeniu z wyrażeniam i lambda umożliwiają tworzenie "funkcji funkcji"
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:
x = range(10)
y = []
for i in x:
y.append(2*i)
y
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
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ę:
def f(x):
return 2*x
l = range(10)
y = list(map(f, l))
y
[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:
l = range(10)
y = list(map(lambda x: 2*x, l))
y
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
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):
l = range(10)
y = list(filter(lambda x: x > 5, l))
y
[6, 7, 8, 9]
def f(x, n):
return x > n
n = 3
list(filter(lambda x: f(x, n), range(10)))
[4, 5, 6, 7, 8, 9]
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:
l = range(10)
y = [x for x in l]
y
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
natomiast odpowiednia modyfikacja wypisze nam kwadraty:
y = [x**2 for x in l]
y
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Oczywiście, możemy też zdefiniowac wcześniej funkcję lub wyrażenie lambda:
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]
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:
import matplotlib.pyplot as plt
x = range(10)
y = [i**2 for i in x]
plt.plot(x, y)
[<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.
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()
<matplotlib.legend.Legend at 0x7fd53172eb90>
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().
import numpy as np
v = range(-5, 5)
u = np.array(v)
u
array([-5, -4, -3, -2, -1, 0, 1, 2, 3, 4])
W tym momencie możemy już wykonać wspomniane operacje, czyli np. dodać skalar 1 do każdego elementu $u$:
u + 1
array([-4, -3, -2, -1, 0, 1, 2, 3, 4, 5])
lub np. pomnożyć każdy element wektora $u$ przez 1.28:
u * 1.28
array([-6.4 , -5.12, -3.84, -2.56, -1.28, 0. , 1.28, 2.56, 3.84,
5.12])
u[0:5]
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:
s = np.array(range(5, -5, -1))
s
array([ 5, 4, 3, 2, 1, 0, -1, -2, -3, -4])
u + s
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
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ą.
A = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10, 11, 12]])
A
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ć
A + 1
array([[ 2, 3, 4],
[ 5, 6, 7],
[ 8, 9, 10],
[11, 12, 13]])
2 * A
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:
A[:,0]
array([ 1, 4, 7, 10])
dwa pierwsze wiersze:
A[0:2,:]
array([[1, 2, 3],
[4, 5, 6]])
albo podmacierz powstałą przez
A[[0, 2],[0, 2]]
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ą:
A[[0, 2],:][:,[0,2]]
array([[1, 3],
[7, 9]])
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:
x = np.arange(0, 2, 0.1)
np.exp(x)
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:
def sinh(x):
return 0.5*(np.exp(x) - np.exp(-x))
x = np.arange(-4, 4, 0.1)
plt.plot(x, sinh(x))
[<matplotlib.lines.Line2D at 0x7fd5314d97b0>]
Uwaga! Niektóre funkcje wymagają Wektoryzacji
Moduł numpy ma całą gamę przydatnych funkcji oraz właściwości przydatnych do obsługi macierzy:
A.shapeA = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]])
nx, ny = A.shape
print(nx, ny)
4 3
A.TA.T
array([[ 1, 4, 7, 10],
[ 2, 5, 8, 11],
[ 3, 6, 9, 12]])
arange(), która podobnie to funkcji range() tworzy zakres, ale przymuje również wartości niecałkowite:np.arange(-5, 5, 0.5)
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])
linspace() dzieląca przedział na podaną liczbę podprzedziałów:np.linspace(0, 5, 11)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. ])
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:np.arange(1, 10).reshape(3,3)
array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
A = np.arange(1, 7).reshape(2, 3)
B = np.arange(6, 0, -1).reshape(3, 2)
A @ B
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.
A * A
array([[ 1, 4, 9],
[16, 25, 36]])
outer() realizująca iloczyn kartezjański, czyli mnożąca "każdy z każdym":np.outer(np.arange(1, 11), np.arange(1, 11))
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]])
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.A = np.arange(1.0, 10).reshape(3, 3)
np.pad(A, ((1,2), (2,3)), constant_values=(0, 8))
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ą:
np.pad(A, (0,2))
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.]])
np.pad(A, (0,2))
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.]])
tile(), która powtarza daną macierz (również pojednyczą liczbę) tak, aby stworzyć macierz o określonym rozmiarze:np.tile(128, (2, 3))
array([[128, 128, 128],
[128, 128, 128]])
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ść):np.ones((3, 3))
array([[1., 1., 1.],
[1., 1., 1.],
[1., 1., 1.]])
np.zeros((2, 3))
array([[0., 0., 0.],
[0., 0., 0.]])
np.eye(4)
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}$
A = np.array([[1, 2], [3, 4]])
A
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):
b = np.array([[-1, -1]])
np.append(A, b, axis = 0)
array([[ 1, 2],
[ 3, 4],
[-1, -1]])
np.append(A, b.T, axis = 1)
array([[ 1, 2, -1],
[ 3, 4, -1]])
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:
np.random.uniform(0, 1, 10)
array([0.52704059, 0.55280793, 0.79855673, 0.65996422, 0.61242318,
0.85410624, 0.63622726, 0.22047794, 0.83520866, 0.55999092])
np.random.normal(0, 1, (4, 4))
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]])
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.
x = np.array(range(1, 11))
x
array([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
ind = x > 5
ind
array([False, False, False, False, False, True, True, True, True,
True])
x[ind]
array([ 6, 7, 8, 9, 10])
Oczywiście, nie musimy dokonywać pośredniego kroku stworzenia wektora ind - możemy po prostu wykonać:
x[x > 5]
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ą.
# 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)
<matplotlib.collections.PathCollection at 0x7fd53152b430>
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).
Pierwsza jest po prostu jednowymiarową tablicą. Aby ją uzyskać używamy funkcji Series(), która przekształci inny obiekt (np. zakres) w tę strukturę:
import pandas as pd
s = pd.Series(range(5))
s
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
s.index = ['a', 'b', 'c', 'd', 'd']
s
a 0 b 1 c 2 d 3 d 4 dtype: int64
Szereg możemy też stworzyć korzystając ze słownika:
d = {'a': 1, 'b': 2, 'c': 3}
pd.Series(d)
a 1 b 2 c 3 dtype: int64
lub ze skalara, lecz w takim przypadku musimy podać indeksy:
pd.Series(0, index = range(5))
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
s[0:2]
a 0 b 1 dtype: int64
albo korzystając z indeksu
s['d']
d 3 d 4 dtype: int64
Generalnie na takim obiekcie możemy wykonywac podobne operacje jak w przypadku tablicy numpy, tzn np. dodawanie
s + s
a 0 b 2 c 4 d 6 d 8 dtype: int64
mnożenie przez skalar
2*s
a 0 b 2 c 4 d 6 d 8 dtype: int64
wstawienie jako argument do zwektoryzowanych funkcji numpy
np.sin(s)
a 0.000000 b 0.841471 c 0.909297 d 0.141120 d -0.756802 dtype: float64
czy indeksowanie logiczne
s[s > s.mean()]
d 3 d 4 dtype: int64
df = pd.DataFrame({'a': range(10), 'b': np.sin(range(10))})
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:
x = [[1,2,3],[4,5,6]]
df = pd.DataFrame(x)
df.columns = ['a', 'b', 'c']
df
| a | b | c | |
|---|---|---|---|
| 0 | 1 | 2 | 3 |
| 1 | 4 | 5 | 6 |
df = pd.DataFrame({'a': range(10), 'b': np.sin(range(10))})
df
| 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:
df = pd.read_table("https://jsienkiewicz.pl/R_podstawy/P08/cyber.dat", sep=" ")
df
| 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
Do ramki możemy odwoływac po prostu za pomocą operatora slice:
df[0:5]
| 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ę
df[["t","date"]]
| 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
df.iloc[0:3, 1:3]
| 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:
df.loc[0:5, ["t", "emo"]]
| t | emo | |
|---|---|---|
| 1 | 1 | 0.2667 |
| 2 | 2 | 0.2671 |
| 3 | 3 | 0.2440 |
| 4 | 4 | 0.0538 |
| 5 | 5 | 0.2228 |
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:
df[df["emo"] > 1.5]
| 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:
df[(df["emo"] > 1.5) & (df["comments"] > 1000)]
| 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 |
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
df1 = df.drop(columns = ["date", "time"])
df1.mean()
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:
df1[["neg", "pos","emo"]].agg([min, np.median, np.mean, max])
| 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:
df1.apply(lambda x: np.sum(x**2), axis = 1)
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:
z = df1.groupby('comments')['emo'].mean()
z
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():
df1["bins"] = pd.cut(df1.comments, bins = range(0, 11000, 1000))
df1.groupby('bins')['emo'].agg(['count', 'mean'])
| 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 |
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:
import seaborn as sns
mpg = sns.load_dataset("mpg")
mpg
| 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:
sns.set_theme(style="darkgrid")
sns.relplot(data = mpg, x = "horsepower", y="mpg", hue="origin")
<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):
sns.relplot(data = mpg, x = "horsepower", y="mpg", col="origin", hue="origin", size="weight", height=3)
<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:
sns.pairplot(mpg[["mpg","horsepower","displacement","origin"]], hue="origin")
<seaborn.axisgrid.PairGrid at 0x7fd47f2f0f10>
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:
l = "To jest napis"
l[3:7]
'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:
l = "ma"
k = "tka"
l + k
'matka'
x = [1, 2, 3]
4*x
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
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:
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():
A = np.random.uniform(0, 1, [3, 3, 3])
A
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]]])
B = A.copy()
B[2,2,2] = -100
A
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]]])
Funkcje anonimowe są dość wygodne, gdy chcemy stworzyć funkcje wyższych rzędów, tzn. funkcje funkcji. Skonstruujmy np. następującą funkcję:
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:
kwadrat = funkcja_potegowa(2.0)
print(kwadrat(4), kwadrat(10))
16.0 100.0
szescian = funkcja_potegowa(3.0)
print(szescian(4), szescian(10))
64.0 1000.0
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:
def fsum(n):
return np.sum(np.arange(1, n + 1))
fsum(10)
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:
l = np.arange(1, 10)
v_fsum = np.vectorize(fsum)
v_fsum(l)
array([ 1, 3, 6, 10, 15, 21, 28, 36, 45])