Python jest jednym z najbardziej popularnych języków. Ponieważ na wielu różnych zajęciach korzystam z tego języka poniżej zostały zebrane najważniejsze informacje, dla osób od poziomu początkującego.
Najpewniejszym źródłem wiedzy jest zawsze oficjalna dokumentacja, która ma też przyjazny samouczek. Te strony będą zawsze dużo pewniejszym źródłem niż losowe "lekcje", porady, lub inne informacje znalezione w internecie.
W przypadku używania bibliotek (takich jak numpy, matplotlib i inne) podobnie zachęcam w pierwszym rzędzie do sięgnięcia do oficjalnej dokumentacji, która oprócz opisów funkcji zwykle zawiera bogate i użyteczne przykłady.
Istnieje kilka sposobów wygodnej pracy z Pythonem, w zależności od upodobań, rodzaju problemu i dostępnych narzędzi
Bezpośrednia praca z interpreterem (REPL - read-eval-print loop), w tym przypadku wygodniejszy od standardowego interpretera jest ipython
,
Pisanie skryptów w ulubionym edytorze (gnome-text-editor, vim, emacs, nano) w pliku "program.py" oraz uruchamianie w terminalu poleceniem python program.py
Używanie notatnika jupyter
(lub jego wersji Google-Colab) - odpowiedników notatników w Mathematice
Używanie środowiska programistycznego (np. VS Code) z odpowiednimi wtyczkami do obsługi Pythona
Python jest językiem interpretowanym. Oznacza to, że programów nie trzeba kompilować, a specjalny program - interpreter - tłumaczy na bieżąco polecenia na język maszynowy. Bazowy interpreter można uruchomić poleceniem
$ python
Ale nie jest to jedyna opcja. Bardziej zaawansowany interpreter, który ma podobne możliwości, jak Mathematica lub Matlab, to:
$ ipython
Jeszcze inna wersja to Jupyter Notebook, który działa w przeglądarkach i umożliwia tworzenie notatek - dokumentów, zawierających kod i tekst. Odmianą tego projektu jest Google Colab. Instrukcje w tych intepreterach można wydawać po kolei i śledzić kolejne wyniki (tzw. REPL - Read Evaluate Print Loop, ten sam schemat używa Mathematica, Matlab, itd.). Na przykład po uruchomieniu interpretera ipython zobaczymy coś następującego
In [1]: a = 1
In [2]: b = 5
In [3]: a**2 + b**2
Out[3]: 26
W ten sposób można szybko wykonywać obliczenia, sprawdzać działanie fragmentów kodu itd.
Ale program w pythonie może mieć również postać pliku z rozszerzeniem *.py
, który jest wywoływany przez interpreter:
$ python moj_program.py
Uwaga! Na starszych komputerach można spotkać ciągle Pythona2, który ma inną składnię niż Python3. W tych wypadkach często równolegle jest zainstalowany Python3 i można go uruchomić poleceniem python3
.
Python jest językiem dynamicznym i sam rozpoznaje typ zmiennej:
a = 1
x = 12.345
Ta sama zmienna może za chwilę zmienić się w coś zupełnie innego:
a = "Łańcuch znaków"
i nie będzie to błędem.
Pomimo tego trzeba pamiętać, że zmienne jednak mają swój typ i może to być typ prosty (int, float, bool, string) lub złożony (tuple, dictionary...) i wpływa to na sposób działania kodu.
>>> a = 5
>>> b = "Ala"
>>> a * b
>>> a = True
>>> a * b
Typy proste, o których na razie musimy wiedzieć, to liczby całkowite (int
), logiczne (bool
) i liczby zmiennoprzecinkowe (float
). Z typów złożonych przyda nam się lista:
a = [3, 5, 8]
która jest po prostu uporządkowaną sekwencją jakichkolwiek obiektów. Mogą być to liczby, napisy, inne listy:
a = [12.3, [0, 1, 2], "Ala ma kota", []]
Ważne jest to, że możemy się odwoływać do kolejnych elementów za pomocą indeksów (zaczynających się od zera dla pierwszego elementu).
>>> a[0]
12.3
>>> a[1]
[0, 1, 2]
Ciekawostką są możliwości indeksowania. Ujemne liczby oznaczają liczenie elementów od końca, więc:
a[-1]
to ostatni wyraz. Wybierać można również pewien zakres elementów - poprzez składnię:
a[0:2]
przy czym początek należy do podzbioru, a koniec nie, czyli w podanym przykładzie podzbiór będzie miał elementy a[0] i a[1].
Bieżącą długość listy można sprawdzić poleceniem len
:
n = len(a)
Służy do tego instrukcja print ()
, która - jak łatwo się domyśleć - wypisuje coś na ekranie. Jej składnia jest bardzo prosta: można podawać jej tekst lub liczby, a kolejne elementy oddziela się przecinkami. Print sam wstawia enter na końcu i spacje pomiędzy elementami!
print ("x =", x)
Ciekawe są możliwości formatowania liczb i innych zmiennych za pomocą specjalnej składni. Powiedzmy, że chcemy wypisać wynik z dokładnością do 3 miejsc po przecinku, ale tak, aby cały napis mieścił się na 8 znakach i był wyjustowany w prawą stronę.
print('{: >8.3f}'.format(10 / 3))
Nawiasy klamrowe mówią pythonowi, że tu ma wstawić coś, co będziemy formatować. Po dwukropku podajemy spację, którą wstawi w puste miejsca (można użyć innych znaków), znak >
mówiący, że napis jest wyjustowany na prawo (inne możliwości to < i ^) oraz 8.3f
, który mówi, że pól jest 8, miejsc po przecinku 3, a liczba jest typu float. Uwaga: wynik dzielenia 10 / 3 zostanie automatycznie zrzutowany na typ float
!
Instrukcje kontrolne - to wszelkie elementy języka, które pozwalają na zmiany zachowania się kodu w zależności od wyników obliczeń, wprowadzonych danych, powtarzanie operacji itd.
if x > 0:
print("x jest dodatni")
elif x < 0:
print("x jest ujemny")
else:
print("x jest zerem")
Przy okazji zwróćmy uwagę na kilka istotnych cech składni pythona:
brak nawiasów
brak średników na końcach linii
bloki są definiowane przez wcięcia
na końcu linii z warunkiem jest dwukropek.
Wcięcia wymuszają poprawne formatowanie kodu. Ważne jest, aby wcięć dokonywać za pomocą ustalonej liczby spacji, np. 4. W edytorach można ustawić automatyczne wcinanie kodu, więc nie trzeba ich liczyć ręcznie. Podobnie można ustawić, aby edytor wpisywał odpowiednią liczbę spacji po naciśnięciu klawisza tabulatora. Możliwe jest też stosowanie znaku tabulacji jako wcięcia, ale ważne jest, że nie można ze sobą mieszać spacji i tabulatorów!
x = 1
y = 8
while x < 100:
print (x)
if x % y == 0:
break
x += 1
else:
print (x, 'nie dzieli się przez', y)
W pętli while
operacje wewnątrz są powtarzane, dopóki spełniony jest warunek. Warto zwrócić uwagę na to, że cały blok 'while' musi być wcięty, a instrukcja wewnątrz warunku 'if' - podwójnie wcięta (jest to odpowiednik nawiasów w nawiasach).
Instrukcje +=
i break
mają odpowiedno znaczenie sumowania (x += 1
jest tożsame z x = x + 1
) oraz wyskoczenia z pętli. Ciekawostką jest instrukcja else
przy pętli while
. Będzie ona wykonana tylko wtedy, gdy while
zakończy działanie na skutek spełnienia warunku. Jeżeli z pętli while
wyskoczymy poleceniem break
, to kod w bloku else
nie będzie wykonany.
Instrukcja for w pythonie wygląda następująco
for i in [0, 2, 4, 6]:
print (i)
W pętli for
także możemy stosować polecenie else
, na takich samych zasadach jak w pętli while
.
Dla tej pętli podajemy zbiór, po którym ma się poruszać iterator (tutaj i
) i będzie to robić po kolei, chyba, że jej przerwiemy poleceniem break. Ponieważ często chcemy poruszać się po pewnym zakresie, na przykład od 1 do 10, w pythonie mamy polecenie range()
, które taką listę zrobi od ręki. Ważne, aby pamiętać, że pierwsza podana wartość będzie użyta, a ostatnia jest górną granicą ostrą, czyli jej samej nie będzie. Poniższy kod pokaże nam tabliczkę mnożenia do 10.
for i in range (1, 11):
for j in range (1, 11):
print ( '{: >4d}'.format(i * j) , end='')
print ()
W powyższym przykładzie użyte zostało bardziej zaawansowane formatowanie wypisywanych na ekranie wyników. Użyta została tu metoda, należąca do obiektu typu ciąg znaków, format
. Jest to bardzo elastyczne polecenie, pozwalające na zdefiniowanie wielu aspektów napisu. Najważniejsze dla nas informacje to:
W ciągu znaków używamy nawiasów {}
, które oznaczają kolejno podawane później w poleceniu format
wartości
W nawiasach {:}
pod dwukropku możemy podać formatowanie danej wartości
Dodatkowo możemy podać szerokość napisu, dokładność (liczbę miejsc po przecinku oraz wyjustowanie
{: ^8.3f}
wyjustowanie do środka spacjami, szerokość 8 znaków, 3 miejsca po przecinku
{:0>4d}
wyjustowanie w prawo zerami, szerokość 4 znaki
Można zwrócić też uwagę na słówko end=''
w instrukcji print
, które powoduje, że nie będzie wstawiany znak nowej linii (domyślne działanie), którą chcemy wstawić dopiero na koniec dziesiątki - i stąd puste print ()
, dające tylko enter.
Jeżeli stworzymy w programie jakąś procedurę, którą będziemy często powtarzać, zamiast kopiować i wklejać fragment kodu, warto jest stworzyć z niej funkcję (zwaną także procedurą lub subrutyną). Funkcję definiujemy w następujący sposób
def pitagoras(a, b):
c = a**2 + b**2
return c**(1/2)
Zgodnie ze wcześniejszą składnią stosujemy dwukropek i wcięcie. Jeżeli nie użyjemy słówka return
, funkcja automatycznie zwróci wartość None
(nic).
Zmienne wewnątrz funkcji są widoczne tylko w niej, nawet jeżeli nazywają się tak samo jak zmienne globalne. Na przykład
def f(x):
x = x + 1
print('f:', x)
return x**3
x = 1
y = f(x)
print('main:', x)
rezultatem działania będzie
f: 2
main: 1
Pomimo iż dodajemy jedynkę do x
wewnątrz funkcji, jest to inny x
(lokalny) niż ten, który deklarujemy w głównej części programu! Dlatego po wyjściu z funkcji x
globalny jest dalej równy 1.
Uwaga! Inaczej będą się zachowywały listy. Z powodu sposobu w jaki Python przekazuje zmienne do funkcji, lista zmodyfikowana wewnątrz funkcji, po wyjściu z niej nadal będzie zmodyfikowana. Jeżeli chcemy, aby lista nie ulegała zmianom, nie wystarczy przypisać wartość listy nowej zmiennej (bo wskazuje ona ciągle na ten sam obiekt!). Musimy zrobić jej kopię (np. wewnątrz funkcji B = A.copy()
).
def f(A):
A[0] += 1
print('f:', A)
# To jest niewłaściwe podejście
def g(A):
B = A
B[0] += 1
print('f:', B)
def h(A):
B = A.copy()
B[0] += 1
print('g:', B)
A = [0, 0, 0]
f(A)
print('main:', A)
rezultatem działania będzie
f: [1, 0, 0]
main: [1, 0, 0]
g: [2, 0, 0]
main: [2, 0, 0]
h: [3, 0, 0]
main: [2, 0, 0]
Zarówno f
, jak i g
modyfikują oryginalną listę A
. Dopiero podejście w funkcji h
pokazuje jak uniknąć tego efektu, jeżeli taki mamy zamiar.
Moduł w pythonie to plik z rozszerzeniem *.py
zawierający funkcje, stałe i inne elementy, które możemy importować do innego programu. Jeżeli stworzymy zatem np. zestaw funkcji, które rozwiązują równania różniczkowe, to możemy zebrać je w jednym miejscu i w łatwy sposób korzystać z nich w innych programach. Następnym krokiem, który wykracza poza poruszane tematy jest tworzenie pakietu Pythona, który może zostać zainstalowany lokalnie lub opublikowany i udostępniony szerzej.
Oczywiście, jak można się spodziewać python ma mnóstwo gotowych bibliotek i to zawartych w standardowej dystrybucji (czyli dostępnych zawsze, bez instalowania dodatkowych pakietów). Autorzy nazywają to batteries included. Najpierw zobaczymy zatem co mamy do wyboru, potem nauczymy się jak importować biblioteki, a na końcu tworzyć własne.
Wybrane moduły biblioteki standardowej
argparse - interfejs linii poleceń (parametry)
datetime - operacje związane z czasem i kalendarzem
math - funkcje matematyczne
multiprocessing - przetwarzanie wielowątkowe
os - interakcje z systemem operacyjnym
random - generatory liczb losowych
re - wyrażenia regularne
timeit - pomiary wydajności programu
tkinter - Tk GUI (jedna z prostszych bibliotek GUI)
QtPy - biblioteka GUI Qt
unittest - testowanie programu
Jest to tylko mała część tego co jest dostępne i jest prezentowana tylko po to, aby pokazać gotowe od ręki możliwości pythona. Większości z nich raczej nie będziemy używać.
Do analizy danych używane są biblioteki z poniżej listy, z których zapoznamy się bliżej z numpy
i matplotlib
.
numpy - numerical Python
matplotlib - wykresy
pandas - biblioteka do operacji na zbiorach danych, popularna w Big Data Science
sympy - obliczenia symboliczne
scipy - scientific Python = numpy + matplotlib + ipython + pandas + sympy
Do importowania modułu służy słówko import
, które zwykle używamy na początku programu.
import math
Powyższy przykład daje nam dostęp do funkcji matematycznych. Korzystanie z nich wymaga używania nazwy biblioteki i nazwy funkcji oddzielonych kropką
vx = v * math.sin(alpha)
Taka składnia zapewnia, że jeżeli w innym module (np. numpy) także jest zdefiniowana funkcja sin
to będziemy dokładnie wiedzieli, z której w danym momencie korzystamy.
Czasem jednak jest wygodniej z danej biblioteki wyciągnąć tylko co to nam potrzeba, np. pojedynczą funkcję
from math import sin, pi
y = sin(45 * pi / 180)
Dla odmiany, szczególnie podczas interaktywnych sesji, czasem chcemy zaimportować wszystko i to tak, aby nie kłopotać się z nazwami biblioteki
from math import *
y = sin(1.0)
w = pi * acosh(2.0)
z = exp(0.5)
ale z tą formą trzeba uważać, aby nie narobić sobie bałaganu!
Wreszcie, wiele modułów ma strukturę drzewiastą (nazywane są wtedy pakietami), czyli wewnątrz modułu są kolejne moduły i dopiero w nich są interesujące nas funkcje (np. matplotlib.pyplot.plot
. Wypisywanie takich długich napisów za każdym razem byłoby żmudne. Dlatego istnieje specjalna składnia nadająca alias (czyli wymyśloną przez nas krótką nazwę) danej bibliotece. Bardzo często będziemy używać takiego czegoś
import matplotlib.pyplot as plt
import numpy as np
Po zastosowaniu tej składni użycie słówka plt
ma taki sam skutek jak matplotlib.pyplot
.
W zasadzie każdy skrypt z rozszerzeniem *.py
można importować do innego programu. Wyobraźmy sobie, że napisaliśmy skrypt pitagoras.py
, który wygląda tak
def pitagoras(a, b):
c = a**2 + b**2
return c**(1/2)
print ("Test:", pitagoras(3, 4))
w którym mamy jedną dobrze już znaną funkcję i polecenie, którym testujemy, czy aby wzór jest dobry. Napiszmy teraz inny program np. trojki.py
, w którym użyjemy funkcji pitagoras
do szukania trójek pitagorejskich
import pitagoras
n = 10
for a in range (1, n):
for b in range (a, n):
c = pitagoras.pitagoras (a, b)
if c.is_integer():
print (a, b, c)
Zobaczymy napis
Test 5
3 4 5.0
6 8 10.0
Okazuje się, że nasza testowa linia wydrukowała się po zaimportowaniu modułu! Pierwsze rozwiązanie to jej usunięcie, ale nie zawsze chcemy ingerować w istniejący program. Dlatego zamiast tego można zastosować składnię, która powie pythonowi, że pewna część kodu ma być wywołana tylko, jeżeli uruchomimy dany skrypt bezpośrednio. W przypadku importowania ta część zostanie pomięta. Plik pitagoras.py
będzie teraz wyglądał tak
def pitagoras(a, b):
c = a**2 + b**2
return c**(1/2)
if __name__ == '__main__':
print ("Test:", pitagoras(3, 4))
W poprzednich rozdziałach wspomnieliśmy o bibliotece matplotlib
. Jest to bardzo użyteczna i prosta w obsłudze biblioteka do tworzenia bardzo bogatych, wysokiej jakości wykresów. Często, aby narysować coś bardziej skomplikowanego wystarczy zerknąć na listę gotowych przykładów.
Na razie ograniczymy się do najprostszych wykresów. W Zadaniu 1 liczyliśmy pole powierzchni wielokąta prostego. W tej wersji programu nie było wcale widać jak ten wielokąt wygląda, ale postaramy się teraz to nadrobić.
Podstawową funkcją jaką będziemy używać będzie polecenie plot
, które tworzy wykres. Należy podać mu listę punktów x
, punktów y
i typ wykresu.
import matplotlib.pyplot as plt
plt.plot(x, y, 'o')
plt.show()
Typ wykresu to albo rodzaj punktów (kółka - o, kwadraty - s, trójkąty - v ^ > <, krzyżyki - + x, gwiazdki - *) więcej tutaj, mogą być to również linie ('-', '–', ':', '-.') albo kombinacje punktów i linii (np. 'o-'). Aby wykres się pojawił w okienku musimy podać polecenie 'show'.
W bardziej zaawansowanej postaci możemy modyfikować kolor, grubość, rozmiar znaczników itd.
plt.plot(x, y, color='green', marker='o', linestyle='dashed', linewidth=2, markersize=12)
Matplotlib sam dobiera zakresy osi x i y, ale nie zawsze ten wybór musi nam odpowiadać. Do zmiany zakresu służą polecenia
plt.xlim(0, 10)
plt.ylim(-5, 5)
Dodatkowe pionowe i poziome linie, które mogą np. oznaczać jakieś przyjęte przez nas poziomy odniesienia, średnie wartości, itd. można dodawać przez:
plt.axvline(3.0, ls='--', color='red')
plt.axhline(0.0, ls=':', color='black')
Legendy osi wstawiamy przez xlabel
i ylabel
, ale domyślny rozmiar czcionki jest często zbyt mały, więc można go zwiększyć
plt.xlabel('Czas (t)', fontsize=14)
plt.ylabel('Amplituda (V)', fontsize=14)
Czcionka na osiach teraz jest dużo mniejsza, więc warto ją też powiększyć
plt.tick_params(axis='both', labelsize=12)
Jeżeli nasze wykresy mają jakieś opisy, możemy dodać legendę wyjaśniającą jakie znaczniki i kolory przypisane są jakiej wielkości. Legendę dodajemy poleceniem
plt.legend()
Wreszcie poniższe polecenie spowoduje ładne rozmieszczenie wykresu w okienku
plt.tight_layout()
Łącząc wszystkie te elementy poniższy przykład prezentuje wykres z hipotetycznego pomiaru na pracowni. Zamiast prostego plot
używamy tutaj errorbar
, ponieważ zwykle pomiary mają swoją niepewność, którą chcemy pokazać. Tak przygotowany wykres jest w zasadzie gotowy do wklejenia do raportu!
import matplotlib.pyplot as plt
t = [0, 1, 2, 3, 4, 5, 6]
A = [1.0, 1.1, 0.9, 0.7, 0.3, 0.1, 0.02]
dA = [0.1, 0.1, 0.1, 0.1, 0.05, 0.05, 0.005]
plt.errorbar(t, A, yerr=dA, marker='o', ls='None', color='red', label='Seria 1')
plt.xlim(0, 7)
plt.ylim(0, 1.5)
plt.xlabel('Czas (t)', fontsize=14)
plt.ylabel('Amplituda (V)', fontsize=14)
plt.tick_params(axis='both', labelsize=12)
plt.legend()
plt.tight_layout()
plt.show()
Jeżeli do przedstawienia mamy wiele krzywych, to możemy je umieścić na jednym wykresie (wtedy warto użyć legendy), albo podzielić je na kilka osobnych, mniejszych wykresów. Pierwsze rozwiązanie jest bardzo proste, wystarczy wydać polecenie plot
więcej niż jeden raz
plt.plot(t, A1, 'o', label='Seria 1')
plt.plot(t, A2, 's', label='Seria 2')
plt.plot(t, A3, 'v', label='Seria 3')
plt.legend()
plt.show()
W drugiej wersji stworzymy podwykresy za pomocą polecenia subplot
. Podajemy w nim liczbę wierszy, liczbę kolumn i numer wykresu liczony od 1.
plt.subplot(3, 1, 1)
plt.plot(t, A1, 'o', label='Seria 1')
plt.subplot(3, 1, 2)
plt.plot(t, A2, 's', label='Seria 2')
plt.subplot(3, 1, 3)
plt.plot(t, A3, 'v', label='Seria 3')
plt.legend()
plt.tight_layout()
plt.show()
W tym przykładzie legenda pojawi się tylko na ostatnim wykresie. Dzieje się tak, ponieważ polecenia (zmiana osi, legendy itd.) dotyczą tylko bieżącego, czyli ostatnio stworzonego podwykresu.
Pakiet NumPy, czyli Numerical Python to biblioteka dodająca do pythona obsługę dużych, wielowymiarowych macierzy oraz mnóstwo narzędzi numerycznych (metod numerycznych) do ich obsługi oraz wykonywania najróżniejszych obliczeń. NumPy jest napisany w C i skompilowane biblioteki są wołane przez pythona, jest dzięki temu często równie szybki (o ile trzymamy się struktur numpy i nie używamy np. pętli czystego pythona) jak inne kompilowane języki, a dzięki optymalizacji zwykle o wiele szybszy niż to co można samemu napisać w krótkim czasie.
Podstawową strukturą numpy jest tablica, która może być jedno lub więcej wymiarowa. Działania zwykle są wykonywane na tablicy w całości, zamiast na kolejnych wyrazach, dzięki czemu kod jest znacznie szybszy. Tablicę można utworzyć na kilka sposobów, najprostszy jest przez podanie listy
x = numpy.array([0, 1, 2, 3])
Oczywiście lista musi mieć sensowną postać, którą da się przełożyć na wektor lub macierz, a więc nie może zawierać wymieszanych napisów i liczb, być nieprostokątna itd. Tablicę o większej liczbie wymiarów tworzymy podając listę zawierającą listy, np. dwuwymiarowa
A = numpy.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
Do elementów takiej tablicy możemy się odwoływać przez podwójny indeks
A[0, 0]
Ale także możemy dostać cały wiersz
A[0, :]
lub kolumnę
A[:, 0]
Ten sposób, podawania elementów wprost, jest wygodny jeżeli pracujemy z niewielkimi tablicami. A co jeśli chcemy stworzyć tablicę zawierającą 100, 1000 lub jeszcze więcej elementów? Najczęściej będziemy stosować dwie metody.
x = numpy.arange(100)
ta postać działa bardzo podobnie do polecenia range
, i tworzy tablicę liczb od 0 do 99. Jeżeli chcemy tablicę z krokiem innym niż 1, to musimy podać początek, koniec oraz krok
x = numpy.arange(0, 10, 0.1)
i dostaniemy tablicę 100 elementów od 0 do 9.9
Inny sposób wygląda następująco
x = numpy.linspace(0, 1)
linspace
tworzy równy podział podanego odcinka na zadaną liczbę odcinków (domyślnie - 50), włącznie z końcami przedziału. Dlatego jeżeli chcemy równo podzielić przedział 0, 1 na części co 0.01, musimy zrobić to następująco
x = numpy.linspace(0, 1, 101)
Rozmiar (a dokładniej kształt) tablicy można sprawdzić poleceniem shape
, które zwraca listę rozmiarów w poszczególnych wymiarach. Jeżeli tablica jest jednowymiarowa, to będzie to lista zawierająca jeden element (ale nadal lista, a nie pojedyncza liczba!). W przykładzie poniżej sprawdzamy zatem liczbę elementów w osi X dla jednowymiarowej tablicy x.
x_size = x.shape[0]
Kiedy mamy już tablicę można teraz łatwo wywołać na niej różne operacje, przykład pokazuje połączenie z matplotlib i stworzenie wykresów funkcji trygonometrycznych
x = numpy.linspace(-numpy.pi ,numpy.pi, 100)
plt.plot(x, numpy.sin(x), '-')
plt.plot(x, numpy.cos(x), '--')
plt.show()
NumPy jest niezwykle bogatą biblioteką i najlepiej szukać w niej narzędzi w bardzo dobrej dokumentacji projektu. Kilka funkcja przyda nam się w następnym zadaniu.
numpy.average(x)
numpy.std(x)
numpy.min(x)
numpy.max(x)
Te funkcje obliczają średnią, odchylenie standardowe, minimum i maksimum dla podanych danych. Bardzo przydatnym narzędziem jest możliwość ładowania plików z danymi w formacie tekstowym z wartościami oddzielonymi spacjami
data = numpy.loadtxt('plik.txt')
Automatycznie zwracana jest tablica o takim kształcie jaki miały dane. Linie zaczynające się od znaku # traktowane są jako komentarze i są ignorowane. Oczywiście te domyśle zachowania można modyfikować i podpowiedzieć funkcji inne ustawienia
data = numpy.loadtxt('plik.csv', delimiter=',', comments='%', skiprows=3)
co oznacza, że dane są oddzielone przecinkiem, komentarze oznaczone % i dodatkowo pierwsze trzy linie pliku należy pominąć (np. zawierają nagłówek).
Funkcja open
zwraca obiekt powiązany z plikiem, w zależności od trybu otwarcia może być on do odczytu (r), pisania (w, istniejący plik zostaje skasowany), dopisywania (a), odczytu i zapisu (r+). Dodanie litery b do trybu oznacza tryb binarny, w innym przypadku jest to tryb tekstowy.
data_file = open('data.txt', r)
Funkcja read służy do czytania danych, read() wczyta cały plik, read(size) wczyta size bajtów.
data_file.read(10)
Funkcja readline wczytuje jedną linię tekstu (łącznie ze znakiem końca linii).
data_file.readline()
for line in data_file:
print(line, end='')
write służy do pisania w pliku, natomiast seek i tell odpowiednio przesuwają i zwracają położenie bieżącej pozycji w pliku.
data_file.write('{} {} {} \n'.format(0, 0, 1)
data_file.seek(10)
data_file.tell()
Prosty program z drobnym błędem
l = [0, 1, 2]
if l > 0:
l.pop()
i próba uruchomienia
Traceback (most recent call last):
File "test.py", line 2, in <module>
if l > 0:
TypeError: '>' not supported between instances of 'list' and 'int'
wskazuje na linijkę kodu, w której wystąpił błąd (numer 2) oraz typ błędu: nie można porównać listy i liczby za pomocą operatora ">".
Wszelkie błędy są zwracane w postaci wyjątków (Exception), należących do odpowiednich klas. Powyżej mamy błąd typu (TypeError). Program wcale nie musi przerywać działania w takiej sytuacji, możliwa jest odpowiednia obsługa wyjątku.
data_file = open('data.txt')
counter = 0
for line in data_file:
try:
counter += int(line)
except TypeError:
pass
Podejście obiektowe jest próbą opisu skomplikowanych systemów za pomocą abstrakcji.
Tworzymy obiekty, z którymi się komunikujemy (i które mogą się komunikować między sobą), które realizują zadania dzięki wewnętrznym metodom.
Problem dzielimy na pewne zadania, które przypisujemy obiektom i w pewnym sensie nie interesuje nas jak dany obiekt rozwiąże zadanie, o ile robi to zgodnie z oczekiwaniami. Niepotrzebne informacje są ukryte wewnątrz obiektu.
Każdy obiekt należy do pewnej klasy.
Każdy obiekt ma swoje zmienne i procedury.
Obiekty można ponownie używać w innych programach.
Wewnętrzną strukturę obiektów można modyfikować, uzupełniać i poprawiać, o ile nie wpływa to na widziane z zewnątrz własności.
Obiekty mogą dziedziczyć (łączyć się hierachicznie) po bardziej ogólnych klasach (np. samochód, czy rower jest bardziej szczegółową realizacją klasy pojazd).
Obiekty mogą zawierać inne obiekty (np. samochód zawiera koła, czujnik prędkości itd.).
W Pythonie wszyscy członkowie klasy są publiczni, a wszystkie funkcje są wirtualne (według terminologii C++).
Poniższy przykład tworzy dwie klasy obiektów matematycznych: Point oraz Circle. Do zdefiniowania punktu na płaszczyźnie potrzebujemy jego współrzędnych. Tworzymy też metodę tej klasy, distance
, która mierzy odległość między dwoma punktami. Druga klasa - Circle - wymaga podania położenia środka okręgu oraz jego promienia. Środek okręgu jest punktem, więc możemy użyć obiektu poprzednio stworzonej klasy Point (taka konstrukcja nazywa się kompozycją).
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, point):
return math.sqrt((self.x - point.x)**2 + (self.y - point.y)**2)
class Circle:
def __init__(self, x, y, r):
self.center = Point(x, y)
self.r = r
def distance_to_perimeter(self, point):
return math.sqrt((self.center.x - point.x)**2 +
(self.center.y - point.y)**2) - self.r
p1 = Point(0, 0)
p2 = Point(1, 2)
c1 = Circle(1, 1, 1)
print(p1.distance(p2))
print(c1.distance_to_perimeter(p2))
print(p1.distance(c1.center))
Gdybyśmy chcieli stworzyć więcej kształtów geometrycznych i nasz kod miałby elastycznie z nimi pracować możnaby rozważyć utworzenie klasy Shape, po której dziedziczą kolejne elementy. Powiedzmy, że chcemy, żeby każdy kształt miał jakiś kolor. Możemy wtedy zrobić tak.
import math
class Shape:
def __init__(self, color="black"):
self.color = color
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class Circle(Shape):
def __init__(self, x, y, r, color="black"):
super().__init__(color)
self.center = Point(x, y)
self.r = r
class Triangle(Shape):
def __init__(self, points, color="black"):
super().__init__(color)
self.points = points
c1 = Circle(1, 1, 1)
c2 = Circle(0, 0, 1, "red")
t1 = Triangle([Point(0, 0), Point(1, 1), Point(0, 1)], "green")
print(c1.color)
print(c2.color)
print(t1.color)
Jeżeli wpadnie nam do głowy, że oprócz koloru przydałby się np. krój linii (kreski, kropki, itp.) teraz wystarczy dołożyć taki element do macierzystej klasy Shape i wszystkie dziedziczące po niej klasy będą taką cechę posiadały.
Nowe wyjątki można tworzyć korzystając z mechanizmu dziedziczenia. Wszystkie wyjątki dziedziczą po bazowej klasie Exception.
class NuclearError(Exception):
def __init__(self, msg = ''):
self.msg = msg
def __str__(self):
return self.msg
try:
if temp > temp_max:
raise NuclearError('Max temp reached')
except NuclearError as err:
print(err)
Wielokrotnie podczas badań naukowych (i nie tylko) napotykamy podobny schemat działania z danymi, które są uzyskane z różnych źródeł (z eksperymentu, z baz danych, publikacji itp.). Zwykle sprowadza się on do trzech podstawowych kroków: wczytania danych, ich obróbki (analizy) oraz stworzenia informacji wyjściowych w postaci danych, tabel, wykresów itp. W poniższych przykładach spróbujemy przejść przez te etapy w bardzo uproszczonym schemacie.
Dane, które uzyskujemy są w bardzo rozmaitych formatach. Mogą to być dane tesktowe (np. w formacie ASCII, CSV), binarne (w jakimś szerszym standardzie lub specyficznym, np. dla używanych urządzeń) ewentualnie pochodzące z komunikacji z bazą danych (np. MySQL). W przykładach skupimy się na dwóch pierwszych typach, ponieważ są one najczęściej spotykane w czasie pracy w laboratoriach.
Przykładem danych tekstowych jest plik uzyskany z analizatora Tukan, używanego w kilku stanowiskach na zaawansowanej pracowni fizyki w ćwiczeniach z fizyki jądrowej. Plik został zebrany podczas pomiaru kalibracyjnego ze źródłem 152Eu i ma postać następującą
$SPEC_ID:
Ge
$SPECTRUM_DESC:
$DATE_MEA:
3/13/2023 09:21:06 AM
$MEAS_TIM:
2069 s 2111 s
$DATA:
0 8192
0 0
1 0
2 0
3 0
4 0
5 0
(...)
8189 0
8190 0
8191 0
Na początku znajduje się nagłówek zawierający informacje o dacie, czasie trwania pomiaru itp. Dane zaczynają się od linii 11 zawierającej 0 0
. Mamy tu dwie kolumny, pierwsza to numer kanału, druga liczba zliczeń zarejestrowana w danym kanale. Jest to przykład widma uzyskanego ze spektrometru promieniowania gamma.
Zaczniemy od najprostszej metody polegającej na ręcznym wczytaniu pliku i zinterpretowaniu jego zawartości. Nasza funkcja load_tukan
będzie brała informacje o pliku do wczytania oraz o długości nagłówka (który może się zmieniać). Następnie otworzymy plik w trybie tekstowym
datafile = open(filename, 'r')
i wczytamy go linia po linii
for line in datafile:
...
Opuszczamy linie tak długo, aż miniemy długość nagłówka. Prawdziwe dane zawierają dwie (lub jedną w innej wersji plików) kolumny z liczbami całkowitymi. Wczytana linia to napis typu string
. Każdą linię oczyścimy z białych znaków na początku i na końcu i podzielimy na kolejne wyrazy w kolumnach
line.strip().split()
Teraz wystarczy przerobić kolejne wyrazy na typ Integer i dodać do tabeli z danymi. Na koniec tabelę przerobimy na macierz numpy.
import numpy
def load_tukan(filename, header):
i = 0
datafile = open(filename, 'r')
data = []
for line in datafile:
i += 1
if i <= header:
continue
row = line.strip().split()
for i in range(len(row)):
row[i] = int(row[i])
data.append(row)
return numpy.array(data)
data = load_tukan('eu152.lst', 11)
print(data)
Ten sam efekt można osiągnąć używając funkcji numpy.loadtxt
. Ale nie zawsze pliki są skonstruowane w przyjazny sposób i automatyczne wczytywanie może zawieść. Np. nie poradzi sobie on z brakującymi danymi, które są zastąpione jakimś znakiem tekstowym (np. -
), ponieważ oczekuje kolumn w dobrze ustalonym typie. Dlatego tego typu funkcja czasem jest niezbędna. W naszym przykładzie numpy bez problemu jednak wczyta dane.
data = numpy.loadtxt("eu152.lst", skiprows=10)
print(data)
W obu wersjach powinniśmy móc już narysować widmo
import matplotlib.pyplot as plt
plt.plot(data[:, 0], data[:, 1], ds='steps-mid')
plt.show()
Format binarny oznacza, że dane są zapisywane do pliku w takiej postaci w jakiej występują w pamięci komputera (reprezentacji bitowej), a nie w postaci napisów. Takie pliki trudno czytać i interpretować, jeżeli nie znamy dokładnie struktury danych, ale za to zajmują znacznie mniej miejsca. Zapisanie jakiejś liczby w pliku tekstowym zajmuje tyle bajtów ile ma ona znaków, ale jeżeli użyjemy np. reprezentacji Int32, to zajmie ona zawsze 4 bajty niezależnie od długości i nie będzie potrzeby dodawania spacji, przecinków czy innych znaków oddzielających wartości (które także zajmują miejsce). Czasami pliki używają kompresji danych, specjalnych formatów lub innych sztuczek i wtedy byłoby dobrze, żeby istniała biblioteka obsługująca ten format. Popularnym formatem binarnym z wieloma możliwościami jest HDF5 (ale nie będziemy się nim tu zajmować).
W naszym przykładzie plik zawiera dane z analizatora posiadającego 3 karty po 16 kanałów. Dane są umieszczone w pliku dla kolejnych modułów i dla kolejnych kanałów, czyli: moduł 1: kanał 1, kanał 2, ... kanał 16, moduł 2: kanał 1, ... Każdy kanał zawiera widmo o 32768 kanałach, a każdy kanał to liczba typu UInt32 (4 bajty).
Zaczynamy od otwarcia pliku w trybie binarnym
datafile = open("cs137.bin", "rb")
Dane można czytać poleceniem read
podając długość wczytanych informacji w bajtach, np.
datafile.read(4)
wczyta 4 kolejne bajty. Możemy też wczytać jednocześnie całe widmo z danego kanału
datafile.read(4 * 32768)
Wcztane w ten sposób dane na razie są w postaci tablicy bajtów. Aby przerobić je na właściwą postać musimy znać ich postać i użyć polecenia struct.unpack
. Postać danych wpisujemy kodami w postaci liter: np. h
oznacza liczbę typu Int16, d
liczbę Float64 itd. (pełna lista jest w dokumentacji). Nasz typ to I
(liczba Int32 bez znaku) powtórzony 32768 razy. Dane przerobione na listę możemy od razu narysować (osobny wykres dla każdego modułu).
import struct
import matplotlib.pyplot as plt
datafile = open("cs137.bin", "rb")
for m in range(3):
plt.figure(m+1, (8, 6))
for c in range(16):
plt.subplot(4, 4, c+1)
v = struct.unpack('I' * 32768, datafile.read(4 * 32768))
plt.plot(v, label="{}:{}".format(m, c))
plt.legend()
datafile.close()
plt.show()
Okazuje się, że wszystkie kanały są puste poza modułem 0, kanał 1 (tylko jeden detektor był podłączony do urządzenia).
Jedną z często wykonywanych czynności jest dopasowanie krzywej (funkcji) do danych w celu ich opisania, porówania z modelem, czy określenia pewnych parametrów. Na początek spróbujemy dopasowania prostej do sztucznie stworzonych danych, żeby zobaczyć jak to się robi. Potem spróbujemy dopasować funkcję Gaussa do linii we wczytanych wcześniej danych.
Zaczniemy od "wyprodukowania" danych. Zrobimy zestaw punktów układających się wzdłuż prostej o znanych parametrach, ale aby było trochę ciekawiej, dodamy niewielki losowy rozrzut (z rozkładu normalnego).
xdata = numpy.linspace(0, 10, 11)
ydata = 0.2 * xdata + 1.5 + 0.1 * numpy.random.normal(size=xdata.size)
Najważniejszą częścią jest oczywiście dopasowanie. Funkcja curve_fit
z pakietu scipy.optimize
używa metody nieliniowej najmniejszych kwadratów. Musimy podać funkcję, którą chcemy opisać dane, listę położeń x
, y
, oraz początkowe wartości (których oczywiście musimy podać tyle ile bierze funkcja). Dla bardziej skomplikowanych funkcji musimy wybrać w miarę sensowne wartości, w pobliżu oczekiwanych wartości. W innym przypadku algorytm szukający minimum (domyślnie: Levenberga–Marquardta) potrafi zdryfować w inne lokalne minimum. Funkcja curve_fit
zwraca dwie macierze, pierwsza zawiera znalezione parametry, druga jest macierzą kowariancji, która zawiera kowariancję dla wszystkich par parametrów. Z definicji kowariancji wiemy, że na diagonalii, gdzie mamy kowariancję paremetrów samych z sobą, dostaniemy kwadraty odchyleń standardowych. W uproszczonej analizie często jest to wystarczająca informacja utożsamiana z niepewnością (co jest dobrym przybliżeniem, o ile pozadiagonalne elementy są małe).
import numpy
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
def f(x, a, b):
return a * x + b
xdata = numpy.linspace(0, 10, 11)
ydata = 0.2 * xdata + 1.5 + 0.1 * numpy.random.normal(size=xdata.size)
plt.plot(xdata, ydata, 'o')
popt, pcon = curve_fit(f, xdata, ydata, p0=[1, 0])
print('a =', popt[0], '+/-', numpy.sqrt(pcon[0][0]))
print('b =', popt[1], '+/-', numpy.sqrt(pcon[1][1]))
plt.plot(xdata, f(xdata, *popt), '--')
plt.tight_layout()
plt.show()
W dalszej części zabawy użyjemy dane wczytywane w pierwszym przykładzie. W okolicach kanału 270 znajduje się tam największa linia. Linie w tego typu widmach mają kształt krzywej Gaussa, ale dodatkowo wokół nich znajduje się tło. Dla w miarę niewielkiego obszaru możemy je przybliżyć funkcją liniową.
data = numpy.loadtxt("eu152.lst", skiprows=10)
plt.subplot(1, 2, 1)
plt.plot(data[:, 0], data[:, 1], ds="steps-mid")
plt.subplot(1, 2, 2)
plt.plot(data[:, 0], data[:, 1], ds="steps-mid")
plt.xlim(250, 300)
plt.ylim(0, None)
plt.tight_layout()
plt.show()
Zdefinujemy funkcję N
zawierającą funkcję Gaussa i liniowe tło. Do danych w wybranym zakresie trzeba wybrać rozsądne początkowe parametry. Położenie środka łatwo odczytać z wykresu (270), wartość odchylenia standardowego oszacujemy na około 2 (szerokość linii w połowie wysokości jest równa mniej więcej 2 razy σ) i wreszcie parametr A
oznaczający całkę pod krzywą ustalimy na równy dwa razy liczbie zliczeń w największym kanale (co powinno być wystarczające bliskim). Tło spróbujemy ustalić na stałe a0 = 2000, a1 = 0
.
import numpy
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
def N(x, x0, s, A, a0, a1):
return (A / numpy.sqrt(2 * numpy.pi * s**2)
* numpy.exp(-(x - x0)**2 / (2 * s**2)) + a0 + a1 * x)
data = numpy.loadtxt("eu152.lst", skiprows=10)
plt.plot(data[:, 0], data[:, 1], ds="steps-mid")
x0 = 260
x1 = 280
popt, pcon = curve_fit(N, data[x0:x1, 0], data[x0:x1, 1],
p0=[270, 2.0, 240000, 2000, 0])
names = ['x0', 's', 'A', 'a0', 'a1']
for i, name in enumerate(names):
print('{} = {:.3f} +/- {:.3f}'.format(name, popt[i],
numpy.sqrt(pcon[i][i])))
xf = numpy.linspace(x0, x1, 1000)
plt.plot(xf, N(xf, *popt), '-')
plt.xlim(x0 - 10, x1 + 10)
plt.tight_layout()
plt.show()
Można jeszcze z ciekawości sprawdzić, co się stanie, gdy parametry początkowe wybierzemy bardzo źle.
popt, pcon = curve_fit(N, data[x0:x1, 0], data[x0:x1, 1],
p0=[1.0, 2.0, 3.0, 4.0, 5.0])
for i, name in enumerate(names):
print('{} = {:.3f} +/- {:.3f}'.format(name, popt[i],
numpy.sqrt(pcon[i][i])))
xf = numpy.linspace(x0, x1, 1000)
plt.plot(data[:, 0], data[:, 1], ds="steps-mid")
plt.plot(xf, N(xf, *popt), '-')
plt.xlim(x0 - 10, x1 + 10)
plt.tight_layout()
plt.show()
Ale wystarczy podać wartości nieco lepsze wartości niektórych z nich (x0, A), żeby rozwiązanie zbiegło do właściwego minimum.
popt, pcon = curve_fit(N, data[x0:x1, 0], data[x0:x1, 1],
p0=[270.0, 1.0, 1000.0, 0.0, 0.0])
for i, name in enumerate(names):
print('{} = {:.3f} +/- {:.3f}'.format(name, popt[i],
numpy.sqrt(pcon[i][i])))
xf = numpy.linspace(x0, x1, 1000)
plt.plot(data[:, 0], data[:, 1], ds="steps-mid")
plt.plot(xf, N(xf, *popt), '-')
plt.xlim(x0 - 10, x1 + 10)
plt.tight_layout()
plt.show()
Przedstawiona tu analiza używa otwartych przedziałów parametrów. Można także dopasowywać parametry przez curve_fit
, innym algorytmem, który pozwala na wpisanie pewnych przedziałów, wynikających np. z fizycznych ograniczeń. Jak można się spodziewać jest to wierzchołek góry lodowej dotyczącej analizy danych, ale mam nadzieję, że ułatwi zaczęcie jej poznawania.