Podstawy Pythona

Wstęp

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.

Praca z Pythonem

Istnieje kilka sposobów wygodnej pracy z Pythonem, w zależności od upodobań, rodzaju problemu i dostępnych narzędzi

  1. Bezpośrednia praca z interpreterem (REPL - read-eval-print loop), w tym przypadku wygodniejszy od standardowego interpretera jest ipython,

  2. Pisanie skryptów w ulubionym edytorze (gnome-text-editor, vim, emacs, nano) w pliku "program.py" oraz uruchamianie w terminalu poleceniem python program.py

  3. Używanie notatnika jupyter (lub jego wersji Google-Colab) - odpowiedników notatników w Mathematice

  4. Używanie środowiska programistycznego (np. VS Code) z odpowiednimi wtyczkami do obsługi Pythona

Podstawy

Interpreter

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.

Zmienne

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)

Wypisywanie na ekranie

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

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.

Polecenie if

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!

Instrukcja while

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

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 ()

Formatowanie napisów

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

    • {:d} liczba całkowita

    • {:f} liczba zmiennoprzecinkowa

    • {:e} liczba zmiennoprzecinkowa w formacie "naukowym"

    • {:s} ciąg znaków

  • 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.

Funkcje

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.

Więcej Pythona

Moduły

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.

Biblioteka standardowa

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ć.

Inne przydatne biblioteki

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

Importowanie modułów

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łasne moduły

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))

Wykresy

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ć.

Podstawowy wykres

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)

Zakres osi

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)

Linie pionowe i poziome

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 i opisy osi

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()

Wiele wykresów

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.

Numpy

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.

Tablice

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]

Funkcje

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).

Operacje I/O

Pliki

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()

Wyjątki

Błędy

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

Programowanie obiektowe

  • 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++).

Klasy

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.

Wyjątki - jeszcze raz

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)

Dane i analiza

Wstęp

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.

Wczytywanie danych

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.

Dane tekstowe

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 152^{152}Eu 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()

Dane binarne

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).

Dopasowywanie krzywych

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 σ\sigma) 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.

CC BY-SA 4.0 Krzysztof Miernik. Last modified: December 06, 2023. Website built with Franklin.jl and the Julia programming language.