FOR · Laboratori

Python per i laboratori

Lab 0: notebook, liste, list comprehension, dizionari
≈ 10 min di lettura · 2106 parole

I laboratori del corso usano Python e il pacchetto Python-MIP. Del linguaggio serve però poco: saper scrivere un ciclo, costruire una lista o un dizionario con una comprensione, definire una funzione. Tutto ciò che compare nei modelli dei capitoli seguenti si riduce a queste poche costruzioni, e questo capitolo le raccoglie in un unico posto. Chi conosce già Python può saltare direttamente alla sezione sui dizionari, che è la struttura dati su cui si appoggiano quasi tutti i modelli, e al troubleshooting finale.

I blocchi di codice contrassegnati dal pulsante Run si eseguono direttamente nel browser: conviene modificarli e rilanciarli. Il codice che usa Python-MIP non è eseguibile nella pagina (il solver è un programma nativo) e compare come listato da leggere.

1. Notebook e Google Colab#

Un notebook (file .ipynb) è una sequenza di celle. Una cella contiene testo formattato in Markdown (con formule LaTeX\LaTeX come 2πxsin(1/x)dx\int_{\sqrt 2}^{\pi} x \sin(1/x)\,dx) oppure codice Python. Una cella di codice si esegue con il pulsante Run o con Shift+Enter; il risultato compare subito sotto e resta lì finché non si rilancia la cella.

Nei laboratori i notebook si aprono su Google Colab: basta un account Google, si carica il file .ipynb e si lavora nel browser senza installare nulla. Ogni notebook dei lab comincia con due celle fisse, l’installazione del pacchetto e la sua importazione:

!pip install mip      # il punto esclamativo esegue un comando di shell dentro Colab
import mip

Il tempo di esecuzione compare a fianco di ogni cella; per le prime celle è zero perché non c’è nulla da calcolare.

Le celle hanno memoria

Python vede le celle come un’unica sequenza di istruzioni. Tutto ciò che una cella crea (variabili, funzioni, vincoli di un modello) resta in memoria finché non si riavvia il kernel con Runtime / Restart. Due conseguenze pratiche:

  • se si modifica una cella bisogna rilanciarla, e poi rilanciare anche le celle che dipendono da essa;
  • una cella eseguita due volte fa il suo lavoro due volte: un l.append(3) lanciato quattro volte aggiunge quattro 3 alla lista.

Il riavvio cancella la memoria e gli output, non il testo delle celle. Un ciclo che non termina si interrompe con il quadrato nero in alto.

2. Primi passi: variabili e stampa#

Una variabile si crea assegnandole un valore con =. Non c’è dichiarazione di tipo e non c’è punto e virgola a fine riga.

La seconda print usa una f-string: la f prima delle virgolette abilita la sostituzione delle espressioni fra parentesi graffe. Le f-string compaiono ovunque nei notebook per stampare soluzioni con un formato controllato, per esempio {obj:10.2f} stampa un numero su 10 caratteri con 2 decimali.

Gli operatori aritmetici sono quelli usuali, con tre aggiunte da ricordare: ** è la potenza (3**4 vale 81), // è la divisione intera e % è il resto della divisione (17 % 6 vale 5).

3. Controllo di flusso e funzioni#

L’esempio guida del Lab 0 è un test di primalità: nn è primo se nessun intero kk fra 2 e n\lfloor\sqrt n\rfloor lo divide. Non serve controllare divisori più grandi di n\sqrt n, perché se n=kqn = k \cdot q con k>nk > \sqrt n allora q<nq < \sqrt n è già stato controllato.

Algoritmo PrimeNumber
Input: n        Output: 1 se n è primo, 0 altrimenti
k = 2
finché k <= sqrt(n):
    se n è divisibile per k: restituisci 0
    incrementa k
restituisci 1

La traduzione in Python incapsula l’algoritmo in una funzione, introdotta da def, che restituisce True o False:

Tre osservazioni, tutte fonti tipiche di errore.

La prima versione dello stesso programma, nel notebook introduttivo, non usa il resto ma confronta divisione intera e divisione reale (n // k == n / k) e salva i divisori trovati in una lista, per poi stamparli tutti, compresi quelli maggiori di n\sqrt n ottenuti come n // d:

Esercizi del Lab 0
  1. Stampare tutti i numeri primi da 2 a 1000 usando PrimeNumber.
  2. Scrivere una funzione che calcoli i primi 30 numeri di Fibonacci, con f1=f2=1f_1 = f_2 = 1 e fk=fk1+fk2f_k = f_{k-1} + f_{k-2}.
Soluzione

Per il primo basta un ciclo con un if; per il secondo si costruisce la lista aggiungendo ogni volta la somma degli ultimi due elementi (fib[-1] e fib[-2] sono l’ultimo e il penultimo).

4. Liste#

Una lista è una sequenza ordinata e modificabile di oggetti, anche di tipo diverso: l = [1, 2, "hey!"]. Le operazioni che ricorrono nei lab:

5. List comprehension#

In matematica un insieme si definisce spesso per comprensione, per esempio S={4i3:i=3,4,,10}S = \{4i^3 : i = 3, 4, \ldots, 10\}. Python permette la stessa scrittura in una riga:

Il risultato è la lista [108, 256, 500, 864, 1372, 2048, 2916, 4000]. Attenzione: S è una lista, non un insieme, quindi ogni elemento ha una posizione, e la posizione non ha relazione con il valore di i che l’ha generato (S[3] è 864, prodotto da i=6i = 6).

La comprensione accetta anche una condizione: da S1={2,,100}S_1 = \{2, \ldots, 100\} si estrae l’insieme dei primi con

S1 = range(2, 101)
S2 = [i for i in S1 if PrimeNumber(i)]
print(S2)

(il notebook originale stampa P invece di S2, un refuso: la variabile definita si chiama S2).

In parole semplici

Una list comprehension si legge da destra a sinistra come una frase: «per ogni i in range(3, 11), prendi 4*i**3». Se c’è un if, si applica prima il filtro e poi si trasforma. È esattamente il modo in cui nei modelli si scrivono le somme iIaixi\sum_{i \in I} a_i x_i e i vincoli «per ogni jJj \in J»: la comprensione è il ciclo for scritto dentro le parentesi.

Un piccolo esercizio del notebook chiede di calcolare 210002^{1000}: gli interi Python non hanno limite di dimensione, quindi 2**1000 si stampa senza problemi, un numero di 302 cifre.

6. Dizionari e tuple#

6.1 Dizionari#

Un dizionario associa a ogni chiave un valore. È la struttura più usata nei laboratori, perché le variabili e i vincoli di un modello sono indicizzati da insiemi che spesso non sono numeri consecutivi (coppie di nodi, nomi di prodotti).

Un dizionario vuoto è {}; d.keys(), d.values() e d.items() restituiscono rispettivamente le chiavi, i valori e le coppie, e permettono di iterare su tutto il contenuto. Come le liste, anche i dizionari si costruiscono per comprensione: {i: len(i) for i in names} associa a ogni nome la sua lunghezza.

L’esempio del notebook sulle popolazioni combina comprensione e filtro sulle chiavi:

Esercizio: medie e massimi su un dizionario

Stampare la popolazione media dei paesi il cui nome termina per y, poi la popolazione massima fra i paesi con nome di 7 lettere. Servono solo len(s), s[-1] (ultimo carattere di una stringa) e max(l).

Soluzione

I paesi che finiscono per y sono Germany, Italy e Norway, con media 49272.3 migliaia; quelli con sette lettere sono Austria, Belgium, Denmark, Finland e Germany, e il massimo è la Germania con 83191.

6.2 Tuple#

Una tupla è una sequenza immutabile: t = (a1, a2, a3). Si accede agli elementi come in una lista (t[0]), ma non si può modificare un elemento dopo la creazione: t[2] = 8 produce un errore, mentre su una lista funziona.

Il motivo per cui le tuple contano nei lab è che possono fare da chiave di un dizionario, le liste no. Un dizionario come {(1,2): 12, (3,6): 2} associa coppie a numeri: è esattamente la forma delle variabili xijx_{ij} indicizzate su archi di un grafo nel capitolo 2. L’esempio del notebook costruisce, per coppie (x,y)(x, y), le coordinate polari ρ=x2+y2\rho = \sqrt{x^2 + y^2} e θ=arctan(y/x)\theta = \arctan(y/x):

La doppia for dentro la comprensione (for i in S1 for j in S2) enumera tutte le coppie: è il prodotto cartesiano S1×S2S_1 \times S_2, la stessa costruzione con cui si genera l’insieme degli archi di un grafo.

Esercizio: fattori primi
  1. Completare la funzione prime_factors che, dato un numero, restituisce la lista dei suoi fattori primi.
  2. Costruire un dizionario che associa a ogni numero da 2 a 100 la lista dei suoi fattori primi (a 57 deve corrispondere [3, 19]).
  3. Costruire un dizionario che associa a ogni kk da 2 a 50 il numero di interi positivi sotto 100 di cui kk è fattore primo (a 7 deve corrispondere 14: i multipli 7, 14, …, 98).
def prime_factors(n):
    factors = []
    decomp = n
    i = 2
    while i <= decomp and i <= n:
        while decomp % i == 0:
            decomp = decomp / i
            if i not in factors:
                # TODO: aggiungere i a factors
        i += 1
    return factors
Soluzione

L’istruzione mancante è factors.append(i). I due dizionari si costruiscono per comprensione; per il terzo si contano, per ogni kk, i numeri la cui lista di fattori contiene kk.

Osserviamo che conteggio[k] è zero per ogni kk non primo, perché un numero composto non compare mai come fattore primo.

7. NumPy e Matplotlib#

Due moduli esterni compaiono nei lab sui grafi. NumPy offre i multiarray (vettori, matrici) con operazioni elemento per elemento e prodotto matriciale con @; Matplotlib disegna i grafici. Si importano con un alias per brevità.

Con np.random.random((k, 2)) si generano kk coppie di numeri casuali in [0,1)[0, 1); moltiplicando per 100 si ottengono punti in un quadrato di lato 100, la costruzione con cui il Lab 2 crea le istanze. Un grafico a dispersione dei punti si ottiene con plt.scatter(point[:,0], point[:,1]) seguito da plt.show(), dove point[:,0] seleziona la prima colonna.

8. Errori frequenti e troubleshooting#

La sezione conclusiva del primo notebook del Lab 1 raccoglie le cause più comuni di errore. Vale la pena conoscerle prima di scrivere il primo modello.

Indentazione. In un for, in un if o in una def il corpo deve essere rientrato in modo coerente. Danno errore sia un corpo non rientrato, sia due righe con rientro diverso nello stesso blocco:

for i in [1,2,3]:
print(i)                # errore: manca il rientro

if i==4:
    print('i is 4')
  print('deal with it') # errore: rientro incoerente

Il rientro consigliato è di 4 spazi.

Assegnamento e uguaglianza. = assegna, == confronta. if a = 4 è un errore di sintassi; a == 4 da solo è lecito ma non fa nulla (restituisce True o False). Questo distinguo torna nei vincoli dei modelli: un vincolo di uguaglianza si scrive con ==, mai con =.

Istruzioni su più righe. Una condizione lunga si spezza aggiungendo \ a fine riga, oppure lasciando aperta una parentesi: dentro parentesi non chiuse il ritorno a capo è libero. È il caso più frequente nei modelli, dove m.add_constr(...) occupa spesso due o tre righe.

if (i==3 or i==5 or
    i==7):
    print('i is prime')

Persistenza delle celle. È l’errore più insidioso perché non produce alcun messaggio. Il notebook lo illustra con un modello Python-MIP: si risolve maxx1+x2\max x_1 + x_2 con il vincolo 2x1+x2102x_1 + x_2 \le 10 e si ottiene (0,10)(0, 10). Poi, per «rilassare» il problema, si aggiunge in una nuova cella il vincolo 2x1+x2202x_1 + x_2 \le 20 e si riottimizza:

m.add_constr(2*x1 + x2 <= 20)
m.optimize()
print('solution:', x1.x, ',', x2.x)      # ancora 0.0 , 10.0

La soluzione non cambia. Il modello ora ha due vincoli, quello vecchio e quello nuovo, e il più restrittivo continua a valere: add_constr aggiunge, non sostituisce. Per modificare un vincolo si torna nella cella che lo definisce, la si modifica e si rilanciano tutte le celle da lì in avanti; nel dubbio si riavvia il kernel e si esegue tutto dall’inizio.

Stile. Chi vuole verificare che il proprio codice segua lo standard può eseguire flake8 myprogram.py, che segnala ogni deviazione (di solito molte).

Costruzione Sintassi Dove serve nei lab
Lista per comprensione [expr for i in S if cond] vettori di variabili [m.add_var() for i in range(n)]
Dizionario per comprensione {k: expr for k in S} variabili su archi {(i,j): m.add_var() for (i,j) in A}
Doppio for for i in V for j in V insiemi di archi e coppie
Somma di un generatore sum(expr for i in S) in Python-MIP diventa mip.xsum(...)
Tupla come chiave x[i,j] accesso alle variabili indicizzate su coppie
range(n) da 0 a n1n-1 indici delle variabili, sempre da zero
f-string f"{valore:8.2f}" stampa formattata delle soluzioni

Elaborato dai tre notebook introduttivi del Lab 0 e dalla sezione «Miscellanea and troubleshooting» del Lab 1.