Esercizio - Algoritmi di ricerca in Python con Problem, Node e best-first search (laboratorio 3)
In questa pagina 9
Laboratorio 3 del corso di Intelligenza Artificiale, Ing. dell'Informazione UniPD: gli algoritmi di ricerca della 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 →, 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 →) realizzati in Python con classi, con il codice del corso commentato. Argomenti del laboratorio: formulazione di un problema, albero di ricerca, nodo, code, best-first search; greedy best-first, A∗, A∗ pesato, costo uniforme; ampiezza; approfondimento iterativo. Il laboratorio usa le classi del laboratorio 2 (Esercizio - Programmazione a oggetti in Python - listino, carrello, copie e ristorante (laboratorio 2)). Le applicazioni (mappa della Romania, pancake sorting) sono in Esercizio - Route finding sulla Romania e pancake sorting con euristica del gap (laboratorio 3), il confronto tra gli algoritmi in Esercizio - Confronto degli algoritmi di ricerca con CountCalls, report e grafici (laboratorio 3).
Testo (traduzione delle slide)
Introduzione. La ricerca si può definire come i passi per trovare una sequenza di azioni che porta allo stato obiettivo.
Formulazione del problema.
- Stato iniziale: lo stato in cui l'agente si trova all'inizio.
- Azioni: l'insieme delle azioni applicabili nello stato corrente;
ACTIONS(s)restituisce le azioni applicabili in . - Modello di transizione: definisce la conseguenza di ogni azione sullo stato;
RESULT(s, a)restituisce lo stato raggiunto applicando in . - Spazio degli stati: (a) è definito dallo stato iniziale, dalle azioni e dal modello di transizione; (b) contiene tutti gli stati ottenibili applicando una sequenza di azioni.
- Test dell'obiettivo: verifica quando è raggiunto uno stato obiettivo;
IS_GOAL(s)restituisceTruese è uguale all'obiettivo. - Funzione di costo del cammino: misura la qualità di una soluzione valutando il costo di ogni cammino (la sequenza di stati dello spazio degli stati data da una sequenza di azioni); è il costo di un passo per raggiungere da con l'azione (
ACTION_COST(s, a, s')). La soluzione ottima ha il costo di cammino minimo.
Albero di ricerca. Si considerano algoritmi che sovrappongono un albero di ricerca al grafo dello spazio degli stati, formando vari cammini a partire dallo stato iniziale e cercando di trovarne uno che raggiunge uno stato obiettivo. Ogni nodo dell'albero corrisponde a uno stato dello spazio degli stati e i rami corrispondono alle azioni. La radice corrisponde allo stato iniziale.
Code. Negli algoritmi di ricerca si usano tre tipi di coda: FIFO (primo a entrare, primo a uscire: deque), LIFO (ultimo a entrare, primo a uscire: list), coda con priorità (esce l'elemento di priorità più alta, classe PriorityQueue basata su heap; Code con prioritàEntry chiave-valore; ADT coda con priorità (insert, min, removeMin) con chiave minima = priorità massima; esempio di esecuzione; applicazioni; implementazioni con lista non ordinata e ordinata e relativi costi; ordinamento tramite coda con priorità.Code con priorità →).
Da implementare e capire: Problem, Node, best_first_search; da questa, greedy best-first, A∗, A∗ pesato, costo uniforme (e ampiezza e profondità come casi particolari); poi ampiezza con coda FIFO e approfondimento iterativo.
Il problema: la classe Problem
Una classe astratta che rappresenta il problema formale: ogni dominio (mappa, puzzle, ...) la estende ridefinendo actions e result (e, se serve, le altre) (Classi ed ereditarietà in PythonAttributi di istanza e di classe, metodi di istanza, di classe e statici; confronto e ordinamento con eq e lt; proprietà; ereditarietà, super(), override e polimorfismo; classi astratte come interfacce; eccezioni personalizzate; overloading e shadowing in Python.Classi ed ereditarietà in Python →). L'euristica predefinita è 0 e il costo predefinito di ogni azione è 1.
import math, sys, heapq
from collections import defaultdict, deque, Counter
class Problem(object):
def __init__(self, initial=None, goal=None, **kwds):
self.__dict__.update(initial=initial, goal=goal, **kwds)
def actions(self, state): raise NotImplementedError # azioni applicabili
def result(self, state, action): raise NotImplementedError # stato raggiunto
def is_goal(self, state): return state == self.goal
def action_cost(self, s, a, s1): return 1
def h(self, node): return 0 # euristica nulla
def __str__(self):
return '{}({!r}, {!r})'.format(type(self).__name__, self.initial, self.goal)self.__dict__.update(...) trasforma ogni argomento con nome in un attributo (graph=... diventa self.graph): così le sottoclassi non hanno bisogno di un costruttore.
Il nodo: la classe Node
Un nodo dell'albero di ricerca contiene stato, nodo genitore, azione che l'ha generato e costo del cammino (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 →). Tre metodi speciali: __repr__ (come lo si stampa), __len__ (la profondità del nodo: 0 per la radice, altrimenti 1 più quella del genitore) e __lt__ (confronto per costo, che serve a heapq quando due nodi hanno la stessa priorità).
class Node:
"Un nodo dell'albero di ricerca."
def __init__(self, state, parent=None, action=None, path_cost=0):
self.__dict__.update(state=state, parent=parent, action=action, path_cost=path_cost)
def __repr__(self): return '<{}>'.format(self.state)
def __len__(self): return 0 if self.parent is None else (1 + len(self.parent))
def __lt__(self, other): return self.path_cost < other.path_cost
failure = Node('failure', path_cost=math.inf) # nessuna soluzione
cutoff = Node('cutoff', path_cost=math.inf) # ricerca a profondità limitata troncata
def expand(problem, node):
"Genera i figli di un nodo."
s = node.state
for action in problem.actions(s):
s1 = problem.result(s, action)
cost = node.path_cost + problem.action_cost(s, action, s1)
yield Node(s1, node, action, cost)
def path_actions(node):
"Le azioni dalla radice al nodo."
if node.parent is None:
return []
return path_actions(node.parent) + [node.action]
def path_states(node):
"Gli stati dalla radice al nodo."
if node in (cutoff, failure, None):
return []
return path_states(node.parent) + [node.state]failure e cutoff sono nodi "sentinella" con costo infinito: il valore restituito da una ricerca che non trova niente è ancora un Node, così search(...).path_cost e len(...) funzionano sempre. expand è un generatore (yield): produce un figlio alla volta. path_actions e path_states risalgono i genitori per ricostruire la soluzione.
Le code
FIFOQueue = deque # coda: aggiungere con appendleft, togliere con pop (dalla parte opposta)
LIFOQueue = list # pila: aggiungere con append, togliere con pop
class PriorityQueue:
"Coda in cui l'elemento con f(item) minimo esce per primo."
def __init__(self, items=(), key=lambda x: x):
self.key = key
self.items = [] # uno heap di coppie (punteggio, elemento)
for item in items:
self.add(item)
def add(self, item):
heapq.heappush(self.items, (self.key(item), item))
def pop(self):
return heapq.heappop(self.items)[1]
def top(self): return self.items[0][1]
def __len__(self): return len(self.items)heapq mantiene la lista come heap minimo: heappush e heappop costano . Gli elementi sono coppie (punteggio, nodo): se due punteggi sono uguali Python confronta i nodi, ed è per questo che Node definisce __lt__.
Best-first search
L'algoritmo generale: si estrae sempre il nodo con minima. È la ricerca su grafo con la tabella reached (degli stati raggiunti con il costo migliore):
def best_first_search(problem, f):
"Cerca prima i nodi con f(node) minima."
node = Node(problem.initial)
frontier = PriorityQueue([node], key=f)
reached = {problem.initial: node}
while frontier:
node = frontier.pop()
if problem.is_goal(node.state):
return node
for child in expand(problem, node):
s = child.state
if s not in reached or child.path_cost < reached[s].path_cost:
reached[s] = child
frontier.add(child)
return failureUn figlio entra nella frontiera se il suo stato non è mai stato raggiunto, oppure se è raggiunto ora con un costo minore di prima. L'obiettivo si controlla all'estrazione (così il costo uniforme e A∗ sono ottimi). Esiste anche una versione senza reached (best_first_tree_search), che scarta solo i figli che formano un ciclo con i propri antenati (is_cycle, fino a profondità ):
def is_cycle(node, k=30):
"Il nodo forma un ciclo di lunghezza al più k?"
def find_cycle(ancestor, k):
return (ancestor is not None and k > 0 and
(ancestor.state == node.state or find_cycle(ancestor.parent, k - 1)))
return find_cycle(node.parent, k)Osservazione sull'implementazione: quando un figlio è trovato per una seconda volta con un costo minore, la versione vecchia resta nello heap (heapq non sa rimuovere), e verrà estratta ed espansa comunque più tardi, producendo figli che reached poi scarta. È uno spreco, non un errore (vedi l'esempio sotto: il nodo è espanso due volte).
Da best-first search agli altri algoritmi
Basta cambiare la funzione di valutazione :
| algoritmo | codice | |
|---|---|---|
| costo uniforme | best_first_search(problem, f=g) |
|
| greedy best-first | best_first_search(problem, f=h) |
|
| A∗ | best_first_search(problem, f=lambda n: g(n) + h(n)) |
|
| A∗ pesato | best_first_search(problem, f=lambda n: g(n) + weight * h(n)) |
|
| ampiezza (via best-first) | profondità | f=len |
| profondità (via best-first) | profondità | f=lambda n: -len(n) |
def g(n): return n.path_cost
def astar_search(problem, h=None):
h = h or problem.h
return best_first_search(problem, f=lambda n: g(n) + h(n))
def weighted_astar_search(problem, h=None, weight=1.4):
h = h or problem.h
return best_first_search(problem, f=lambda n: g(n) + weight * h(n))
def greedy_bfs(problem, h=None):
h = h or problem.h
return best_first_search(problem, f=h)
def uniform_cost_search(problem):
return best_first_search(problem, f=g)
def breadth_first_bfs(problem):
return best_first_search(problem, f=len)
def depth_first_bfs(problem):
return best_first_search(problem, f=lambda n: -len(n))Il peso predefinito di weighted_astar_search è . Per breadth_first_bfs basta ricordare che len(n) è la profondità del nodo (Visite di grafi - BFS e DFSVisite in ampiezza (BFS) e in profondità (DFS) come design pattern; etichette discovery, cross e back edge; BFS tree e distanze; complessità Theta(n+m) con liste di adiacenza; applicazioni (connettività, componenti, spanning tree, cammini minimi non pesati, cicli, vertici a distanza al più d); esempio svolto su un grafo di 7 vertici.Visite di grafi - BFS e DFS →).
Ampiezza con coda FIFO, profondità limitata e approfondimento iterativo
L'ampiezza "vera" non ha bisogno di una coda con priorità: una coda FIFO basta. Il test dell'obiettivo si fa alla generazione del figlio (con costo uniforme dei passi non si perde l'ottimalità e si risparmia un livello), e un insieme reached evita i ripetuti:
def breadth_first_search(problem):
"Cerca prima i nodi meno profondi."
node = Node(problem.initial)
if problem.is_goal(problem.initial):
return node
frontier = deque([node])
reached = {problem.initial}
while frontier:
node = frontier.pop() # esce dalla destra
for child in expand(problem, node):
s = child.state
if problem.is_goal(s):
return child
if s not in reached:
reached.add(s)
frontier.appendleft(child) # entra da sinistra
return failure(appendleft e pop agiscono da lati opposti: è una FIFO.) La ricerca a profondità limitata è una DFS con pila (list) e limite sulla profondità; restituisce cutoff se il limite ha troncato qualcosa:
def depth_limited_search(problem, limit=10):
"Cerca prima i nodi più profondi, fino alla profondità limit."
frontier = [Node(problem.initial)]
result = failure
while frontier:
node = frontier.pop()
if problem.is_goal(node.state):
return node
elif len(node) >= limit:
result = cutoff
elif not is_cycle(node):
for child in expand(problem, node):
frontier.append(child)
return result
def iterative_deepening_search(problem):
"Ricerca a profondità limitata con limite crescente."
for limit in range(1, sys.maxsize):
result = depth_limited_search(problem, limit)
if result != cutoff:
return resultL'approfondimento iterativo prova e restituisce appena il risultato non è cutoff (cioè o si è trovato un obiettivo o l'albero è stato esplorato per intero: failure). Nel codice del corso c'è anche una depth_first_recursive_search, la DFS ricorsiva con controllo dei cicli.
Esempio completo: un grafo di prova
Per vedere gli algoritmi al lavoro, un piccolo problema su un grafo orientato pesato, definito estendendo Problem:
class GraphProblem(Problem):
"""graph[s] = {successore: costo}; heur[s] = h(s)."""
def actions(self, s): return list(self.graph[s])
def result(self, s, a): return a
def action_cost(self, s, a, s1): return self.graph[s][s1]
def h(self, node): return self.heur[node.state]
graph = {'S': {'A': 1, 'B': 4}, 'A': {'B': 2, 'C': 6}, 'B': {'C': 1, 'G': 9}, 'C': {'G': 3}, 'G': {}}
heur = {'S': 6, 'A': 5, 'B': 3, 'C': 3, 'G': 0}
p = GraphProblem(initial='S', goal='G', graph=graph, heur=heur)
for search in (breadth_first_search, uniform_cost_search, greedy_bfs, astar_search, iterative_deepening_search):
n = search(p)
print(search.__name__, path_states(n), n.path_cost)I costi veri verso sono , , , ; l'euristica scelta sta sotto: è ammissibile (ogni non supera il costo vero) e consistente (per esempio ). Output:
breadth_first_search ['S', 'B', 'G'] 13
uniform_cost_search ['S', 'A', 'B', 'C', 'G'] 7
greedy_bfs ['S', 'B', 'G'] 13
astar_search ['S', 'A', 'B', 'C', 'G'] 7
iterative_deepening_search ['S', 'B', 'G'] 13Ampiezza, approfondimento iterativo e greedy trovano il cammino con meno passi (costo 13); il costo uniforme e A∗ trovano il ottimo (costo 7). Il greedy ci arriva con 3 sole espansioni (, , ): sceglie perché e poi con .
Traccia di A∗ (, tra parentesi e ):
| passo | si espande | si aggiunge alla frontiera o si scarta |
|---|---|---|
| 1 | (, ) | (, ); (, ) |
| 2 | (, ) | con è meno di : sostituisce, ; (, ) |
| 3 | (, ) | con è meno di : sostituisce, ; (, ) |
| 4 | (, ): la voce vecchia | con e con non migliorano: scartati |
| 5 | (, ) | con è meno di : sostituisce, |
| 6 | (, ) | obiettivo |
Cammino , costo . Il passo 4 è proprio lo spreco descritto sopra: la voce di con , rimasta nello heap, viene espansa inutilmente. Le chiamate a is_goal, che avvengono all'estrazione, seguono questo ordine: ['S', 'A', 'B', 'B', 'C', 'G'].
Applicazione alla mappa della Romania
Sulla mappa del laboratorio (vedi Esercizio - Route finding sulla Romania e pancake sorting con euristica del gap (laboratorio 3)), da Arad () a Bucarest (), contando le espansioni e gli stati generati (result):
| algoritmo | cammino | costo | espansioni | nodi generati |
|---|---|---|---|---|
uniform_cost_search |
A, S, R, P, B | 418 | 13 | 30 |
astar_search |
A, S, R, P, B | 418 | 6 | 15 |
greedy_bfs |
A, S, F, B | 450 | 4 | 9 |
weighted_astar_search (peso 1,4) |
A, S, F, B | 450 | 4 | 9 |
breadth_first_bfs |
A, S, F, B | 450 | 12 | 28 |
breadth_first_search |
A, S, F, B | 450 | (test alla generazione) | 18 |
iterative_deepening_search |
A, S, F, B | 450 | 27 | |
depth_first_bfs |
A, T, L, M, D, C, P, B | 733 | 10 | 21 |
L'ordine di espansione di A∗ è (come nella teoria, con = Rimnicu Vilcea e = Fagaras), quello del costo uniforme . Il cammino della profondità (733) è il più costoso: la DFS non guarda né i passi né il costo. Il peso 1,4 basta a trasformare A∗ in greedy su questa mappa.
Versione ripasso
Problem:initial,goal,actions(s),result(s, a),is_goal(s)(s == goal),action_cost(s, a, s1)(1 di default),h(node)(0 di default); le sottoclassi ridefinisconoactionseresult;self.__dict__.update(...)trasforma gli argomenti con nome in attributi (Classi ed ereditarietà in PythonAttributi di istanza e di classe, metodi di istanza, di classe e statici; confronto e ordinamento con eq e lt; proprietà; ereditarietà, super(), override e polimorfismo; classi astratte come interfacce; eccezioni personalizzate; overloading e shadowing in Python.Classi ed ereditarietà in Python →).Node:state,parent,action,path_cost;len(node)= profondità;__lt__confrontapath_cost(serve aheapqnei pareggi);failureecutoffsono nodi sentinella con costo .expandgenera i figli (yield);path_actionsepath_statesrisalgono i genitori.- Code: FIFO =
deque(appendleftepop), LIFO =list(appendepop), priorità =PriorityQueuesuheapqcon coppie(punteggio, nodo), (Code con prioritàEntry chiave-valore; ADT coda con priorità (insert, min, removeMin) con chiave minima = priorità massima; esempio di esecuzione; applicazioni; implementazioni con lista non ordinata e ordinata e relativi costi; ordinamento tramite coda con priorità.Code con priorità →). best_first_search(problem, f): estrae il nodo con minima; test dell'obiettivo all'estrazione; un figlio entra ses not in reached or child.path_cost < reached[s].path_cost; restituiscefailurese la frontiera si svuota. Le voci vecchie restano nello heap e vengono espanse (spreco).- Casi particolari: costo uniforme ; greedy ; A∗ ; A∗ pesato (peso 1,4); ampiezza
f=len; profonditàf=lambda n: -len(n).is_cycle(node, k=30)cerca lo stato tra gli ultimi antenati. - Ampiezza vera: coda FIFO, test dell'obiettivo alla generazione, insieme
reached. Profondità limitata: pilalist, restituiscecutoffse il limite tronca. Approfondimento iterativo: limiti finché il risultato non ècutoff. - Grafo di prova (
SA1,B4;AB2,C6;BC1,G9;CG3; ): ampiezza, approfondimento iterativo e greedy danno (costo 13); costo uniforme e A∗ danno (costo 7). A∗ espande (voce vecchia), e due volte sostituisce un nodo con uno meno costoso (: da 4 a 3, : da 7 a 4, : da 12 a 7). - Romania, A B: costo uniforme 418 (13 espansioni, 30 nodi generati); A∗ 418 (6, 15); greedy 450 (4, 9); ampiezza FIFO 450; profondità 733.
- Traccia di A∗ sul grafo di prova (, ): (0, 6) genera (1, 6) e (4, 7); genera con (, sostituisce) e (7, 10); (3, 6) genera con (, sostituisce) e (12, 12); la voce vecchia (4, 7) viene espansa e scartata; (4, 7) genera con (, sostituisce); è l'obiettivo, costo . Euristica ammissibile e consistente: .
- Errori tipici: dimenticare che le sottoclassi devono implementare
actionseresult; confondere ampiezza conf=len(priorità) e ampiezza con coda FIFO (test alla generazione); credere che la voce vecchia nello heap sia rimossa; usare liste invece di tuple come stati (non sono hashabili e si modificano sul posto).
Esercizi su questo argomento
- Esercizio - Confronto degli algoritmi di ricerca con CountCalls, report e grafici (laboratorio 3)
- Esercizio - Programmazione a oggetti in Python - listino, carrello, copie e ristorante (laboratorio 2)
- Esercizio - Ricerca di costo uniforme, greedy e A* sulla mappa della Romania (slide del corso)
- Esercizio - Route finding sulla Romania e pancake sorting con euristica del gap (laboratorio 3)