Salta al contenuto
Note per Studenti Esercizio - Route finding sulla Romania e pancake sorting con euristica del gap (laboratorio 3)

Esercizio - Route finding sulla Romania e pancake sorting con euristica del gap (laboratorio 3)

In questa pagina 3

Esercizi extra del laboratorio 3 del corso di Intelligenza Artificiale, Ing. dell'Informazione UniPD: due domini di prova per gli algoritmi di ricerca di Esercizio - Algoritmi di ricerca in Python con Problem, Node e best-first search (laboratorio 3), cioè la ricerca di percorsi su una mappa (route finding) e l'ordinamento di una pila di frittelle (pancake sorting) con l'euristica del gap. Teoria: Ricerca nello spazio degli stati - formulazione dei problemi e ricerca non informataUn agente risolutore di problemi è un agente basato su obiettivi con rappresentazione atomica: formula un problema (stato iniziale, azioni, modello di transizione, test dell'obiettivo, costo dei passi) e cerca una sequenza di azioni che porta a uno stato obiettivo costruendo un albero di ricerca. Le strategie non informate usano solo la definizione del problema e si distinguono per l'ordine di espansione della frontiera: ampiezza (coda FIFO), costo uniforme (coda con priorità sul costo g), profondità (pila LIFO), profondità limitata, approfondimento iterativo e ricerca bidirezionale. Si confrontano per completezza, ottimalità, tempo e spazio in funzione di b, d, m; tutte sono casi di best-first search con una diversa funzione di valutazione f.Ricerca nello spazio degli stati - formulazione dei problemi e ricerca non informata → e Ricerca informata - greedy best-first, A* ed euristicheLa ricerca informata usa una funzione euristica h(n) che stima il costo da n all'obiettivo. Greedy best-first espande il nodo con h minima (f = h): veloce ma non ottima né in generale completa. A∗ usa f = g + h e, con h ammissibile (mai sopra il costo vero) e, sul grafo con tabella dei raggiunti, consistente, è completa e ottima; il fattore di diramazione effettivo b∗ misura la qualità di h, e un'euristica che ne domina un'altra espande meno nodi. A∗ pesato (f = g + W h) rinuncia all'ottimalità per espandere meno nodi. Per i problemi in cui conta solo lo stato finale esiste la ricerca locale (hill climbing, simulated annealing, beam search, algoritmi genetici). In appendice, cenni di ricerca avversaria (minimax e potatura alfa-beta).Ricerca informata - greedy best-first, A* ed euristiche →. Il codice presuppone le classi Problem, Node e gli algoritmi dell'esercizio precedente.

Parte 1 - Route finding

Testo (traduzione). In un RouteProblem gli stati sono nomi di "città" (o altri luoghi), come 'A' per Arad. Anche le azioni sono nomi di città: 'Z' è l'azione che porta alla città 'Z'. La disposizione delle città è data da una struttura dati a parte, una Map, cioè un grafo con vertici (città), collegamenti tra vertici, distanze (costi) di quei collegamenti (se non sono specificate, il valore predefinito è 1 per ogni collegamento) e, facoltativamente, la posizione 2D (x,y)(x, y) di ogni città. Un RouteProblem prende questa Map in ingresso e permette azioni che si spostano tra città collegate. L'euristica predefinita è la distanza in linea retta dall'obiettivo, o è uniformemente zero se le posizioni non sono date.

La classe Map

Il grafo si passa come links, un dizionario {(v1, v2): distanza} oppure un elenco di coppie [(v1, v2), ...] (allora ogni distanza è 1: hasattr(links, 'items') controlla se l'oggetto è un dizionario). Se il grafo non è orientato (predefinito), per ogni collegamento (v1,v2)(v_1, v_2) si aggiunge anche (v2,v1)(v_2, v_1). I vicini sono memorizzati in una multimappa, un dizionario vertice -> lista di vicini; se mancano le posizioni, ogni città ha la posizione (0,0)(0, 0) (defaultdict).

python
def multimap(pairs) -> dict:
    "Date coppie (chiave, valore), costruisce {chiave: [valore, ...]}."
    result = defaultdict(list)
    for key, val in pairs:
        result[key].append(val)
    return result

class Map:
    def __init__(self, links, locations=None, directed=False):
        if not hasattr(links, 'items'):          # distanze 1 per default
            links = {link: 1 for link in links}
        if not directed:
            for (v1, v2) in list(links):
                links[v2, v1] = links[v1, v2]
        self.distances = links
        self.neighbors = multimap(links)
        self.locations = locations or defaultdict(lambda: (0, 0))

multimap applicata ai collegamenti della Romania dà, per esempio, 'A': ['Z', 'S', 'T'], 'O': ['Z', 'S'], 'B': ['F', 'P', 'G', 'U'] (l'ordine dei vicini segue l'ordine del dizionario e cambia l'ordine di espansione a parità di punteggio).

RouteProblem e distanza in linea retta

python
class RouteProblem(Problem):
    def actions(self, state):
        "Le località vicine a state."
        return self.map.neighbors[state]

    def result(self, state, action):
        "Va nella località action, se la mappa lo permette."
        return action if action in self.map.neighbors[state] else state

    def action_cost(self, s, action, s1):
        "La distanza per andare da s a s1."
        return self.map.distances[s, s1]

    def h(self, node):
        "Distanza in linea retta tra lo stato e l'obiettivo."
        locs = self.map.locations
        return straight_line_distance(locs[node.state], locs[self.goal])

def straight_line_distance(A, B):
    return sum(abs(a - b)**2 for (a, b) in zip(A, B)) ** 0.5

zip(A, B) accoppia le coordinate corrispondenti (le tuple (x1, y1) e (x2, y2) danno le coppie (x1,x2)(x_1, x_2) e (y1,y2)(y_1, y_2)); la somma dei quadrati delle differenze, elevata a 1/21/2, è la distanza euclidea (x1−x2)2+(y1−y2)2\sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}. Per Arad (76,497)(76, 497) e Bucarest (400,327)(400, 327): 3242+1702=133 876≈365,9\sqrt{324^2 + 170^2} = \sqrt{133\,876} \approx 365{,}9, cioè i 366366 km della tabella delle slide: le coordinate sono in scala con i chilometri.

I problemi di prova

python
romania = Map(
    {('O', 'Z'):  71, ('O', 'S'): 151, ('A', 'Z'): 75, ('A', 'S'): 140, ('A', 'T'): 118,
     ('L', 'T'): 111, ('L', 'M'):  70, ('D', 'M'):  75, ('C', 'D'): 120, ('C', 'R'): 146,
     ('C', 'P'): 138, ('R', 'S'):  80, ('F', 'S'):  99, ('B', 'F'): 211, ('B', 'P'): 101,
     ('B', 'G'):  90, ('B', 'U'):  85, ('H', 'U'):  98, ('E', 'H'):  86, ('U', 'V'): 142,
     ('I', 'V'):  92, ('I', 'N'):  87, ('P', 'R'):  97},
    {'A': ( 76, 497), 'B': (400, 327), 'C': (246, 285), 'D': (160, 296), 'E': (558, 294),
     'F': (285, 460), 'G': (368, 257), 'H': (548, 355), 'I': (488, 535), 'L': (162, 379),
     'M': (160, 343), 'N': (407, 561), 'O': (117, 580), 'P': (311, 372), 'R': (227, 412),
     'S': (187, 463), 'T': ( 83, 414), 'U': (471, 363), 'V': (535, 473), 'Z': ( 92, 539)})

r0 = RouteProblem('A', 'A', map=romania)   # partenza = arrivo
r1 = RouteProblem('A', 'B', map=romania)   # Arad -> Bucarest
r2 = RouteProblem('N', 'L', map=romania)   # Neamt -> Lugoj
r3 = RouteProblem('E', 'T', map=romania)   # Eforie -> Timisoara
r4 = RouteProblem('O', 'M', map=romania)   # Oradea -> Mehadia

I collegamenti sono quelli della mappa della teoria. Il problema r0 è banale: il test dell'obiettivo sullo stato iniziale riesce subito, quindi nessun nodo è generato. I percorsi trovati, con costo (tutti i cammini sono stati verificati con il codice del corso):

problema A∗ e costo uniforme (ottimo) greedy ampiezza (meno passi)
r1, A →\to B A S R P B, 418 A S F B, 450 A S F B, 450
r2, N →\to L N I V U B P C D M L, 910 N I V U B F S R C D M L, 1207 N I V U B F S A T L, 1085
r3, E →\to T E H U B P R S A T, 805 E H U B F S A T, 837 E H U B F S A T, 837
r4, O →\to M O Z A T L M, 445 O S R C D M, 572 O Z A T L M, 445

Si vedono i comportamenti attesi: A∗ e il costo uniforme danno sempre l'ottimo (A∗ con meno espansioni: per r1, 6 contro 13); il greedy dà cammini più lunghi, anche molto (1207 contro 910 per r2); l'ampiezza minimizza i passi, non il costo (1085 contro 910 in r2; in r4 capita che coincida con l'ottimo).

Parte 2 - Pancake sorting

Testo (traduzione). Data una pila di frittelle di taglie diverse, si possono ordinare in una pila di taglie decrescenti, con la più grande in basso e la più piccola in alto? Si ha una spatola con cui si possono capovolgere le prime ii frittelle della pila (per i=3i = 3, la spatola afferra le prime tre frittelle e le capovolge). Quante inversioni servono per ordinare tutta la pila? (È un problema di ordinamento; fonte: Jones e Pevzner, An Introduction to Bioinformatics Algorithms.)

Rappresentazione. Le frittelle sono classificate per taglia: la più piccola è 1, la seconda 2, e così via. Uno stato è la tupla delle taglie, dalla frittella in alto a quella in basso; l'obiettivo è sempre (1, 2, ..., n); lo stato iniziale della figura è (2, 1, 4, 6, 3, 5). Un'azione è l'indice ii delle frittelle da capovolgere (i=2,…,ni = 2, \dots, n: con i=1i = 1 non cambia niente). Capovolgere le prime ii significa rovesciare il prefisso e lasciare il resto: state[:i][::-1] + state[i:]. Con i=3i = 3 su (2, 1, 4, 6, 3, 5): state[:3] = (2, 1, 4), rovesciata (4, 1, 2), resto (6, 3, 5), risultato (4, 1, 2, 6, 3, 5). Si usano tuple (immutabili e hashable) perché gli stati servono come chiavi della tabella reached.

Euristica del gap. Se due frittelle vicine hanno taglie consecutive (la seconda più piccola vicina alla terza), è bene: devono restare vicine. Se la seconda più piccola è vicina alla quarta, è male: servirà almeno una mossa per separarle e infilare la terza in mezzo. L'euristica del gap conta il numero di vicini con un gap di questo tipo, cioè con taglie che differiscono per più di 1.

python
class PancakeProblem(Problem):
    """Obiettivo sempre tuple(range(1, n+1)); lo stato iniziale è una permutazione.
    Un'azione è l'indice i delle prime i frittelle da capovolgere."""

    def __init__(self, initial):
        self.initial, self.goal = tuple(initial), tuple(sorted(initial))

    def actions(self, state): return range(2, len(state) + 1)

    def result(self, state, i): return state[:i][::-1] + state[i:]

    def h(self, node):
        "L'euristica del gap."
        s = node.state
        return sum(abs(s[i] - s[i - 1]) > 1 for i in range(1, len(s)))

c0 = PancakeProblem((2, 1, 4, 6, 3, 5))
c1 = PancakeProblem((4, 6, 2, 5, 1, 3))
c2 = PancakeProblem((1, 3, 7, 5, 2, 6, 4))
c3 = PancakeProblem((1, 7, 2, 6, 3, 5, 4))
c4 = PancakeProblem((1, 3, 5, 7, 9, 2, 4, 6, 8))

La somma di booleani conta i True (True vale 1). Nelle slide il calcolo per (2, 1, 4, 6, 3, 5) è passo passo: coppia (2,1)(2, 1) differenza 11, no; (1,4)(1, 4) differenza 33, sì (+1+1); (4,6)(4, 6) differenza 22, sì (+2+2); (6,3)(6, 3) differenza 33, sì (+3+3); (3,5)(3, 5) differenza 22, sì (+4+4). Dunque h=4h = 4.

Soluzione di c0 con A∗.

python
path_states(astar_search(c0))
[(2, 1, 4, 6, 3, 5),
 (6, 4, 1, 2, 3, 5),
 (5, 3, 2, 1, 4, 6),
 (4, 1, 2, 3, 5, 6),
 (3, 2, 1, 4, 5, 6),
 (1, 2, 3, 4, 5, 6)]

Le azioni sono capovolgere le prime 4,6,5,4,34, 6, 5, 4, 3 frittelle: 5 inversioni. Si controlla a mano il primo passo, i=4i = 4: (2, 1, 4, 6) rovesciata dà (6, 4, 1, 2), resto (3, 5): (6, 4, 1, 2, 3, 5). I valori di hh lungo il cammino sono 4,3,3,2,1,04, 3, 3, 2, 1, 0: per ogni passo hh scende al più di 1 (ogni inversione crea o rimuove al più un'adiacenza problematica), perciò con costo 1 per passo l'euristica è consistente.

Soluzioni ottime trovate da A∗ per gli altri stati: c1 7 inversioni, c2 8, c3 6, c4 9.

L'euristica del gap è ammissibile e consistente. Ogni inversione di un prefisso cambia al più una coppia di vicini (quella al bordo del prefisso; dentro il prefisso le coppie restano coppie, solo in ordine inverso), quindi hh cala al più di 1 per passo e vale 0 solo sull'obiettivo: h(s)≤1+h(s′)h(s) \le 1 + h(s') e di conseguenza h(s)≤h∗(s)h(s) \le h^\ast(s). Verifica sperimentale: con una ricerca in ampiezza all'indietro dall'obiettivo (le inversioni sono reversibili) si calcolano le distanze vere di tutte le permutazioni; per n=4,5,6,7n = 4, 5, 6, 7 la disuguaglianza h≤h∗h \le h^\ast (ammissibilità) e h(s)≤1+h(s′)h(s) \le 1 + h(s') per ogni vicino (consistenza) valgono sempre. La distanza massima è 4, 5, 7, 8 inversioni per n=4,5,6,7n = 4, 5, 6, 7.

Nota. L'euristica del corso non conta il bordo con il piatto sotto la pila: la versione completa aggiunge 1 se la frittella in fondo non è la più grande. Resta ammissibile e più accurata (per n=6n = 6 coincide con la distanza vera in circa il 62% degli stati, per n=5n = 5 nel 67%).

Confronto degli algoritmi sui dieci problemi

Con la funzione report (Esercizio - Confronto degli algoritmi di ricerca con CountCalls, report e grafici (laboratorio 3)) si contano i nodi generati. Per i problemi delle frittelle (c0–c4), dalle esecuzioni del corso:

algoritmo c0 c1 c2 c3 c4 inversioni (costo)
astar_search 155 1285 3804 294 2256 5, 7, 8, 6, 9
greedy_bfs 35 40 66 60 104 6, 7, 9, 8, 11
uniform_cost_search 2050 3590 30204 22068 2271792 5, 7, 8, 6, 9

(nodi generati per problema). Greedy genera pochissimi nodi ma non è ottimo (9 inversioni contro 8 per c2; 11 contro 9 per c4); A∗ è ottimo con una frazione dei nodi del costo uniforme (per c4, 2256 contro più di 2,2 milioni). L'euristica del gap è una buona euristica: il fattore di diramazione effettivo scende di molto (Ricerca informata - greedy best-first, A* ed euristicheLa ricerca informata usa una funzione euristica h(n) che stima il costo da n all'obiettivo. Greedy best-first espande il nodo con h minima (f = h): veloce ma non ottima né in generale completa. A∗ usa f = g + h e, con h ammissibile (mai sopra il costo vero) e, sul grafo con tabella dei raggiunti, consistente, è completa e ottima; il fattore di diramazione effettivo b∗ misura la qualità di h, e un'euristica che ne domina un'altra espande meno nodi. A∗ pesato (f = g + W h) rinuncia all'ottimalità per espandere meno nodi. Per i problemi in cui conta solo lo stato finale esiste la ricerca locale (hill climbing, simulated annealing, beam search, algoritmi genetici). In appendice, cenni di ricerca avversaria (minimax e potatura alfa-beta).Ricerca informata - greedy best-first, A* ed euristiche →).

Versione ripasso

  • Map(links, locations=None, directed=False): links è un dizionario {(v1, v2): distanza} o un elenco di coppie (distanza 1 per default, hasattr(links, 'items')); se non orientato si aggiunge (v2, v1); distances, neighbors (multimappa vertice -> lista, multimap con defaultdict(list)), locations (default (0,0)(0,0)).
  • RouteProblem: stati = città, azioni = città vicine; actions = map.neighbors[state]; result va nella città se è vicina, altrimenti resta; action_cost = map.distances[s, s1]; h = straight_line_distance (con zip, ∑(a−b)2\sqrt{\sum (a - b)^2}). Arad (76,497)(76,497), Bucarest (400,327)(400,327): 3242+1702≈365,9\sqrt{324^2 + 170^2} \approx 365{,}9.
  • Problemi: r0 A →\to A (nessun nodo), r1 A →\to B, r2 N →\to L, r3 E →\to T, r4 O →\to M. Costi ottimi (A∗ e costo uniforme): 418, 910, 805, 445. Greedy: 450, 1207, 837, 572. Ampiezza (meno passi): 450, 1085, 837, 445.
  • Pancake: stato = tupla delle taglie dall'alto, obiettivo tuple(sorted(...)); azione i∈{2,…,n}i \in \{2, \dots, n\}, result = state[:i][::-1] + state[i:] ((2,1,4,6,3,5) con i=3i = 3 dà (4,1,2,6,3,5)); tuple immutabili e hashable.
  • Gap: sum(abs(s[i] - s[i-1]) > 1 for i in range(1, len(s))) conta i vicini con taglie che differiscono per più di 1; (2,1,4,6,3,5) ha h=4h = 4 (coppie (1,4)(1,4), (4,6)(4,6), (6,3)(6,3), (3,5)(3,5)).
  • c0 con A∗: (2,1,4,6,3,5) →\to (6,4,1,2,3,5) →\to (5,3,2,1,4,6) →\to (4,1,2,3,5,6) →\to (3,2,1,4,5,6) →\to (1,2,3,4,5,6); capovolgere 4, 6, 5, 4, 3 frittelle: 5 inversioni; hh lungo il cammino 4,3,3,2,1,04, 3, 3, 2, 1, 0. Ottimi: c1 7, c2 8, c3 6, c4 9.
  • Gap ammissibile e consistente: un'inversione cambia al più una coppia al bordo del prefisso, quindi hh cala al più di 1 per passo; verificato su tutte le permutazioni con n≤7n \le 7 (distanza massima 4, 5, 7, 8). Il gap completo conta anche il piatto (+1 se in fondo non c'è la più grande).
  • Nodi generati sulle frittelle (c0–c4): A∗ 155, 1285, 3804, 294, 2256; greedy 35, 40, 66, 60, 104 (non ottimo: 6, 7, 9, 8, 11 inversioni); costo uniforme 2050, 3590, 30204, 22068, 2271792.
  • Errori tipici: usare liste come stati; contare anche i=1i = 1 come azione; dimenticare che l'ordine dei vicini nella multimappa cambia l'ordine di espansione a parità di punteggio; confondere ottimalità sui passi (ampiezza) e sul costo (A∗, costo uniforme).

Esercizi su questo argomento

Teoria collegata