Escolar Documentos
Profissional Documentos
Cultura Documentos
Novatec
São Paulo | 2019
Original English language edition published by Manning Publications Co, Copyright © 2019 by
Manning Publications. Portuguese-language edition for Brazil copyright © 2019 by Novatec
Editora. All rights reserved.
Edição original em Inglês publicada pela Manning Publications Co, Copyright © 2019 pela
Manning Publications. Edição em Português para o Brasil copyright © 2019 pela Novatec Editora.
Todos os direitos reservados.
Todos os direitos reservados e protegidos pela Lei 9.610 de 19/02/1998. É proibida a reprodução
desta obra, mesmo parcial, por qualquer processo, sem prévia autorização, por escrito, do autor e
da Editora.
Editor: Rubens Prates
Tradução: Lúcia A. Kinoshita
Revisão gramatical: Tássia Carvalho
Editoração eletrônica: Carolina Kuwabata
ISBN: 978-85-7522-806-7
Histórico de edições impressas:
Setembro/2019 Primeira edição
Novatec Editora Ltda.
Rua Luís Antônio dos Santos 110
02460-000 – São Paulo, SP – Brasil
Tel.: +55 11 2959-6529
E-mail: novatec@novatec.com.br
Site: www.novatec.com.br
Twitter: twitter.com/novateceditora
Facebook: facebook.com/novatec
LinkedIn: linkedin.com/in/novatec
Dedicado à minha avó Erminia Antos, professora e aprendiz por toda a
vida.
Sumário
Agradecimentos
Sobre o autor
Sobre a ilustração da capa
Introdução
Capítulo 1 ■ Problemas pequenos
1.1 Sequência de Fibonacci
1.1.1 Uma primeira tentativa com recursão
1.1.2 Utilizando casos de base
1.1.3 Memoização para nos salvar
1.1.4 Memoização automática
1.1.5 Fibonacci simples
1.1.6 Gerando números de Fibonacci com um gerador
1.2 Compactação trivial
1.3 Criptogra a inquebrável
1.3.1 Deixando os dados em ordem
1.3.2 Criptografando e descriptografando
1.4 Calculando pi
1.5 Torres de Hanói
1.5.1 Modelando as torres
1.5.2 Solucionando as Torres de Hanói
1.6 Aplicações no mundo real
1.7 Exercícios
Capítulo 2 ■ Problemas de busca
2.1 Busca em DNA
2.1.1 Armazenando um DNA
2.1.2 Busca linear
2.1.3 Busca binária
2.1.4 Um exemplo genérico
2.2 Resolução de labirintos
2.2.1 Gerando um labirinto aleatório
2.2.2 Miscelânea de minúcias sobre labirintos
2.2.3 Busca em profundidade
2.2.4 Busca em largura
2.2.5 Busca A*
2.3 Missionários e canibais
2.3.1 Representando o problema
2.3.2 Solução
2.4 Aplicações no mundo real
2.5 Exercícios
Capítulo 3 ■ Problemas de satisfação de restrições
3.1 Construindo um framework para problemas de satisfação de
restrições
3.2 Problema de coloração do mapa da Austrália
3.3 Problema das oito rainhas
3.4 Caça-palavras
3.5 SEND+MORE=MONEY
3.6 Layout de placa de circuitos
3.7 Aplicações no mundo real
3.8 Exercícios
Capítulo 4 ■ Problemas de grafos
4.1 Mapa como um grafo
4.2 Construindo um framework de grafos
4.2.1 Trabalhando com Edge e Graph
4.3 Encontrando o caminho mínimo
4.3.1 Retomando a busca em largura (BFS)
4.4 Minimizando o custo de construção da rede
4.4.1 Trabalhando com pesos
4.4.2 Encontrando a árvore geradora mínima
4.5 Encontrando caminhos mínimos em um grafo com peso
4.5.1 Algoritmo de Dijkstra
4.6 Aplicações no mundo real
4.7 Exercícios
Capítulo 5 ■ Algoritmos genéticos
5.1 Background em biologia
5.2 Algoritmo genético genérico
5.3 Teste simples
5.4 Revendo SEND+MORE=MONEY
5.5 Otimizando a compactação de listas
5.6 Desa os para os algoritmos genéticos
5.7 Aplicações no mundo real
5.8 Exercícios
Capítulo 6 ■ Clustering k-means
6.1 Informações preliminares
6.2 Algoritmo de clustering k-means
6.3 Clustering de governadores por idade e longitude
6.4 Clustering de álbuns do Michael Jackson por tamanho
6.5 Problemas e extensões do clustering k-means
6.6 Aplicações no mundo real
6.7 Exercícios
Capítulo 7 ■ Redes neurais relativamente simples
7.1 Base biológica?
7.2 Redes neurais arti ciais
7.2.1 Neurônios
7.2.2 Camadas
7.2.3 Retropropagação
7.2.4 Visão geral
7.3 Informações preliminares
7.3.1 Produto escalar
7.3.2 Função de ativação
7.4 Construindo a rede
7.4.1 Implementando os neurônios
7.4.2 Implementando as camadas
7.4.3 Implementando a rede
7.5 Problemas de classi cação
7.5.1 Normalizando dados
7.5.2 Conjunto clássico de dados de amostras de íris
7.5.3 Classi cando vinhos
7.6 Agilizando as redes neurais
7.7 Problemas e extensões das redes neurais
7.8 Aplicações no mundo real
7.9 Exercícios
Capítulo 8 ■ Busca competitiva
8.1 Componentes básicos de jogos de tabuleiro
8.2 Jogo da velha
8.2.1 Administrando os estados do jogo da velha
8.2.2 Minimax
8.2.3 Testando o minimax com o jogo da velha
8.2.4 Desenvolvendo uma IA para o jogo da velha
8.3 Connect Four
8.3.1 Peças do jogo Connect Four
8.3.2 Uma IA para o Connect Four
8.3.3 Aperfeiçoando o minimax com a poda alfa-beta
8.4 Melhorias no minimax além da poda alfa-beta
8.5 Aplicações no mundo real
8.6 Exercícios
Capítulo 9 ■ Problemas diversos
9.1 Problema da mochila
9.2 Problema do Caixeiro-Viajante
9.2.1 Abordagem ingênua
9.2.2 Avançando para o próximo nível
9.3 Dados mnemônicos para números de telefone
9.4 Aplicações no mundo real
9.5 Exercícios
Apêndice A ■ Glossário
Apêndice B ■ Outros recursos
B.1 Python
B.2 Algoritmos e estruturas de dados
B.3 Inteligência arti cial
B.4 Programação funcional
B.5 Projetos de código aberto convenientes para aprendizado de
máquina
Apêndice C ■ Introdução rápida às dicas de tipo
C.1 O que são dicas de tipo?
C.2 Como é a aparência das dicas de tipo?
C.3 Por que as dicas de tipo são úteis?
C.4 Quais são as desvantagens das dicas de tipo?
C.5 Obtendo mais informações
Agradecimentos
1 Se você acabou de iniciar sua jornada com Python, talvez queira dar uma olhada no livro The
Quick Python Book, 3ª edição, de Naomi Ceder (Manning, 2018) antes de começar a ler este
livro.
2 N.T: Jogo tradicionalmente composto de um tabuleiro vertical contendo seis linhas e sete
colunas, para dois jogadores. Os jogadores se alternam para inserir discos coloridos na parte
superior de cada coluna, os quais ocuparão a primeira posição livre nessa coluna. O vencedor
será aquele que conseguir formar primeiro uma sequência vertical, horizontal ou diagonal de
quatro peças com a sua cor.
CAPÍTULO 1
Problemas pequenos
Figura 1.3 – Toda chamada para fib2() que não seja um caso de base resulta
em outras duas chamadas de fib2().
Em outras palavras, a árvore de chamadas cresce exponencialmente. Por
exemplo, uma chamada a fib2(4) resulta no seguinte conjunto total de
chamadas:
fib2(4) -> fib2(3), fib2(2)
fib2(3) -> fib2(2), fib2(1)
fib2(2) -> fib2(1), fib2(0)
fib2(2) -> fib2(1), fib2(0)
fib2(1) -> 1
fib2(1) -> 1
fib2(1) -> 1
fib2(0) -> 0
fib2(0) -> 0
@lru_cache(maxsize=None)
def fib4(n: int) -> int: # mesma definição de fib2()
if n < 2: # caso de base
return n
return fib4(n - 2) + fib4(n - 1) # caso recursivo
if __name__ == "__main__":
print(fib4(5))
print(fib4(50))
if __name__ == "__main__":
print(fib5(5))
print(fib5(50))
AVISO O corpo do laço for em fib5() utiliza desempacotamento de tuplas de uma
maneira talvez um pouco exageradamente inteligente. Algumas pessoas podem achar que a
legibilidade está sendo sacri cada em favor da concisão. Outras poderão achar que a própria
concisão deixa o código mais legível. O truque está no fato de last ser de nido com o valor
anterior de next , e next ser de nido com o valor anterior de last somado ao valor anterior
de next . Isso evita a criação de uma variável temporária para armazenar o valor antigo de
next depois que last é atualizada, mas antes de next ser atualizada. Usar
desempacotamento de tuplas dessa forma para fazer algum tipo de troca (swap) de variáveis
é comum em Python.
Com essa abordagem, o corpo do laço for executará um máximo de n-1
vezes. Em outras palavras, essa é a versão mais e ciente até agora.
Compare as 19 execuções do corpo do laço for com as 21.891 chamadas
recursivas de fib2() para o vigésimo número de Fibonacci. Isso poderia
fazer uma diferença enorme em uma aplicação do mundo real!
Nas soluções recursivas, trabalhamos no sentido inverso. Nessa solução
iterativa, trabalhamos seguindo em frente. Às vezes, a recursão é o modo
mais intuitivo para resolver um problema. Por exemplo, a parte principal
de fib1() e de fib2() é, basicamente, uma tradução mecânica da fórmula
de Fibonacci original. No entanto, soluções recursivas ingênuas também
podem ter custos signi cativos de desempenho. Lembre-se de que
qualquer problema que possa ser resolvido recursivamente também pode
ser solucionado de forma iterativa.
if __name__ == "__main__":
for i in fib6(50):
print(i)
Figura 1.5 – Compactando uma str que representa um gene em uma cadeia
de bits contendo 2 bits por nucleotídeo.
Listagem 1.10 – trivial_compression.py
class CompressedGene:
def __init__(self, gene: str) -> None:
self._compress(gene)
CompressedGenerecebe uma str de caracteres que representam os
nucleotídeos de um gene e, internamente, armazena a sequência de
nucleotídeos como uma cadeia de bits. A principal responsabilidade do
método __init__() é inicializar a cadeia de bits com os dados
apropriados. __init__() chama _compress() para fazer o trabalho sujo de
realmente converter a str de nucleotídeos fornecida em uma cadeia de
bits.
Observe que _compress() começa com um underscore. Python não tem o
conceito de métodos ou variáveis realmente privados. (Todas as variáveis
e métodos podem ser acessados por meio de re exão [re ection]; não há
nenhuma garantia rigorosa de privacidade.) Um underscore na frente é
usado como convenção para sinalizar que atores externos à classe não
deverão depender da implementação de um método. (Ela estará sujeita a
mudanças e o método deve ser tratado como privado.)
DICA Se você iniciar o nome de um método ou de uma variável de instância em uma classe
com dois underscores na frente, Python vai “embaralhar o nome”, modi cando o nome
implementado com um salt, fazendo com que ele não seja facilmente descoberto por outras
classes. Usamos um underscore neste livro para sinalizar uma variável ou um método
“privado”, mas você pode usar dois caso queira realmente enfatizar que algo é privado. Para
saber mais sobre nomenclatura em Python, consulte a seção “Descriptive Naming Styles”
(Estilos de nomes descritivos) da PEP 8: http://mng.bz/NA52.
A seguir, vamos ver como podemos fazer efetivamente a compactação.
Listagem 1.11 – Continuação de trivial_compression.py
def _compress(self, gene: str) -> None:
self.bit_string: int = 1 # começa com uma sentinela
for nucleotide in gene.upper():
self.bit_string <<= 2 # desloca dois bits para a esquerda
if nucleotide == "A": # muda os dois últimos bits para 00
self.bit_string |= 0b00
elif nucleotide == "C": # muda os dois últimos bits para 01
self.bit_string |= 0b01
elif nucleotide == "G": # muda os dois últimos bits para 10
self.bit_string |= 0b10
elif nucleotide == "T": # muda os dois últimos bits para 11
self.bit_string |= 0b11
else:
raise ValueError("Invalid Nucleotide:{}".format(nucleotide))
O método _compress() veri ca cada caractere da str de nucleotídeos
sequencialmente. Se vir um A, ele acrescentará 00 à cadeia de bits. Se vir
um C, acrescentará 01 e assim por diante. Lembre-se de que são
necessários dois bits para cada nucleotídeo. Como resultado, antes de
acrescentar cada novo nucleotídeo, deslocamos dois bits para a esquerda
na cadeia de bits (self.bit_string <<= 2).
Cada nucleotídeo é adicionado usando uma operação “ou” (|). Depois
do deslocamento à esquerda, dois 0s são acrescentados do lado direito da
cadeia de bits. Em operações bit a bit, fazer um “OR” (por exemplo,
self.bit_string |= 0b10 ) de 0s com qualquer outro valor resulta no outro
valor substituindo os 0s. Em outras palavras, acrescentamos
continuamente dois novos bits do lado direito da cadeia de bits. Os dois
bits acrescentados são determinados pelo tipo do nucleotídeo.
Por m, implementaremos a descompactação e o método especial
__str__() que a utiliza.
decompress() lê dois bits de cada vez da cadeia de bits e utiliza esses dois
bits para determinar qual caractere deve ser adicionado no nal da
representação em str do gene. Como os bits estão sendo lidos na ordem
inversa se comparados com a ordem em que foram compactados (da
direita para a esquerda, e não da esquerda para a direita), a representação
em str, em última análise, está invertida (usamos a notação de fatiamento
para inversão [::-1]). Por m, observe como o método conveniente int
bit_length() ajudou no desenvolvimento de decompress() . Vamos testá-lo.
Figura 1.6 – Um one-time pad resulta em duas chaves que podem ser
separadas e então recombinadas para recriar os dados originais.
Se seu console exibir One Time Pad! , é sinal de que tudo funcionou
corretamente.
1.4 Calculando pi
O número pi (p ou 3,14159…), signi cativo na matemática, pode ser obtido
por meio de várias fórmulas. Uma das mais simples é a fórmula de
Leibniz, a qual postula que a convergência da seguinte série in nita é
igual a pi:
π = 4/1 - 4/3 + 4/5 - 4/7 + 4/9 - 4/11...
if __name__ == "__main__":
print(calculate_pi(1000000))
DICA Na maioria das plataformas, os float s Python são números de ponto utuante de 64
bits (ou double em C).
Essa função é um exemplo de como uma conversão mecânica entre uma
fórmula e um código de programação pode ser simples e e caz para
modelar ou simular um conceito interessante. Uma conversão mecânica é
uma ferramenta útil, mas devemos ter em mente que ela não é,
necessariamente, a solução mais e caz. Certamente, a fórmula de Leibniz
para pi pode ser implementada com um código mais e ciente ou mais
compacto.
NOTA Quanto mais termos houver na série in nita (quanto maior o valor de n_terms
quando calculate_pi() for chamado), mais exato será o cálculo nal de pi.
class Stack(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []
1.7 Exercícios
1. Escreva outra função que forneça o elemento n da sequência de
Fibonacci usando uma técnica cujo design seja seu. Escreva testes de
unidade (unit tests) que avaliem se a função está correta, além de
mostrar o desempenho em comparação com outras versões
apresentadas neste capítulo.
2. Vimos como o tipo int simples de Python pode ser usado para
representar uma cadeia de bits. Escreva um wrapper ergonômico em
torno de int que seja usado de modo genérico como uma sequência
de bits (torne-o iterável e implemente __getitem__()). Reimplemente
CompressedGene usando o wrapper.
3. Escreva uma solução para as Torres de Hanói que funcione para
qualquer quantidade de torres.
4. Utilize um one-time pad para criptografar e descriptografar imagens.
Problemas de busca
“Busca” é um termo tão amplo que este livro poderia ter recebido o título
de Problemas clássicos de busca com Python. Este capítulo descreve os
principais algoritmos de busca que todo programador deve conhecer.
Apesar do título declarado, ele não tem a pretensão de abranger tudo.
Figura 2.2 – No pior caso de uma busca linear, você veri cará
sequencialmente todos os elementos do array.
Listagem 2.6 – Continuação de dna_search.py
def linear_contains(gene: Gene, key_codon: Codon) -> bool:
for codon in gene:
if codon == key_codon:
return True
return False
C = TypeVar("C", bound="Comparable")
class Comparable(Protocol):
if __name__ == "__main__":
print(linear_contains([1, 5, 15, 15, 15, 15, 20], 5)) # True
print(binary_contains(["a", "d", "e", "f", "z"], "f")) # True
print(binary_contains(["john", "mark", "ronald", "sarah"], "sheila")) #
False
Agora você pode tentar fazer buscas com outros tipos de dados. Essas
funções podem ser reutilizadas para praticamente qualquer coleção
Python. Eis a e cácia de escrever um código de forma genérica. O único
aspecto lamentável nesse exemplo são as partes confusas por causa das
dicas de tipos de Python, na forma da classe Comparable. Um tipo
Comparable é um tipo que implemnta os operadores de comparação (< , >= e
assim por diante). Em versões futuras de Python, deverá haver um modo
mais sucinto de criar uma dica de tipo para tipos que implementem esses
operadores comuns.
2.2 Resolução de labirintos
Encontrar um caminho em um labirinto é análogo a vários problemas
comuns de busca em ciência da computação. Então, por que não
encontrar literalmente um caminho em um labirinto para ilustrar os
algoritmos de busca em largura (breadth- rst search), busca em
profundidade (depth- rst search) e A*?
Nosso labirinto será uma grade bidimensional de Cells. Uma Cell é um
enumerado (enum) com valores str, em que " " representará um espaço
vazio e "X" representará um espaço bloqueado. Haverá também outros
casos para exibição de um labirinto ilustrado.
Listagem 2.10 – maze.py
from enum import Enum
from typing import List, NamedTuple, Callable, Optional
import random
from math import sqrt
from generic_search import dfs, bfs, node_to_path, astar, Node
Mais uma vez, estamos fazendo várias importações para que elas já
estejam resolvidas. Observe que a última importação (de generic_search) é
de símbolos que ainda não de nimos. Foi incluída aqui por conveniência,
mas você pode comentá-la até que ela seja necessária.
Precisaremos de uma forma de referenciar uma posição individual no
labirinto. Essa será apenas uma NamedTuple com propriedades que
representam a linha e a coluna da posição em questão.
Listagem 2.11 – Continuação de maze.py
class MazeLocation(NamedTuple):
row: int
column: int
2.2.1 Gerando um labirinto aleatório
Nossa classe Maze manterá internamente o controle de uma grade (uma
lista de listas) que representa o seu estado. Ela também terá variáveis de
instância para o número de linhas, o número de colunas, a posição inicial
e a posição do objetivo. A grade será preenchida aleatoriamente com
células bloqueadas.
O labirinto gerado deve ser razoavelmente esparso para que quase
sempre haja um caminho de uma dada posição inicial até uma dada
posição de chegada. (A nal de contas, isso serve para testar nossos
algoritmos.) Deixaremos que quem zer a chamada para criar um
labirinto decida exatamente quão esparso ele será, mas forneceremos um
valor default igual a 20% de posições bloqueadas. Quando um número
aleatório for menor que o limiar representado pelo parâmetro sparseness
em questão, simplesmente substituiremos um espaço vazio por uma
parede. Se zermos isso para todas as posições possíveis do labirinto,
estatisticamente, o quão esparso está o labirinto estará próximo do
parâmetro sparseness fornecido.
Listagem 2.12 – Continuação de maze.py
class Maze:
def __init__(self, rows: int = 10, columns: int = 10, sparseness: float
= 0.2, start: MazeLocation = MazeLocation(0, 0), goal: MazeLocation =
MazeLocation(9, 9)) -> None:
# inicializa as variáveis de instância básicas
self._rows: int = rows
self._columns: int = columns
self.start: MazeLocation = start
self.goal: MazeLocation = goal
# preenche a grade com células vazias
self._grid: List[List[Cell]] =
[[Cell.EMPTY for c in range(columns)] for r in range(rows)]
# preenche a grade com células bloqueadas
self._randomly_fill(rows, columns, sparseness)
# preenche as posições inicial e final
self._grid[start.row][start.column] = Cell.START
self._grid[goal.row][goal.column] = Cell.GOAL
@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
Observe que implementar uma pilha usando uma list Python é simples e
basta concatenar itens em sua extremidade direita e removê-los do ponto
mais extremo à direita. O método pop() em list falhará se não houver
mais itens na lista, portanto pop() falhará igualmente em uma Stack se ela
estiver vazia.
Algoritmo de DFS
Mais um pequeno detalhe será necessário antes de podermos
implementar a DFS. Precisamos de uma classe Node que usaremos para
manter o controle de como passamos de um estado para outro (ou de
uma posição para outra) à medida que fazemos a busca. Podemos pensar
em um Node como um wrapper em torno de um estado. No caso de nosso
problema de resolução de labirinto, esses estados são do tipo
MazeLocation . Chamaremos o Node do qual um estado se originou de seu
parent . Além disso, de niremos nossa classe Node com as propriedades
cost e heuristic, e com __lt__() implementado, para que possamos
reutilizá-la depois no algoritmo A*.
Listagem 2.17 – Continuação de generic_search.py
class Node(Generic[T]):
def __init__(self, state: T, parent: Optional[Node], cost: float = 0.0,
heuristic: float = 0.0) -> None:
self.state: T = state
self.parent: Optional[Node] = parent
self.cost: float = cost
self.heuristic: float = heuristic
Foi uma longa jornada, mas, nalmente, estamos prontos para resolver o
labirinto.
Listagem 2.21 – Continuação de maze.py
if __name__ == "__main__":
# Teste da DFS
m: Maze = Maze()
print(m)
solution1: Optional[Node[MazeLocation]] = dfs(m.start, m.goal_test,
m.successors)
if solution1 is None:
print("No solution found using depth-first search!")
else:
path1: List[MazeLocation] = node_to_path(solution1)
m.mark(path1)
print(m)
m.clear(path1)
@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
def push(self, item: T) -> None:
self._container.append(item)
Algoritmo de BFS
Por incrível que pareça, o algoritmo para uma busca em largura é idêntico
ao algoritmo de uma busca em profundidade, com a fronteira alterada,
passando de uma pilha para uma la. Modi car a fronteira de uma pilha
para uma la altera a ordem com que os estados são pesquisados e
garante que os estados mais próximos ao estado inicial sejam pesquisados
antes.
Listagem 2.23 – Continuação de generic_search.py
def bfs(initial: T, goal_test: Callable[[T], bool], successors:
Callable[[T], List[T]]) -> Optional[Node[T]]:
# frontier corresponde aos lugares que ainda devemos visitar
frontier: Queue[Node[T]] = Queue()
frontier.push(Node(initial, None))
# explored representa os lugares em que já estivemos
explored: Set[T] = {initial}
Se você tentar executar bfs(), verá que ele sempre encontrará a solução de
caminho mais curto para o labirinto em questão. O teste a seguir foi
adicionado logo após o anterior na seção if __name__ == "__main__": do
arquivo, de modo que os resultados para o mesmo labirinto poderão ser
comparados.
Listagem 2.24 – Continuação de maze.py
# Teste da BFS
solution2: Optional[Node[MazeLocation]] = bfs(m.start, m.goal_test,
m.successors)
if solution2 is None:
print("No solution found using breadth-first search!")
else:
path2: List[MazeLocation] = node_to_path(solution2)
m.mark(path2)
print(m)
m.clear(path2)
2.2.5 Busca A*
Descascar uma cebola, camada por camada, pode demorar bastante –
assim como uma busca em largura. Do mesmo modo que uma BFS, uma
busca A* tem como objetivo encontrar o caminho mais curto de um
estado inicial até um estado visado. De modo diferente da implementação
anterior de BFS, uma busca A* utiliza uma combinação entre uma função
de custo e uma função heurística para manter o foco da busca em
caminhos com mais chances de chegar ao objetivo rapidamente.
A função de custo, g(n), analisa o custo para chegar a um estado em
particular. No caso de nosso labirinto, seria a quantidade de passos
anteriores que tivemos de dar para chegar ao estado em questão. A
função heurística, h(n), fornece uma estimativa do custo para ir do estado
em questão até o estado que representa o objetivo. É possível provar que,
se h(n) é uma heurística admissível, o caminho nal encontrado será
ótimo. Uma heurística admissível é aquela que jamais superestima o custo
para alcançar o objetivo. Em um plano bidimensional, um exemplo é a
heurística da distância em linha reta, pois uma linha reta será sempre o
caminho mais curto.1
O custo total para qualquer estado considerado é f(n), que é
simplesmente a combinação entre g(n) e h(n). Com efeito, f(n) = g(n) +
h(n). Ao escolher o próximo estado da fronteira a ser explorado, uma
busca A* escolherá o estado com o menor f(n). É assim que ela se
distingue da BFS e da DFS.
Filas de prioridade
Para escolher o estado com o menor f(n) na fronteira, uma busca A* usa
uma la de prioridades como a estrutura de dados para a sua fronteira.
Uma la de prioridades mantém seus elementos em uma ordem interna,
de modo que o primeiro elemento removido será sempre o elemento de
mais alta prioridade. (Em nosso caso, o item de mais alta prioridade é
aquele com o menor f(n).) Em geral, isso implica o uso de um heap
binário internamente, o que resulta em inserções com complexidade O(lg
n) e remoções com O(lg n).
A biblioteca-padrão de Python contém funções heappush() e heappop()
que receberão uma lista e a manterão como um heap binário. Podemos
implementar uma la de prioridades construindo um wrapper no em
torno dessas funções da biblioteca-padrão. Nossa classe PriorityQueue será
semelhante às nossas classes Stack e Queue, com os métodos push() e pop()
modi cados de modo a usarem heappush() e heappop().
Listagem 2.25 – Continuação de generic_search.py
class PriorityQueue(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []
@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
def push(self, item: T) -> None:
heappush(self._container, item) # insere de acordo com a prioridade
MAX_NUM: int = 3
class MCState:
def __init__(self, missionaries: int, cannibals: int, boat: bool) ->
None:
self.wm: int = missionaries # missionários na margem oeste
self.wc: int = cannibals # canibais na margem oeste
self.em: int = MAX_NUM - self.wm # missionários na margem leste
self.ec: int = MAX_NUM - self.wc # canibais na margem leste
self.boat: bool = boat
2.3.2 Solução
Temos agora todos os ingredientes à disposição para resolver o problema.
Lembre-se de que, quando resolvemos um problema usando as funções
de busca bfs(), dfs() e astar(), recebemos um Node que, em última análise,
será convertido com node_to_path() em uma lista de estados que levará a
uma solução. O que precisamos ainda é de uma forma de converter essa
lista em uma sequência de passos compreensíveis a ser exibida,
resolvendo o problema dos missionários e canibais.
A função display_solution() converte o caminho de uma solução em
uma saída a ser exibida – uma solução para o problema, legível aos seres
humanos. Ela funciona iterando por todos os estados que estão no
caminho da solução, ao mesmo tempo que mantém o controle do último
estado também. A função observa a diferença entre o último estado e o
estado no qual está iterando no momento a m de descobrir quantos
missionários e canibais cruzaram o rio e em qual direção.
Listagem 2.34 – Continuação de missionaries.py
def display_solution(path: List[MCState]):
if len(path) == 0: # verificação de sanidade
return
old_state: MCState = path[0]
print(old_state)
for current_state in path[1:]:
if current_state.boat:
print("{} missionaries and {} cannibals moved from the east bank
to the west bank.\n"
.format(old_state.em - current_state.em, old_state.ec -
current_state.ec))
else:
print("{} missionaries and {} cannibals moved from the west bank
to the east bank.\n"
.format(old_state.wm - current_state.wm, old_state.wc -
current_state.wc))
print(current_state)
old_state = current_state
1 Para mais informações sobre heurística, veja o livro Arti cial Intelligence: A Modern Approach,
3ª edição (Pearson, 2010), página 94, de Stuart Russell e Peter Norvig (edição publicada no
Brasil: Inteligência Arti cial [Campus, 2013]).
2 Para mais informações sobre heurística em busca de caminhos (path nding) com A*, consulte o
capítulo “Heuristics” em Amit’s Thoughts on Path nding (Ideias de Amit sobre busca de
caminhos) de Amit Patel, em http://mng.bz/z7O4.
CAPÍTULO 3
Figura 3.3 – Em uma solução para o problema das oito rainhas (há várias),
duas rainhas quaisquer não podem ameaçar uma à outra.
Para representar os quadrados do tabuleiro, atribuiremos uma linha e
uma coluna, na forma de valores inteiros, a cada quadrado. Podemos
garantir que cada uma das oito rainhas não está na mesma coluna
simplesmente atribuindo as colunas de 1 a 8 a elas, sequencialmente. As
variáveis em nosso problema de satisfação de restrições podem ser
apenas a coluna da rainha em questão. Os domínios podem ser as linhas
possíveis (novamente, de 1 a 8). A listagem de código a seguir mostra o
nal de nosso arquivo, onde essas variáveis e domínios são de nidos.
Listagem 3.8 – queens.py
if __name__ == "__main__":
columns: List[int] = [1, 2, 3, 4, 5, 6, 7, 8]
rows: Dict[int, List[int]] = {}
for column in columns:
rows[column] = [1, 2, 3, 4, 5, 6, 7, 8]
csp: CSP[int, int] = CSP(columns, rows)
3.4 Caça-palavras
Um caça-palavras é uma grade de letras com palavras ocultas posicionada
em linhas, colunas e diagonais. Um jogador de caça-palavras tenta
encontrar as palavras ocultas analisando atentamente a grade. Encontrar
lugares para inserir as palavras de modo que todas sejam inseridas na
grade é uma espécie de problema de satisfação de restrições. As variáveis
são as palavras e os domínios são os possíveis lugares para inserir essas
palavras. A Figura 3.4 ilustra esse problema.
Figura 3.4 – Um caça-palavras clássico, como aqueles que você veria em um
livro de passatempos para crianças.
Por conveniência, nosso caça-palavras não incluirá palavras que se
sobreponham. Você poderá aperfeiçoá-lo para permitir que haja
sobreposição de palavras, como um exercício.
A grade para esse problema de caça-palavras não é tão diferente dos
labirintos do Capítulo 2. Alguns dos tipos de dados a seguir deverão ser
familiares.
Listagem 3.11 – word_search.py
from typing import NamedTuple, List, Dict, Optional
from random import choice
from string import ascii_uppercase
from csp import CSP, Constraint
class GridLocation(NamedTuple):
row: int
column: int
Inicialmente, preencheremos a grade com as letras do alfabeto
(ascii_uppercase). Também precisaremos de uma função para exibir a
grade.
Listagem 3.12 – Continuação de word_search.py
def generate_grid(rows: int, columns: int) -> Grid:
# inicializa a grade com letras aleatórias
return [[choice(ascii_uppercase) for c in range(columns)] for r in
range(rows)]
Para o intervalo de lugares possíveis para uma palavra (em uma linha,
uma coluna ou na diagonal), as list comprehensions traduzem o intervalo
em uma lista de GridLocation usando o construtor dessa classe. Como
generate_domain() percorre todas as posições da grade em um laço, da
parte superior à esquerda até a parte inferior à direita para cada palavra,
muito processamento está envolvido. Você é capaz de pensar em um
modo mais e ciente de fazer isso? E se veri cássemos todas as palavras
de mesmo tamanho ao mesmo tempo, dentro do laço?
Para veri car se uma solução em potencial é válida, devemos
implementar uma restrição personalizada para o caça-palavras. O método
satisfied() de WordSearchConstraint simplesmente veri ca se algum dos
locais propostos para uma palavra é igual a um local proposto para outra.
Isso é feito com um set. Converter uma list em um set removerá todas as
duplicatas. Se houver menos itens em um set resultante da conversão de
uma list em comparação com o que havia na list original, é sinal de que
a list original continha algumas duplicatas. Para preparar os dados para
essa veri cação, usaremos uma list comprehension, de certa forma
complicada, para combinar várias sub-listas de posições para cada
palavra da atribuição em uma única lista maior de posições.
Listagem 3.14 – Continuação de word_search.py
class WordSearchConstraint(Constraint[str, List[GridLocation]]):
def __init__(self, words: List[str]) -> None:
super().__init__(words)
self.words: List[str] = words
3.5 SEND+MORE=MONEY
SEND+MORE=MONEY é uma charada criptoaritmética; isso signi ca
que se trata de encontrar dígitos que substituam as letras, de modo que
uma declaração matemática seja verdadeira. Cada letra no problema
representa um dígito (0–9). Duas letras diferentes não podem representar
o mesmo dígito. Quando uma letra se repete, signi ca que um dígito se
repetirá na solução.
Para resolver manualmente essa charada, colocar as palavras alinhadas
pode ajudar.
SEND
+MORE
=MONEY
3.8 Exercícios
1. Revise WordSearchConstraint de modo que a sobreposição de letras seja
permitida.
2. Implemente um código para solucionar o problema do layout da
placa de circuitos descrito na Seção 3.6, caso ainda não o tenha feito.
3. Implemente um programa capaz de resolver problemas de Sudoku
usando o framework de resolução de problemas de satisfação de
restrições deste capítulo.
Problemas de grafos
@dataclass
class Edge:
u: int # o vértice "de"
v: int # o vértice "para"
Uma Edge é de nida como uma conexão entre dois vértices, cada qual
representado por um índice inteiro. Por convenção, u é usado para
referenciar o primeiro vértice e v é utilizado para representar o segundo.
Também podemos pensar em u como “de” e v como “para”. Neste
capítulo, trabalharemos apenas com grafos não direcionados (grafos com
arestas que permitem trafegar nas duas direções), mas em grafos
direcionados (ou, ainda, orientados ou dirigidos), também conhecidos
como digrafos, as arestas também podem ser unidirecionais. O método
reversed() devolve uma Edge que percorra a direção inversa da aresta na
qual ele for aplicado.
NOTA A classe Edge utiliza um novo recurso de Python 3.7: dataclasses. Uma classe marcada
com o decorador @dataclass evita um pouco de tédio ao criar automaticamente um
método __init__() que instancia variáveis de instância para qualquer variável declarada
com anotações de tipo no corpo da classe. As dataclasses também podem criar
automaticamente outros métodos especiais em uma classe. Os métodos especiais que serão
criados automaticamente podem ser con gurados usando o decorador. Consulte a
documentação de Python sobre as dataclasses para ver os detalhes (https://docs.python.org/
3/library/dataclasses.html). Em suma, uma dataclass é um modo de evitar um pouco de
digitação.
A classe Graph tem como foco o papel principal de um grafo: associar
vértices a arestas. Novamente, queremos que os vértices sejam de
qualquer tipo que o usuário do framework queira. Isso permite que o
framework seja usado em uma grande variedade de problemas, sem a
necessidade de criar estruturas de dados intermediárias para uni car
tudo. Por exemplo, em um grafo como aquele das rotas do Hyperloop,
poderíamos de nir o tipo dos vértices como str porque usaríamos
strings como “New York” e “Los Angeles” como vértices. Vamos dar
início à classe Graph.
Listagem 4.2 – graph.py
from typing import TypeVar, Generic, List, Optional
from edge import Edge
class Graph(Generic[V]):
def __init__(self, vertices: List[V] = []) -> None:
self._vertices: List[V] = vertices
self._edges: List[List[Edge]] = [[] for _ in vertices]
@property
def edge_count(self) -> int:
return sum(map(len, self._edges)) # Número de arestas
Vamos parar um instante e considerar o motivo pelo qual essa classe tem
duas versões para a maioria de seus métodos. Com base na de nição da
classe, sabemos que a lista _vertices é uma lista de elementos do tipo V,
que pode ser qualquer classe Python. Portanto, temos vértices do tipo V
armazenados na lista _vertices. Contudo, se quisermos obtê-los ou
manipulá-los mais tarde, temos de saber em que local eles estão
armazenados nessa lista. Desse modo, todo vértice tem um índice de array
(um inteiro) associado a ele. Se não soubermos o índice de um vértice,
será necessário consultá-lo fazendo uma busca em _vertices. É por isso
que há duas versões para cada método. Uma atua em índices int,
enquanto a outra atua no próprio V. Os métodos que atuam em V
consultam os índices relevantes e chamam a função que trabalha com
índices. Desse modo, esses métodos podem ser considerados como
auxiliares.
A maioria das função é razoavelmente autoexplicativa, mas
neighbors_for_index() merece um pouco mais de explicações. Ela devolve
os vizinhos (neighbors) de um vértice. Os vizinhos de um vértice são
todos os demais vértices diretamente conectados a ele por uma aresta. Por
exemplo, na Figura 4.2, New York e Washington são os únicos vizinhos
de Philadelphia. Encontramos os vizinhos de um vértice consultando as
extremidades (os vs) de todas as arestas que partem dele.
def neighbors_for_index(self, index: int) -> List[V]:
return list(map(self.vertex_at, [e.v for e in self._edges[index]]))
Figura 4.4 – A rota mínima entre Boston e Miami, no que diz respeito ao
número de arestas, está em destaque.
Figura 4.5 – Um grafo com peso com as 15 maiores MSAs dos Estados
Unidos, no qual cada um dos pesos representa a distância em milhas entre
duas MSAs.
Para lidar com pesos, precisaremos de uma subclasse de Edge
(WeightedEdge) e de uma subclasse de Graph (WeightedGraph). Toda
WeightedEdge terá um float associado, representando o seu peso. O
algoritmo de Jarník, que descreveremos em breve, exige que seja possível
comparar uma aresta com outra a m de determinar qual é a aresta de
menor peso. Isso é fácil de ser feito com pesos numéricos.
Listagem 4.6 – weighted_edge.py
from __future__ import annotations
from dataclasses import dataclass
from edge import Edge
@dataclass
class WeightedEdge(Edge):
weight: float
Agora é possível de nir realmente um grafo com peso. O grafo com peso
com o qual trabalharemos se chama city_graph2 e é uma representação da
Figura 4.5.
Listagem 4.8 – Continuação de weighted_graph.py
if __name__ == "__main__":
city_graph2: WeightedGraph[str] = WeightedGraph(["Seattle", "San
Francisco", "Los Angeles", "Riverside", "Phoenix", "Chicago", "Boston",
"New York", "Atlanta", "Miami", "Dallas", "Houston", "Detroit",
"Philadelphia", "Washington"])
city_graph2.add_edge_by_vertices("Seattle", "Chicago", 1737)
city_graph2.add_edge_by_vertices("Seattle", "San Francisco", 678)
city_graph2.add_edge_by_vertices("San Francisco", "Riverside", 386)
city_graph2.add_edge_by_vertices("San Francisco", "Los Angeles", 348)
city_graph2.add_edge_by_vertices("Los Angeles", "Riverside", 50)
city_graph2.add_edge_by_vertices("Los Angeles", "Phoenix", 357)
city_graph2.add_edge_by_vertices("Riverside", "Phoenix", 307)
city_graph2.add_edge_by_vertices("Riverside", "Chicago", 1704)
city_graph2.add_edge_by_vertices("Phoenix", "Dallas", 887)
city_graph2.add_edge_by_vertices("Phoenix", "Houston", 1015)
city_graph2.add_edge_by_vertices("Dallas", "Chicago", 805)
city_graph2.add_edge_by_vertices("Dallas", "Atlanta", 721)
city_graph2.add_edge_by_vertices("Dallas", "Houston", 225)
city_graph2.add_edge_by_vertices("Houston", "Atlanta", 702)
city_graph2.add_edge_by_vertices("Houston", "Miami", 968)
city_graph2.add_edge_by_vertices("Atlanta", "Chicago", 588)
city_graph2.add_edge_by_vertices("Atlanta", "Washington", 543)
city_graph2.add_edge_by_vertices("Atlanta", "Miami", 604)
city_graph2.add_edge_by_vertices("Miami", "Washington", 923)
city_graph2.add_edge_by_vertices("Chicago", "Detroit", 238)
city_graph2.add_edge_by_vertices("Detroit", "Boston", 613)
city_graph2.add_edge_by_vertices("Detroit", "Washington", 396)
city_graph2.add_edge_by_vertices("Detroit", "New York", 482)
city_graph2.add_edge_by_vertices("Boston", "New York", 190)
city_graph2.add_edge_by_vertices("New York", "Philadelphia", 81)
city_graph2.add_edge_by_vertices("Philadelphia", "Washington", 123)
print(city_graph2)
T = TypeVar('T')
class PriorityQueue(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []
@property
def empty(self) -> bool:
return not self._container # negação é verdadeira para um contêiner
vazio
Algoritmo de Jarník
O algoritmo de Jarník para encontrar uma árvore geradora mínima divide
um grafo em duas partes: os vértices da árvore geradora mínima que
ainda está sendo montada e os vértices que ainda não estão nessa árvore.
Os seguintes passos serão executados:
1. Escolha um vértice arbitrário para incluir na árvore geradora mínima.
2. Encontre a aresta de menor peso que conecta a árvore geradora
mínima aos vértices que ainda não estão nessa árvore.
3. Adicione o vértice que está no nal dessa aresta mínima à árvore
geradora mínima.
4. Repita os passos 2 e 3 até que todos os vértices do grafo estejam na
árvore geradora mínima.
NOTA O algoritmo de Jarník é comumente chamado de algoritmo de Prim. Dois
matemáticos tchecos, Otakar Borůvka e Vojtĕch Jarník, interessados em minimizar o custo
de instalação de ações para energia elétrica no nal dos anos 1920, criaram algoritmos para
resolver o problema de encontrar uma árvore geradora mínima. Seus algoritmos foram
“redescobertos” décadas depois, por outras pessoas.3
Para executar o algoritmo de Jarník de modo e caz, uma la de
prioridades será usada. Sempre que um novo vértice for adicionado à
árvore geradora mínima, todas as suas arestas de saída que se ligam aos
vértices fora da árvore serão adicionadas à la de prioridades. A aresta de
menor peso será sempre removida da la de prioridades, e o algoritmo
continuará executando até que essa la esteja vazia. Isso garante que as
arestas de menor peso sejam sempre adicionadas na árvore antes. As
arestas que se conectam aos vértices que já estão na árvore serão
ignoradas após serem removidas.
O código de mst() a seguir contém a implementação completa do
algoritmo de Jarník,4 junto com uma função utilitária para exibir um
WeightedPath .
AVISO O algoritmo de Jarník não funcionará necessariamente de forma correta em um grafo
com arestas direcionadas. Também não funcionará em um grafo que não seja conectado.
Listagem 4.11 – Continuação de mst.py
def mst(wg: WeightedGraph[V], start: int = 0) -> Optional[WeightedPath]:
if start > (wg.vertex_count - 1) or start < 0:
return None
result: WeightedPath = [] # armazena a MST final
pq: PriorityQueue[WeightedEdge] = PriorityQueue()
visited: [bool] = [False] * wg.vertex_count # locais já visitados
visit() é uma função auxiliar interna que marca um vértice como visitado
e adiciona todas as arestas conectadas a vértices ainda não visitados em
pq .
Observe a facilidade com que o modelo de lista de adjacências permite
encontrar as arestas que pertencem a um vértice em particular.
visit(start) # o primeiro vértice é onde tudo começa
Não importa qual vértice será visitado antes, a menos que o grafo não
seja conectado. Se o grafo não for conectado, mas for composto de
componentes desconectados, mst() devolverá uma árvore que se entende
pelo componente em particular ao qual o vértice inicial pertence.
while not pq.empty: # continua enquanto houver arestas para processar
edge = pq.pop()
if visited[edge.v]:
continue # nunca visita mais de uma vez
# esta é a menor no momento, portanto adiciona à solução
result.append(edge)
visit(edge.v) # visita o vértice ao qual esta aresta se conecta
return result
@dataclass
class DijkstraNode:
vertex: int
distance: float
def __lt__(self, other: DijkstraNode) -> bool:
return self.distance < other.distance
def __eq__(self, other: DijkstraNode) -> bool:
return self.distance == other.distance
# Função auxiliar para ter um acesso mais fácil aos resultados de dijkstra
def distance_array_to_vertex_dict(wg: WeightedGraph[V], distances:
List[Optional[float]]) -> Dict[V, Optional[float]]:
distance_dict: Dict[V, Optional[float]] = {}
for i in range(len(distances)):
distance_dict[wg.vertex_at(i)] = distances[i]
return distance_dict
4.7 Exercícios
1. Acrescente suporte no framework de grafos para remoção de arestas
e de vértices.
2. Acrescente suporte no framework de grafos para grafos direcionados
(digrafos).
3. Utilize o framework de grafos deste capítulo para comprovar ou
refutar o problema clássico das Pontes de Königsberg, conforme
descrito na Wikipédia:
https://en.wikipedia.org/wiki/Seven_Bridges_of_Königsberg.
1 Dados do American Fact Finder do United States Census Bureau, https://fact nder.census.gov/.
2 Elon Musk, “Hyperloop Alpha”, http://mng.bz/chmu.
3 Helena Durnová, “Otakar Borůvka (1899-1995) and the Minimum Spanning Tree” (Otakar
Borůvka (1899-1995) e a Árvore Geradora Mínima) (Instituto de Matemática da Academia
Tcheca de Ciências, 2006), http://mng.bz/O2vj.
4 Inspirado em uma solução de Robert Sedgewick e Kevin Wayne, Algorithms, 4ª edição
(Addison-Wesley Professional, 2011), p. 619.
CAPÍTULO 5
Algoritmos genéticos
@classmethod
@abstractmethod
def random_instance(cls: Type[T]) -> T:
...
@abstractmethod
def crossover(self: T, other: T) -> Tuple[T, T]:
...
@abstractmethod
def mutate(self) -> None:
...
DICA Você notará que o TypeVar T está limitado a Chromosome em seu construtor. Isso
signi ca que tudo que preencher uma variável do tipo T deve ser uma instância de
Chromosome ou de uma subclasse de Chromosome .
Implementaremos o algoritmo propriamente dito (o código que
manipulará os cromossomos) como uma classe genérica, passível de ter
subclasses para futuras aplicações especializadas. Antes de fazer isso,
porém, vamos rever a descrição de um algoritmo genético do início do
capítulo e de nir claramente os passos que esse algoritmo executa:
1. Crie uma população inicial de cromossomos aleatórios para a
primeira geração do algoritmo.
2. Avalie a aptidão de cada cromossomo nessa geração da população. Se
algum deles exceder o limiar, devolva-o, e o algoritmo terminará.
3. Selecione alguns indivíduos para se reproduzir, com uma
probabilidade maior de selecionar aqueles com as melhores aptidões.
4. Faça um crossover (combinação), com certa probabilidade, de alguns
cromossomos selecionados, a m de criar lhos que representem a
população para a próxima geração.
5. Faça uma mutação, geralmente com uma baixa probabilidade, em
alguns desses cromossomos. A população da nova geração agora
estará completa e substituirá a população da geração anterior.
6. Retorne ao passo 2, a menos que o número máximo de gerações
tenha sido alcançado. Se isso acontecer, devolva o melhor
cromossomo encontrado até então.
Há vários detalhes importantes que estão ausentes nessa descrição geral
de um algoritmo genético (ilustrado na Figura 5.1). Quantos
cromossomos deve haver na população? Qual é o limiar para interromper
o algoritmo? Como os cromossomos devem ser selecionados para
reprodução? Como devem ser combinados (crossover) e com qual
probabilidade? Com qual probabilidade as mutações devem ocorrer?
Quantas gerações deve haver?
Figura 5.1 – Esquema geral de um algoritmo genético.
Todos esses pontos serão con guráveis em nossa classe
GeneticAlgorithm . Vamos de ni-la por partes para que possamos discutir
cada uma separadamente.
Listagem 5.2 – genetic_algorithm.py
from __future__ import annotations
from typing import TypeVar, Generic, List, Tuple, Callable
from enum import Enum
from random import choices, random
from heapq import nlargest
from statistics import mean
from chromosome import Chromosome
class GeneticAlgorithm(Generic[C]):
SelectionType = Enum("SelectionType", "ROULETTE TOURNAMENT")
GeneticAlgorithm recebe um tipo genérico que está em consonância com
Chromosome , e seu nome é C . O enum SelectionType é um tipo interno usado
para especi car o método de seleção utilizado pelo algoritmo. Os dois
métodos de seleção mais comuns em algoritmos genéticos são
conhecidos como seleção por roleta (roulette-wheel selection) – às vezes
chamada de seleção proporcional à aptidão ( tness proportionate
selection) – e a seleção por torneio (tournament selection). O primeiro dá
a todos os cromossomos uma chance de ser escolhido, proporcional à
sua aptidão. Na seleção por torneio, um determinado número de
cromossomos aleatórios é desa ado, uns contra os outros, e aquele com
a melhor aptidão será selecionado.
Listagem 5.3 – Continuação de genetic_algorithm.py
def __init__(self, initial_population: List[C], threshold: float,
max_generations: int = 100, mutation_chance: float = 0.01,
crossover_chance: float = 0.7, selection_type: SelectionType =
SelectionType.TOURNAMENT) -> None:
self._population: List[C] = initial_population
self._threshold: float = threshold
self._max_generations: int = max_generations
self._mutation_chance: float = mutation_chance
self._crossover_chance: float = crossover_chance
self._selection_type: GeneticAlgorithm.SelectionType = selection_type
self._fitness_key: Callable = type(self._population[0]).fitness
class SimpleEquation(Chromosome):
def __init__(self, x: int, y: int) -> None:
self.x: int = x
self.y: int = y
@classmethod
def random_instance(cls) -> SimpleEquation:
return SimpleEquation(randrange(100), randrange(100))
Como podemos ver, ele chegou à solução correta, conforme obtida antes
por meio de cálculo: x = 3 e y = 2. Você pode ter percebido também que,
quase sempre, a cada geração o algoritmo chegou mais próximo da
resposta correta.
Leve em consideração que o algoritmo genético exigiu mais capacidade
de processamento do que outros métodos utilizariam para encontrar a
solução. No mundo real, um problema de maximização simples como
esse não constituiria um bom uso de um algoritmo genético. Contudo,
sua implementação simples pelo menos é su ciente para comprovar que
o nosso algoritmo genético funciona.
class SendMoreMoney2(Chromosome):
def __init__(self, letters: List[str]) -> None:
self.letters: List[str] = letters
@classmethod
def random_instance(cls) -> SendMoreMoney2:
letters = ["S", "E", "N", "D", "M", "O", "R", "Y", " ", " "]
shuffle(letters)
return SendMoreMoney2(letters)
Essa solução mostra que SEND = 8324, MORE = 913 e MONEY = 9237.
Como isso é possível? Parece haver letras faltando na solução. De fato, se
M = 0, há diversas soluções para o problema, as quais não eram possíveis
com a versão que vimos no Capítulo 3. MORE é, na verdade, 0913 em
nosso caso, e MONEY é 09237. O 0 é simplesmente ignorado.
@property
def bytes_compressed(self) -> int:
return getsizeof(compress(dumps(self.lst)))
if __name__ == "__main__":
initial_population: List[ListCompression] =
[ListCompression.random_instance() for _ in range(1000)]
ga: GeneticAlgorithm[ListCompression] =
GeneticAlgorithm(initial_population=initial_population,
threshold=1.0,
max_generations = 1000, mutation_chance = 0.2, crossover_chance =
0.7,
selection_type=GeneticAlgorithm.SelectionType.TOURNAMENT)
result: ListCompression = ga.run()
print(result)
1 Artem Sokolov e Darrell Whitley, “Unbiased Tournament Selection” (Seleção por torneio sem
distorção), GECCO’05 (25 a 29 de junho de 2005, Washington, D.C., U.S.A.),
http://mng.bz/S7l6.
2 Reza Abbasian e Masoud Mazloom, “Solving Cryptarithmetic Problems Using Parallel Genetic
Algorithm” (Resolvendo problemas de criptoaritmética usando um algoritmo genético
paralelo), 2009 Second International Conference on Computer and Electrical Engineering
(Segunda Conferência Internacional de Engenharia Elétrica e Computação),
http://mng.bz/RQ7V.
3 Por exemplo, poderíamos acabar com mais números próximos de 0 do que próximos de 1 se
simplesmente dividíssemos 1 por uma distribuição uniforme de inteiros, o que – considerando
as sutilezas de como os microprocessadores típicos interpretam números de ponto utuante –
poderia levar a certos resultados inesperados. Um modo alternativo de converter um problema
de minimização em um problema de maximização é simplesmente inverter o sinal (deixá-lo
negativo, em vez de positivo). Contudo, essa solução só funcionará se os valores forem todos
positivos, para começar.
4 Steven Skiena, The Algorithm Design Manual, 2ª edição (Springer, 2009), p. 267.
5 A.E. Eiben e J.E. Smith, Introduction to Evolutionary Computation, 2ª edição (Springer, 2015), p.
80.
CAPÍTULO 6
Clustering k-means
class DataPoint:
def __init__(self, initial: Iterable[float]) -> None:
self._originals: Tuple[float, ...] = tuple(initial)
self.dimensions: Tuple[float, ...] = tuple(initial)
@property
def num_dimensions(self) -> int:
return len(self.dimensions)
class KMeans(Generic[Point]):
@dataclass
class Cluster:
points: List[Point]
centroid: DataPoint
@property
def _centroids(self) -> List[DataPoint]:
return [x.centroid for x in self._clusters]
class Governor(DataPoint):
def __init__(self, longitude: float, age: float, state: str) -> None:
super().__init__([longitude, age])
self.longitude = longitude
self.age = age
self.state = state
def __repr__(self) -> str:
return f"{self.state}: (longitude: {self.longitude}, age:
{self.age})"
class Album(DataPoint):
def __init__(self, name: str, year: int, length: float, tracks: float) -
> None:
super().__init__([length, tracks])
self.name = name
self.year = year
self.length = length
self.tracks = tracks
if __name__ == "__main__":
albums: List[Album] =
[Album("Got to Be There", 1972, 35.45, 10), Album("Ben", 1972, 31.31,
10),
Album("Music & Me", 1973, 32.09, 10), Album("Forever, Michael", 1975,
33.36, 10),
Album("Off the Wall", 1979, 42.28, 10), Album("Thriller", 1982, 42.19,
9),
Album("Bad", 1987, 48.16, 10), Album("Dangerous", 1991, 77.03, 14),
Album("HIStory: Past, Present and Future, Book I", 1995, 148.58, 30),
Album("Invincible", 2001, 77.05, 16)]
kmeans: KMeans[Album] = KMeans(2, albums)
clusters: List[KMeans.Cluster] = kmeans.run()
for index, cluster in enumerate(clusters):
print(f"Cluster {index} Avg Length {cluster.centroid.dimensions[0]
} Avg Tracks {cluster.centroid.dimensions[1]}:
{cluster.points}\n")
Observe que os atributos name e year são registrados somente por questões
de nomenclatura, e não estão incluídos no clustering propriamente dito.
Eis um exemplo de saída:
Converged after 1 iterations
Cluster 0 Avg Length -0.5458820039179509 Avg Tracks -0.5009878988684237:
[Got to Be There, 1972, Ben, 1972, Music & Me, 1973, Forever, Michael,
1975, Off the Wall, 1979, Thriller, 1982, Bad, 1987]
Cluster 1 Avg Length 1.2737246758085523 Avg Tracks 1.1689717640263217:
[Dangerous, 1991, HIStory: Past, Present and Future, Book I, 1995,
Invincible, 2001]
6.7 Exercícios
1. Crie uma função capaz de importar dados de um arquivo CSV para
DataPoint s.
2. Crie uma função usando uma biblioteca externa, como a matplotlib,
que crie um grá co de dispersão colorido com os resultados de
qualquer execução de KMeans em um conjunto de dados bidirecional.
3. Crie outro código de inicialização de KMeans que aceite posições
iniciais para os centroides, em vez de atribuí-los aleatoriamente.
4. Faça pesquisas sobre o algoritmo k-means++ e o implemente.
CAPÍTULO 7
7.2.1 Neurônios
A menor unidade em uma rede neural arti cial é o neurônio. Ele
armazena um vetor de pesos, que são apenas números de ponto utuante.
Um vetor de entradas (também composto apenas de números de ponto
utuante) é passado para o neurônio. O neurônio combina essas entradas
com seus pesos usando um produto escalar. Em seguida, ele executa uma
função de ativação nesse produto e disponibiliza esse resultado como
saída. Podemos pensar nessa ação como análoga ao disparo de um
verdadeiro neurônio.
Uma função de ativação é um transformador da saída do neurônio. A
função de ativação é quase sempre não linear, e isso permite que as redes
neurais representem soluções para problemas não lineares. Se não
houvesse funções de ativação, a rede neural completa seria apenas uma
transformação linear. A Figura 7.2 mostra um único neurônio e o seu
funcionamento.
Figura 7.2 – Um único neurônio combina seus pesos com sinais de entrada a
m de gerar um sinal de saída que é modi cado por uma função de
ativação.
NOTA Há alguns termos matemáticos nesta seção, os quais talvez você não tenha visto desde
um curso básico de cálculo ou de álgebra linear. Explicar o que são vetores ou produtos
escalares está além do escopo deste capítulo, mas você provavelmente terá uma intuição
sobre o que uma rede neural faz se acompanhar este capítulo, mesmo que não compreenda
toda a matemática. Mais adiante, haverá um pouco de cálculo, incluindo uso de derivadas e
derivadas parciais, mas, mesmo que não compreenda toda a matemática envolvida, você
deverá ser capaz de entender o código. Este capítulo, na verdade, não explicará como derivar
as fórmulas usando cálculo. Em vez disso, o foco estará no uso das derivadas.
7.2.2 Camadas
Em uma rede neural arti cial feedforward típica, os neurônios estão
organizados em camadas. Cada camada é composta de determinado
número de neurônios alinhados em uma linha ou coluna (dependerá do
diagrama; ambos são equivalentes). Em uma rede feedforward, que é a
rede que construiremos, os sinais sempre trafegam na mesma direção, de
uma camada para a próxima. Os neurônios de cada camada enviam seus
sinais de saída para que sejam utilizados como entrada para os neurônios
da próxima camada. Todo neurônio em qualquer camada está conectado
a todos os neurônios da próxima camada.
A primeira camada é conhecida como camada de entrada, e recebe seus
sinais de alguma entidade externa. A última camada é conhecida como
camada de saída, e sua saída em geral deve ser interpretada por um agente
externo para que um resultado inteligente seja obtido. As camadas entre
as camadas de entrada e de saída são conhecidas como camadas ocultas.
Em redes neurais simples como aquela que construiremos neste capítulo,
há apenas uma camada oculta, mas as redes de aprendizagem profunda
(redes deep learning) têm várias camadas. A Figura 7.3 mostra as camadas
funcionando em conjunto em uma rede simples. Observe como as saídas
de uma camada são utilizadas como entradas para todos os neurônios da
próxima camada.
Essas camadas simplesmente manipulam números de ponto utuante.
As entradas para a camada de entrada são números de ponto utuante, e
as saídas da camada de saída também são números de ponto utuante.
Obviamente esses números devem representar algo signi cativo.
Suponha que a rede tenha sido projetada para classi car pequenas
imagens de animais em preto e branco. A camada de entrada poderia ter
100 neurônios representando a intensidade na escala de cinzas para cada
pixel, em uma imagem de 10 x 10 pixels de um animal, e a camada de
saída teria 5 neurônios que representariam a probabilidade de a imagem
ser de um mamífero, um réptil, um anfíbio, um peixe ou uma ave. A
classi cação nal poderia ser determinada pelo neurônio de saída que
gerasse o maior número de ponto utuante. Se os números de saída
fossem 0,24, 0,65, 0,70, 0,12 e 0,21, respectivamente, a imagem seria
classi cada como de um anfíbio.
Figura 7.3 – Uma rede neural simples, com uma camada de entrada
contendo dois neurônios, uma camada oculta com quatro neurônios e uma
camada de saída com três neurônios. Nessa gura, o número de neurônios
em cada camada é arbitrário.
7.2.3 Retropropagação
A última peça do quebra-cabeça, e a parte inerentemente mais complexa,
é a retropropagação (backpropagation). A retropropagação encontra o
erro na saída de uma rede neural e utiliza esse dado para modi car os
pesos dos neurônios. Os neurônios mais responsáveis pelo erro são os
que sofrerão mais modi cação. Mas de onde vem o erro? Como é
possível saber qual é o erro? O erro é oriundo de uma fase do uso de
uma rede neural conhecida como treinamento.
DICA Há passos descritos (textualmente) para diversas fórmulas matemáticas nesta seção.
Pseudofórmulas (que não utilizam uma notação apropriada) estão nas guras que
acompanham a descrição. Essa abordagem deixará as fórmulas mais legíveis para as pessoas
que não tenham conhecimento da notação matemática (ou que estejam sem prática). Se
você tiver interesse na notação mais formal (e na derivação das fórmulas), consulte o
Capítulo 18 do livro Arti cial Intelligence de Norvig e Russell.1
Antes que seja possível usá-la, a maioria das redes neurais deve passar por
um treinamento. Devemos saber quais são as saídas corretas para algumas
entradas, de modo que possamos usar a diferença entre as saídas
esperadas e as saídas reais para identi car os erros e modi car os pesos.
Em outras palavras, as redes neurais não sabem de nada até que as
respostas corretas lhes sejam informadas para um determinado conjunto
de entradas; desse modo, elas poderão se preparar para outras entradas.
A retropropagação ocorre apenas durante o treinamento.
NOTA Como a maioria das redes neurais deve passar por um treinamento, elas são
consideradas como um tipo de aprendizagem de máquina supervisionada . Lembre-se de que,
conforme vimos no Capítulo 6, o algoritmo k-means e outros algoritmos de clustering
(agrupamento) são considerados como uma forma de aprendizagem de máquina não
supervisionada porque, depois de iniciados, nenhuma intervenção externa é necessária. Há
outros tipos de redes neurais além da rede descrita neste capítulo, as quais não exigem
treinamento prévio e são consideradas como uma forma de aprendizagem não
supervisionada.
O primeiro passo na retropropagação é calcular o erro entre a saída da
rede neural para uma entrada e a saída esperada. Esse erro está espalhado
por todos os neurônios da camada de saída. (Cada neurônio tem uma
saída esperada e a sua saída real.) A derivada da função de ativação do
neurônio de saída é então aplicada no valor que foi gerado pelo neurônio
como saída, antes de sua função de ativação ter sido aplicada.
(Armazenamos a saída em cache, antes da aplicação da função de
ativação.) Esse resultado é multiplicado pelo erro do neurônio para
calcular o seu delta. Essa fórmula para calcular o delta utiliza uma
derivada parcial, e o cálculo dessa derivada está além do escopo deste
livro; basicamente, porém, estamos calculando qual é a parcela de erro
pela qual cada neurônio de saída foi responsável. Veja a Figura 7.4 que
apresenta um diagrama desse cálculo.
Os deltas devem ser então calculados para cada neurônio da(s)
camada(s) oculta(s) da rede. Devemos determinar a parcela de erro pela
qual cada neurônio foi responsável ao gerar a saída incorreta na camada
de saída. Os deltas na camada de saída são usados para calcular os deltas
da camada oculta anterior. Para cada camada anterior, os deltas são
calculados tomando-se o produto escalar dos pesos da próxima camada
em relação ao neurônio especí co em questão e os deltas já calculados na
próxima camada. Esse valor é multiplicado pela derivada da função de
ativação aplicada à última saída de um neurônio (armazenada em cache
antes de a função de ativação ter sido aplicada) a m de obter o delta do
neurônio. Novamente, essa fórmula é obtida usando uma derivada
parcial, a respeito da qual você poderá ler em textos com enfoque maior
em matemática.
class Neuron:
def __init__(self, weights: List[float], learning_rate: float,
activation_function: Callable[[float], float],
derivative_activation_function: Callable[[float], float]) -> None:
self.weights: List[float] = weights
self.activation_function: Callable[[float], float] =
activation_function
self.derivative_activation_function: Callable[[float], float] =
derivative_activation_function
self.learning_rate: float = learning_rate
self.output_cache: float = 0.0
self.delta: float = 0.0
def output(self, inputs: List[float]) -> float:
self.output_cache = dot_product(inputs, self.weights)
return self.activation_function(self.output_cache)
class Layer:
def __init__(self, previous_layer: Optional[Layer], num_neurons: int,
learning_rate: float, activation_function: Callable[[float], float],
derivative_activation_function: Callable[[float], float]) -> None:
self.previous_layer: Optional[Layer] = previous_layer
self.neurons: List[Neuron] = []
# todo o código a seguir poderia ser uma grande list comprehension
for i in range(num_neurons):
if previous_layer is None:
random_weights: List[float] = []
else:
random_weights = [random() for _ in
range(len(previous_layer.neurons))]
neuron: Neuron = Neuron(random_weights, learning_rate,
activation_function, derivative_activation_function)
self.neurons.append(neuron)
self.output_cache: List[float] = [0.0 for _ in range(num_neurons)]
class Network:
def __init__(self, layer_structure: List[int], learning_rate: float,
activation_function: Callable[[float], float] = sigmoid,
derivative_activation_function: Callable[[float], float] =
derivative_sigmoid) -> None:
if len(layer_structure) < 3:
raise ValueError("Error: Should be at least 3 layers (
1 input, 1 hidden, 1 output)")
self.layers: List[Layer] = []
# camada de entrada
input_layer: Layer = Layer(None, layer_structure[0], learning_rate,
activation_function, derivative_activation_function)
self.layers.append(input_layer)
# camadas ocultas e camada de saída
for previous, num_neurons in enumerate(layer_structure[1::]):
next_layer = Layer(self.layers[previous], num_neurons,
learning_rate, activation_function,
derivative_activation_function)
self.layers.append(next_layer)
As saídas da rede neural são o resultado dos sinais passando por todas as
suas camadas. Observe como reduce() é usado de modo compacto em
outputs() para passar sinais de uma camada para a próxima repetidamente,
por toda a rede.
Listagem 7.8 – Continuação de network.py
# Fornece dados de entrada para a primeira camada; em seguida, a saída da
primeira
# é fornecida como entrada para a segunda, a saída da segunda para a
terceira etc.
def outputs(self, input: List[float]) -> List[float]:
return reduce(lambda inputs, layer: layer.outputs(inputs), self.layers,
input)
A rede neural está pronta! Pronta para ser testada com alguns problemas
reais. Embora a arquitetura que construímos seja su cientemente
genérica e seja possível usá-la em diversos problemas, nosso foco estará
em um tipo conhecido de problemas: a classi cação.
if __name__ == "__main__":
iris_parameters: List[List[float]] = []
iris_classifications: List[List[float]] = []
iris_species: List[str] = []
with open('iris.csv', mode='r') as iris_file:
irises: List = list(csv.reader(iris_file))
shuffle(irises) # deixa nossas linhas de dados em ordem aleatória
for iris in irises:
parameters: List[float] = [float(n) for n in iris[0:4]]
iris_parameters.append(parameters)
species: str = iris[4]
if species == "Iris-setosa":
iris_classifications.append([1.0, 0.0, 0.0])
elif species == "Iris-versicolor":
iris_classifications.append([0.0, 1.0, 0.0])
else:
iris_classifications.append([0.0, 0.0, 1.0])
iris_species.append(species)
normalize_by_feature_scaling(iris_parameters)
Todo esse trabalho nos trouxe até a seguinte pergunta nal: dentre 10
amostras de íris escolhidas aleatoriamente do conjunto de dados, quantas
a nossa rede neural consegue classi car corretamente? Como há
aleatoriedade nos pesos iniciais de cada neurônio, diferentes execuções
poderão fornecer resultados distintos. Você pode tentar ajustar a taxa de
aprendizagem, o número de neurônios ocultos e a quantidade de
iterações no treinamento para deixar sua rede mais precisa.
Ao nal, você deverá ver um resultado semelhante a este:
9 correct of 10 = 90.0%
if __name__ == "__main__":
wine_parameters: List[List[float]] = []
wine_classifications: List[List[float]] = []
wine_species: List[int] = []
with open('wine.csv', mode='r') as wine_file:
wines: List = list(csv.reader(wine_file,
quoting=csv.QUOTE_NONNUMERIC))
shuffle(wines) # deixa nossas linhas de dados em ordem aleatória
for wine in wines:
parameters: List[float] = [float(n) for n in wine[1:14]]
wine_parameters.append(parameters)
species: int = int(wine[0])
if species == 1:
wine_classifications.append([1.0, 0.0, 0.0])
elif species == 2:
wine_classifications.append([0.0, 1.0, 0.0])
else:
wine_classifications.append([0.0, 0.0, 1.0])
wine_species.append(species)
normalize_by_feature_scaling(wine_parameters)
Com um pouco de sorte, sua rede neural deverá ser capaz de classi car as
28 amostras com bastante precisão.
27 correct of 28 = 96.42857142857143%
1 Stuart Russell e Peter Norvig, Arti cial Intelligence: A Modern Approach, 3ª edição (Pearson,
2010) (N.T.: Edição publicada no Brasil: Inteligência Arti cial [Campus, 2013]).
2 O repositório está disponível no GitHub em
https://github.com/davecom/ClassicComputerScienceProblemsInPython.
3 M. Lichman, UCI Machine Learning Repository (Irvine, CA: University of California, School of
Information and Computer Science, 2013), http://archive.ics.uci.edu/ml.
CAPÍTULO 8
Busca competitiva
class Piece:
@property
def opposite(self) -> Piece:
raise NotImplementedError("Should be implemented by subclasses.")
class Board(ABC):
@property
@abstractmethod
def turn(self) -> Piece:
...
@abstractmethod
def move(self, location: Move) -> Board:
...
@property
@abstractmethod
def legal_moves(self) -> List[Move]:
...
@property
@abstractmethod
def is_win(self) -> bool:
...
@property
def is_draw(self) -> bool:
return (not self.is_win) and (len(self.legal_moves) == 0)
@abstractmethod
def evaluate(self, player: Piece) -> float:
...
O tipo Move representará um movimento em um jogo. Em sua essência, é
apenas um inteiro. Em jogos como jogo da velha e Connect Four, um
inteiro pode representar um movimento, indicando um quadrado ou uma
coluna no qual uma peça deve ser colocada. Piece é a classe-base para
uma peça no tabuleiro de um jogo. Ela também servirá como indicador
de turno. É por isso que a propriedade opposite é necessária. Precisamos
saber de quem é o turno que se segue a um dado turno.
DICA Como o jogo da velha e o Connect Four têm apenas um tipo de peça, a classe Piece
poderá servir também como um indicador de turno neste capítulo. Em um jogo mais
complexo, como xadrez, que tem diferentes tipos de peças, os turnos podem ser
representados por um inteiro ou um booleano. Como alternativa, o atributo de “cor” de um
tipo Piece mais complexo poderia ser usado para indicar o turno.
A classe-base abstrata Board é a verdadeira mantenedora do estado. Para
qualquer dado jogo que nossos algoritmos de busca processarão,
devemos ser capazes de responder a quatro perguntas:
• De quem é o turno?
• Quais movimentos permitidos podem ser feitos na posição atual?
• O jogo foi vencido?
• O jogo está empatado?
A última pergunta, sobre empates, na verdade é uma combinação das
duas perguntas anteriores em vários jogos. Se o jogo não foi vencido, mas
não há mais movimentos permitidos, é sinal de que houve um empate. É
por isso que a nossa classe-base abstrata Game já pode ter uma
implementação concreta da propriedade is_draw. Além do mais, há duas
ações que devemos ser capazes de executar:
• fazer um movimento para ir da posição atual para uma nova posição;
• avaliar a posição a m de ver qual jogador tem uma vantagem.
Cada um dos métodos e propriedades em Board é um proxy para uma das
perguntas ou ações anteriores. A classe Board poderia também ter sido
chamada de Position no linguajar dos jogos, mas usaremos essa
nomenclatura para algo mais especí co em cada uma de nossas
subclasses.
@property
def opposite(self) -> TTTPiece:
if self == TTTPiece.X:
return TTTPiece.O
elif self == TTTPiece.O:
return TTTPiece.X
else:
return TTTPiece.E
@property
def turn(self) -> Piece:
return self._turn
Um tabuleiro default é um tabuleiro no qual nenhum movimento foi feito
(um tabuleiro vazio). O construtor de Board tem parâmetros default que
inicializam uma posição como essa, com o movimento igual a X (em
geral, é o primeiro jogador no jogo da velha). Talvez você esteja se
perguntando por que temos a variável de instância _turn e a propriedade
turn . Foi um truque para garantir que todas as subclasses de Board
manterão o controle do jogador a quem o turno pertence. Não há
nenhuma maneira clara e óbvia em Python de especi car, em uma classe-
base abstrata, que as suas subclasses devem incluir uma variável de
instância especí ca, mas há um mecanismo desse tipo para as
propriedades.
TTTBoard é uma estrutura de dados informalmente imutável; TTTBoard s não
devem ser modi cados. Sempre que um movimento tiver de ser feito, um
novo TTTBoard com a posição alterada para acomodar o movimento será
gerado. Mais tarde, isso será conveniente em nosso algoritmo de busca.
Quando a busca tiver rami cações, não modi caremos inadvertidamente
a posição de um tabuleiro a partir do qual movimentos possíveis ainda
estão sendo analisados.
Listagem 8.4 – Continuação de tictactoe.py
def move(self, location: Move) -> Board:
temp_position: List[TTTPiece] = self.position.copy()
temp_position[location] = self._turn
return TTTBoard(temp_position, self._turn.opposite)
8.2.2 Minimax
O minimax é um algoritmo clássico para encontrar o melhor movimento
em um jogo de informações perfeitas de soma zero para dois jogadores,
como o jogo da velha, o jogo de damas ou o xadrez. O algoritmo foi
expandido e modi cado para outros tipos de jogos também. O minimax
em geral é implementado com uma função recursiva, na qual cada
jogador é designado como o jogador maximizador ou o jogador
minimizador.
O jogador maximizador tem como objetivo encontrar o movimento que
resultará em ganhos máximos. No entanto, o jogador maximizador deve
levar em consideração os movimentos feitos pelo jogador minimizador.
Depois de cada tentativa de maximizar os ganhos do jogador
maximizador, o minimax é chamado recursivamente para encontrar a
resposta do adversário que minimize os ganhos do jogador maximizador.
Esse processo continua nos dois sentidos (maximizando, minimizando,
maximizando e assim por diante), até que se alcance um caso de base na
função recursiva. O caso de base é uma posição nal (uma vitória ou um
empate) ou uma profundidade máxima na busca.
O minimax devolverá uma avaliação da posição inicial para o jogador
maximizador. Para o método evaluate() da classe TTTBoard, se a melhor
jogada possível de ambos os lados vai resultar em uma vitória do jogador
maximizador, uma pontuação igual a 1 será devolvida. Se a melhor jogada
resultar em uma derrota, -1 será devolvido. Um 0 será devolvido se a
melhor jogada for um empate.
Esses números serão devolvidos quando um caso de base for alcançado.
Eles então se propagam por toda a cadeia de chamadas recursivas que
levaram até o caso de base. Para cada chamada recursiva para maximizar,
as melhores avaliações em um nível abaixo serão enviadas para cima. Para
cada chamada recursiva para minimizar, as piores avaliações em um nível
abaixo serão enviadas para cima. Desse modo, uma árvore de decisão será
construída. A Figura 8.2 mostra uma árvore desse tipo, que facilita que o
resultado se propague para cima na cadeia, em um jogo com dois
movimentos restantes.
Para jogos que tenham um espaço de busca muito profundo para
alcançar uma posição nal (como o jogo de damas e o xadrez), o
minimax será interrompido após certa profundidade (o número de
movimentos em profundidade a serem pesquisados, às vezes chamado de
ply [níveis]). Em seguida, a função de avaliação entra em cena, usando
dados heurísticos para dar uma pontuação ao estado do jogo. Quanto
melhor o jogo para o jogador inicial, maior será a pontuação atribuída.
Retomaremos esse conceito no Connect Four, que tem um espaço de
busca muito maior do que o jogo da velha.
Figura 8.2 – Uma árvore de decisão do minimax para um jogo da velha com
dois movimentos restantes. Para maximizar a probabilidade de vencer, o
primeiro jogador, 0, escolherá colocar 0 na parte inferior central. As setas
indicam as posições a partir das quais uma decisão é tomada.
Eis o minimax() completo:
Listagem 8.8 – minimax.py
from __future__ import annotations
from board import Piece, Board, Move
class TTTMinimaxTestCase(unittest.TestCase):
def test_easy_position(self):
# vitória em um movimento
to_win_easy_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.O,
TTTPiece.X,
TTTPiece.X, TTTPiece.E,
TTTPiece.O,
TTTPiece.E, TTTPiece.E,
TTTPiece.O]
test_board1: TTTBoard = TTTBoard(to_win_easy_position, TTTPiece.X)
answer1: Move = find_best_move(test_board1)
self.assertEqual(answer1, 6)
def test_block_position(self):
# deve bloquear a vitória de O
to_block_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.E,
TTTPiece.E,
TTTPiece.E, TTTPiece.E,
TTTPiece.O,
TTTPiece.E, TTTPiece.X,
TTTPiece.O]
test_board2: TTTBoard = TTTBoard(to_block_position, TTTPiece.X)
answer2: Move = find_best_move(test_board2)
self.assertEqual(answer2, 2)
def test_hard_position(self):
# calcula o melhor movimento para ganhar em 2 movimentos
to_win_hard_position: List[TTTPiece] = [TTTPiece.X, TTTPiece.E,
TTTPiece.E,
TTTPiece.E, TTTPiece.E,
TTTPiece.O,
TTTPiece.O, TTTPiece.X,
TTTPiece.E]
test_board3: TTTBoard = TTTBoard(to_win_hard_position, TTTPiece.X)
answer3: Move = find_best_move(test_board3)
self.assertEqual(answer3, 1)
if __name__ == '__main__':
unittest.main()
if __name__ == "__main__":
# laço principal do jogo
while True:
human_move: Move = get_player_move()
board = board.move(human_move)
if board.is_win:
print("Human wins!")
break
elif board.is_draw:
print("Draw!")
break
computer_move: Move = find_best_move(board)
print(f"Computer move is {computer_move}")
board = board.move(computer_move)
print(board)
if board.is_win:
print("Computer wins!")
break
elif board.is_draw:
print("Draw!")
break
@property
def opposite(self) -> C4Piece:
if self == C4Piece.B:
return C4Piece.R
elif self == C4Piece.R:
return C4Piece.B
else:
return C4Piece.E
A classe C4Board tem uma classe interna chamada Column. Essa classe não é
estritamente necessária porque poderíamos ter usado uma lista
unidimensional para representar o tabuleiro, como zemos no jogo da
velha, ou igualmente, uma lista bidimensional. Usar a classe Column
provavelmente reduzirá um pouco o desempenho, em comparação com
qualquer uma dessas soluções. Contudo, pensar no tabuleiro do Connect
Four como um grupo de sete colunas é conceitualmente e ciente e facilita
um pouco escrever o resto da classe C4Board.
Listagem 8.15 – Continuação de connectfour.py
class Column:
def __init__(self) -> None:
self._container: List[C4Piece] = []
@property
def full(self) -> bool:
return len(self._container) == C4Board.NUM_ROWS
@property
def turn(self) -> Piece:
return self._turn
@property
def legal_moves(self) -> List[Move]:
return [Move(c) for c in range(C4Board.NUM_COLUMNS) if not
self.position[c].full]
Um método auxiliar _count_segment() devolve o número de peças pretas e
vermelhas em um segmento especí co. É seguido de um método para
veri car se há uma vitória, is_win(), o qual examina todos os segmentos
do tabuleiro e determina se há um vencedor usando _count_segment() para
ver se algum segmento tem quatro peças da mesma cor.
Listagem 8.17 Continuação de connectfour.py
# Devolve o número de peças pretas e vermelhas em um segmento
def _count_segment(self, segment: List[Tuple[int, int]]) -> Tuple[int, int]:
black_count: int = 0
red_count: int = 0
for column, row in segment:
if self.position[column][row] == C4Piece.B:
black_count += 1
elif self.position[column][row] == C4Piece.R:
red_count += 1
return black_count, red_count
@property
def is_win(self) -> bool:
for segment in C4Board.SEGMENTS:
black_count, red_count = self._count_segment(segment)
if black_count == 4 or red_count == 4:
return True
return False
if __name__ == "__main__":
# laço principal do jogo
while True:
human_move: Move = get_player_move()
board = board.move(human_move)
if board.is_win:
print("Human wins!")
break
elif board.is_draw:
print("Draw!")
break
computer_move: Move = find_best_move(board, 3)
print(f"Computer move is {computer_move}")
board = board.move(computer_move)
print(board)
if board.is_win:
print("Computer wins!")
break
elif board.is_draw:
print("Draw!")
break
Agora você pode fazer duas pequenas alterações para tirar proveito de
nossa nova função. Modi que find_best_move() em minimax.py para que
use alphabeta() no lugar de minimax(), e altere a profundidade da busca em
connectfour_ai.py, de 3 para 5. Com essas alterações, um jogador médio
de Connect Four não será capaz de derrotar a nossa IA. Em meu
computador, usando o minimax() com uma profundidade igual a 5, nossa
IA do Connect Four demorou aproximadamente 3 minutos por
movimento, enquanto usar o alphabeta() com a mesma profundidade
exigiu cerca de 30 segundos por movimento. Isso representa um sexto do
tempo! É uma melhoria incrível.
8.6 Exercícios
1. Acrescente testes de unidade no jogo da velha a m de garantir que as
propriedades legal_moves, is_win e is_draw funcionam corretamente.
2. Crie testes de unidade para o minimax no Connect Four.
3. Os códigos em tictactoe_ai.py e em connectfour_ai.py são quase
idênticos. Refatore-os em dois métodos que possam ser usados por
qualquer um dos jogos.
4. Modi que connectfour_ai.py para fazer o computador jogar contra si
mesmo. Quem vence é o primeiro ou é o segundo jogador? É sempre
o mesmo jogador?
5. Você é capaz de encontrar uma maneira (por meio do pro ling do
código existente, ou de outro modo) de otimizar o método de
avaliação em connectfour.py de modo a permitir uma profundidade
de busca maior no mesmo intervalo de tempo?
6. Use a função alphabeta() desenvolvida neste capitulo, junto com uma
biblioteca Python para gerar movimentos permitidos no xadrez e
manter o estado do jogo, a m de desenvolver uma IA para o xadrez.
1 Connect Four é uma marca registrada da Hasbro, Inc. Foi usada neste livro apenas com ns
descritivos e de modo favorável.
CAPÍTULO 9
Problemas diversos
class Item(NamedTuple):
name: str
weight: int
value: float
Se tentássemos resolver esse problema usando uma abordagem com força
bruta, analisaríamos todas as combinações de itens disponíveis que
poderiam ser colocados na mochila. Para aqueles com inclinações
matemáticas, isso é conhecido como conjunto de partes (powerset), e um
conjunto de partes é um conjunto (em nosso caso, o conjunto de itens)
com 2^N possíveis subconjuntos diferentes, em que N é o número de
itens. Assim, teríamos de analisar 2^N combinações (O(2^N)). Não
haveria problemas com um número baixo de itens, mas será inviável para
um número grande. Qualquer abordagem que resolva um problema
usando um número exponencial de passos é uma abordagem que
devemos evitar.
Como alternativa, usaremos uma técnica conhecida como programação
dinâmica, que é conceitualmente semelhante à memoização (Capítulo 1).
Em vez de resolver o problema diretamente com uma abordagem de força
bruta, na programação dinâmica, resolvemos subproblemas que
compõem o problema maior, armazenamos seus resultados e os
utilizamos para solucionar o problema maior. Desde que a capacidade da
mochila seja considerada em passos discretos, o problema poderá ser
resolvido com a programação dinâmica.
Por exemplo, a m de resolver o problema para uma mochila com
capacidade de 3 libras e três itens, podemos resolver primeiro o problema
para uma capacidade de 1 libra e um item possível, para uma capacidade
de 2 libras e um item possível e para uma capacidade de 3 libras e um item
possível. Então podemos usar os resultados dessa solução a m de
resolver o problema para uma capacidade de 1 libra e dois itens possíveis,
para uma capacidade de 2 libras e dois itens possíveis e para uma
capacidade de 3 libras e dois itens possíveis. Por m, podemos resolver o
problema para todos os três itens possíveis.
Durante todo o processo, preencheremos uma tabela que nos informará
a melhor solução possível para cada combinação de itens e capacidade.
Nossa função inicialmente preencherá a tabela e, em seguida, encontrará
a solução com base nessa tabela.1
Listagem 9.2 – Continuação de knapsack.py
def knapsack(items: List[Item], max_capacity: int) -> List[Item]:
# constrói a tabela com programação dinâmica
table: List[List[float]] = [[0.0 for _ in range(max_capacity + 1)]
for _ in range(len(items) + 1)]
for i, item in enumerate(items):
for capacity in range(1, max_capacity + 1):
previous_items_value: float = table[i][capacity]
if capacity >= item.weight: # o item cabe na mochila
value_freeing_weight_for_item: float = table[i][capacity -
item.weight]
# pega somente se for mais valioso que o item anterior
table[i + 1][capacity] = max(value_freeing_weight_for_item
+ item.value, previous_items_value)
else: # o item não cabe na mochila
table[i + 1][capacity] = previous_items_value
# descobre a solução com base na tabela
solution: List[Item] = []
capacity = max_capacity
for i in range(len(items), 0, -1): # trabalha na ordem inversa
# este item foi usado?
if table[i - 1][capacity] != table[i][capacity]:
solution.append(items[i - 1])
# se o item foi usado, decrementa o seu peso
capacity -= items[i - 1].weight
return solution
Para ter uma ideia melhor de como tudo isso funciona, vamos analisar
algumas das particularidades da função:
for i, item in enumerate(items):
for capacity in range(1, max_capacity + 1):
9.5 Exercícios
1. Reescreva o código da abordagem ingênua para o Problema do
Caixeiro-Viajante usando o framework de grafos do Capítulo 4.
2. Implemente um algoritmo genético, conforme descrito no Capítulo 5,
para resolver o Problema do Caixeiro-Viajante. Comece com o
conjunto de dados simples das cidades de Vermont, descrito neste
capítulo. Você consegue fazer com que o algoritmo genético chegue
na solução ótima em pouco tempo? Em seguida, tente resolver o
problema com um número cada vez maior de cidades. Até que ponto
o algoritmo genético consegue manter um bom desempenho? É
possível encontrar muitos conjuntos de dados especi camente criados
para o Problema do Caixeiro-Viajante pesquisando na internet.
Desenvolva um framework de testes para testar a e ciência de seu
método.
3. Use um dicionário com o programa de dados mnemônicos para
números de telefone e devolva apenas as permutações que contenham
palavras válidas do dicionário.
1 Analisei diversos conteúdos para escrever esta solução, entre os quais o de maior competência
foi o livro Algorithms (Addison-Wesley, 1988), 2ª edição, de Robert Sedgewick (p. 596). Vi
diversos exemplos do problema 0/1 da mochila no site Rosetta Code, com ênfase na solução
com programação dinâmica em Python (http://mng.bz/kx8C), da qual essa função, em sua
maior parte, foi portada, lá da versão do livro para Swift. (Ela passou de Python para Swift e de
volta novamente para Python.)
2 Robert Sedgewick, “Permutation Generation Methods” (Métodos para geração de
permutações) (Universidade de Princeton), http://mng.bz/87Te.
APÊNDICE A
Glossário
Outros recursos
Qual deve ser o seu próximo passo? O livro abordou diversos tópicos, e
este apêndice fará a conexão entre você e outros recursos ótimos que o
ajudarão a explorá-los melhor.
B.1 Python
Conforme a rmamos na introdução, Problemas Clássicos de Ciência da
Computação com Python parte do pressuposto de que você tenha pelo
menos um conhecimento intermediário da linguagem Python. A seguir,
listarei dois livros sobre Python que, pessoalmente, tenho usado e
recomendo, para que você leve seu conhecimento de Python ao próximo
nível. Esses títulos não são apropriados para iniciantes em Python (para
isso, dê uma olhada no livro The Quick Python Book de Naomi Ceder
[Manning, 2018]), mas podem transformar usuários intermediários de
Python em usuários avançados.
• Luciano Ramalho, Python Fluente (Novatec, 2015)
• Um dos únicos livros populares sobre a linguagem Python que não
deixa indistinta a linha entre usuários iniciantes e
intermediários/avançados; este livro está claramente voltado para
programadores intermediários/avançados.
• Aborda diversos tópicos avançados sobre Python.
• Apresenta as melhores práticas; é o livro que ensinará você a
escrever um código “pythônico”.
• Contém vários exemplos de código para cada assunto e explica o
funcionamento interno da biblioteca-padrão de Python.
• Pode ser um pouco extenso em algumas partes, mas você pode
facilmente as ignorar.
• David Beazley e Brian K. Jones, Python Cookbook, 3ª edição (O’Reilly,
2013)1
• Apresenta tarefas comuns do cotidiano por meio de exemplos.
• Algumas das tarefas estão muito além das tarefas para iniciantes.
• Faz uso intenso da biblioteca-padrão de Python.
• Está um pouco desatualizado (não inclui as ferramentas mais
recentes da biblioteca-padrão) por ter sido lançado há mais de cinco
anos; espero que a quarta edição seja lançada logo.
Está muito mais claro agora. Apenas olhando as dicas de tipo, parece que
essa função aceita um item do tipo Any e devolve uma List preenchida
com esse item um número de vezes igual a times. É claro que a
documentação ajudaria a deixar essa função mais compreensível, mas, no
mínimo, o usuário dessa biblioteca sabe agora qual tipo de valores deve
fornecer e qual tipo de valor pode-se esperar que seja devolvido.
Suponha que a biblioteca com a qual essa função deveria ser usada
funcionasse apenas com números de ponto utuante, e essa função
tivesse sido criada para ser aplicada na preparação de listas a serem
utilizadas por outras funções. Podemos facilmente modi car as dicas de
tipo a m de informar a restrição sobre números de ponto utuante:
def repeat(item: float, times: int) -> List[float]:
Agora está claro que item deve ser um float, e que a lista devolvida estará
preenchida com floats. Bem, a palavra deve é bem forte. As dicas de tipo,
conforme tratadas na versão Python 3.7, não têm nenhuma função na
execução de um programa Python. Elas são de fato apenas dicas, e não
imposições. Em tempo de execução, um programa Python pode ignorar
totalmente suas dicas de tipo e violar qualquer uma de suas supostas
restrições. No entanto, ferramentas para veri cação de tipos (type
checkers) podem avaliar as dicas de tipo em um programa durante o
desenvolvimento, e informar o programador caso haja alguma chamada
ilegítima a uma função. Uma chamada para repeat("hello", 30) poderia
ser identi cada antes que fosse introduzida em um ambiente de produção
(porque "hello" não é um float).
Vamos ver outro exemplo. Dessa vez, analisaremos uma dica de tipo
para uma declaração de variável:
myStrs: List[str] = repeat(4.2, 2)
Essa dica de tipo não faz sentido. Ela diz que esperamos que myStrs seja
uma lista de strings. Contudo, sabemos, com base na dica de tipo
anterior, que repeat() devolve uma lista de números de ponto utuante.
Novamente, como Python, na versão 3.7, não faz nenhuma veri cação
para saber se as dicas de tipo estão corretas durante a execução, essa dica
de tipo incorreta não terá efeito algum na execução do programa.
Entretanto, um veri cador de tipos poderia identi car o erro ou a
concepção equivocada desse programador acerca do tipo correto, antes
que houvesse um desastre.
C.3 Por que as dicas de tipo são úteis?
Agora que você já sabe o que são as dicas de tipo, poderá estar se
perguntando por que todo esse trabalho compensaria. A nal de contas,
você também viu que as dicas de tipo são ignoradas por Python em
tempo de execução. Por que alguém gastaria todo esse tempo
acrescentando anotações de tipos no código, se o interpretador Python
não vai se importar? Como já mencionamos rapidamente, as dicas de tipo
são uma boa ideia por dois motivos principais: fornecem uma
documentação automática para o código e permitem que um veri cador
de tipos con ra se um programa está correto antes que seja executado.
Na maioria das linguagens de programação com tipagem estática (como
Java e Haskell), declarações de tipo obrigatórias deixam muito claro quais
parâmetros uma função (ou método) espera e qual é o tipo que ela
devolverá. Isso reduz um pouco o fardo da documentação para o
programador. Por exemplo, é totalmente desnecessário especi car o que o
método Java a seguir espera como parâmetros ou como tipo de retorno:
/* Consome world, devolvendo a quantidade monetária gerada como resultado.
*/
public float eatWorld(World w, Software s) { … }
Compare isso com a documentação necessária para o método equivalente
escrito em Python tradicional, sem dicas de tipo:
# Consome world
# Parameters:
# w – World a ser consumido
# s – Software com o qual World será consumido
# Returns:
# Quantidade monetária gerada ao consumir world como um número de ponto
flutuante
def eat_world(w, s):
Ao permitir que documentemos automaticamente o nosso código, as
dicas de tipo deixam a documentação Python tão sucinta quanto a
documentação das linguagens estaticamente tipadas:
/* Consome world, devolvendo a quantidade monetária gerada como resultado.
def eat_world(w: World, s: Software) -> float:
Considere um caso extremo. Suponha que você tenha herdado uma base
de código que não tenha nenhum comentário. Seria mais fácil entender
uma base de código sem comentários com ou sem dicas de tipo? As dicas
de tipo evitarão que você precise explorar o código de uma função sem
comentários para entender quais tipos devem ser passados para ela como
parâmetros, e qual é o tipo que se espera que ela devolva.
Lembre-se de que uma dica de tipo é, basicamente, um modo de dizer
qual é o tipo esperado em um ponto de um programa. Contudo, Python
não faz nada para conferir essas expectativas. É aí que um veri cador de
tipos entra em cena. Um veri cador de tipos pode tomar um arquivo com
código-fonte Python escrito com dicas de tipo e conferir se elas de fato
serão obedecidas quando o programa for executado.
Há vários tipos diferentes de veri cadores de tipos para dicas de tipo
em Python. Por exemplo, o popular PyCharm do IDE de Python tem um
veri cador de tipos incluído. Se você editar um programa com dicas de
tipo no PyCharm, ele apontará automaticamente os erros de tipo. Isso
ajudará você a identi car seus erros antes mesmo de terminar de escrever
uma função.
O principal veri cador de tipos Python atualmente, na ocasião em este
livro foi escrito, é o mypy. O projeto mypy é liderado por Guido van
Rossum – a mesma pessoa que criou originalmente o próprio Python.
Isso deixa alguma dúvida em sua mente de que as dicas de tipo podem
ter, potencialmente, um papel muito proeminente no futuro de Python?
Depois de instalar o mypy, utilizá-lo é muito simples e basta executar mypy
example.py , em que example.py é o nome do arquivo no qual você quer
fazer a veri cação de tipos. O mypy exibirá todos os erros de tipo de seu
programa no console, ou não exibirá nada se não houver erros.
É possível que haja outros modos pelos quais as dicas de tipo serão
úteis no futuro. No momento, as dicas de tipo não causam nenhum
impacto na execução de um programa Python. (Para reiterar uma última
vez, elas são ignoradas em tempo de execução.) Contudo, é possível que
futuras versões de Python venham a utilizar as informações de tipo das
dicas para fazer otimizações. Em um mundo como esse, talvez você seja
capaz de agilizar a execução de seu programa Python apenas
acrescentando dicas de tipo. É claro que isso é pura especulação. Não sei
de nenhum plano para implementar otimizações com base em dicas de
tipo em Python.