Uploaded by Guilherme Marques

David-Kopec-Problemas-Clássicos-de-Ciência-da-Computação-com-Python-Novatec-Editora- 2019

advertisement
David Kopec
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
Obrigado a todos da Manning que me ajudaram na produção deste livro:
Cheryl Weisman, Deirdre Hiam, Katie Tennant, Dottie Marsico, Janet
Vail, Barbara Mirecki, Aleksandar Dragosavljević, Mary Piergies e Marija
Tudor.
Agradeço ao editor de aquisições Brian Sawyer que, inteligentemente,
nos levou a atacar Python, depois que eu havia acabado de trabalhar com
o Swift. Obrigado à editora de desenvolvimento Jennifer Stout por ter
sempre mantido uma atitude positiva. Agradeço à editora técnica Frances
Buontempo, que analisou cuidadosamente cada um dos capítulos e deu
um feedback detalhado e útil a cada mudança de página. Agradeço ao
revisor de texto Andy Carroll, cuja esplêndida atenção aos detalhes, tanto
no livro do Swift como neste, identi caram vários de meus erros, e a Juan
Rufes, meu revisor técnico.
As seguintes pessoas também revisaram o livro: Al Krinker, Al Pezewski,
Alan Bogusiewicz, Brian Canada, Craig Henderson, Daniel Kenney-Jung,
Edmond Sesay, Ewa Baranowska, Gary Barnhart, Geo Clark, James
Watson, Je rey Lim, Jens Christian, Bredahl Madsen, Juan Jimenez, Juan
Rufes, Matt Lemke, Mayur Patil, Michael Bright, Roberto Casadei, Sam
Zaydel, Thorsten Weber, Tom Je ries e Will Lopez. Obrigado a todos os
que ofereceram críticas construtivas e especí cas durante o
desenvolvimento do livro. Seus feedbacks foram incorporados.
Agradeço à minha família e aos amigos e colegas que me incentivaram a
assumir o projeto deste livro logo após a publicação de Classic Computer
Science Problems in Swift. Agradeço aos meus amigos virtuais no Twitter e
em outros lugares, os quais me ofereceram palavras de incentivo e
ajudaram a promover o livro de todas as formas possíveis. Além disso,
agradeço à minha esposa Rebecca Kopec e à minha mãe Sylvia Kopec, que
sempre me apoiam em meus projetos.
Desenvolvemos este livro em um período razoavelmente curto. A maior
parte do manuscrito foi redigida durante o verão de 2018, com base na
versão anterior para Swift. Aprecio o fato de a Manning ter se disposto a
condensar o seu processo (em geral muito mais longo) a m de permitir
que eu trabalhasse de acordo com uma agenda que me fosse conveniente.
Sei que isso foi motivo de pressão para a equipe toda durante as três
rodadas de revisões em vários níveis diferentes, com várias pessoas
diferentes, em poucos meses. A maioria dos leitores caria impressionada
com a quantidade de diferentes revisões às quais um livro técnico é
submetido em uma editora tradicional, e com a quantidade de pessoas
que participam das críticas e revisões. Do revisor técnico ao revisor
gramatical ou editorial, todos os revisores o ciais e os que estão entre eles,
muito obrigado!
Por m, e acima de tudo, agradeço aos meus leitores por comprarem
este livro. Em um mundo cheio de tutoriais online monótonos, acho
importante dar apoio ao desenvolvimento de livros que deem voz ao
mesmo autor ao longo de um volume extenso. Tutoriais online podem ser
recursos magní cos; contudo, sua compra possibilita que livros
completos, analisados e cuidadosamente desenvolvidos ainda tenham
espaço no ensino da ciência da computação.
Sobre o autor
David Kopec é professor-assistente de Ciência da Computação &
Inovação no Champlain College em Burlington, Vermont. É um
desenvolvedor de software experiente e autor de Classic Computer Science
Problems in Swift (Manning, 2018) e Dart for Absolute Beginners (Apress,
2014). Tem graduação em economia e mestrado em ciência da
computação, ambos pelo Dartmouth College. É possível encontrar David
no Twitter como @davekopec.
Sobre a ilustração da capa
A imagem na capa de Problemas Clássicos de Ciência da Computação com
Python tem como legenda “Habit of a Bonza or Priest in China” (Hábito
de uma bonza ou sacerdotisa na China). A ilustração foi extraída da obra
A Collection of the Dresses of Di erent Nations, Ancient and Modern de
Thomas Je erys (quatro volumes), publicada em Londres entre 1757 e
1772. A página do título informa que são gravuras em chapas de cobre
com relevos em goma arábica.
Thomas Je erys (1719-1771), conhecido como “Geógrafo do Rei George
III”, era um cartógrafo inglês, líder no fornecimento de mapas em sua
época. Ele gravava e estampava mapas para o governo e outras entidades
o ciais e produzia diversos mapas e atlas comerciais, em especial, da
América do Norte. Seu trabalho como produtor de mapas despertou
interesse acerca dos hábitos associados a vestimentas locais, das terras por
ele pesquisadas e mapeadas, apresentados de forma brilhante em sua
coleção. A fascinação por terras distantes e viagens com vistas à recreação
era um fenômeno relativamente novo no nal do século XVIII, e coleções
como essa eram populares e apresentavam os habitantes de outros países
tanto para os turistas como para aqueles que viajavam com os livros sem
sair de casa.
A diversidade dos desenhos nos volumes da obra de Je erys mostra de
forma vívida a unicidade e a individualidade das nações do mundo há
cerca de duzentos anos. Desde então, os códigos de vestimenta mudaram,
e a diversidade por região e por país, tão rica naquela época, foi
desaparecendo aos poucos. Atualmente, de modo geral, é difícil
diferenciar os habitantes de um continente de outro. Tentando encarar de
forma otimista, talvez tenhamos trocado uma diversidade cultural e visual
por uma vida pessoal mais variada – ou por uma vida intelectual e técnica
mais diversi cada e interessante.
Em uma época em que é difícil diferenciar um livro de informática de
outro, a Manning celebra a inventividade e a iniciativa na área de
computação com capas de livros que se baseiam na rica diversidade dos
hábitos regionais de dois séculos atrás, trazidos de volta à vida pelas
imagens de Je reys.
Introdução
Obrigado pela compra de Problemas Clássicos de Ciência da Computação
com Python. Python é uma das linguagens de programação mais
conhecidas no mundo, e pessoas com experiências anteriores bem
variadas se tornam programadoras de Python. Algumas têm educação
formal em ciência da computação; outras aprendem Python como um
hobby; outras ainda usam Python em um ambiente pro ssional, mas seu
trabalho principal não é como desenvolvedor de software. Os problemas
neste livro de nível intermediário ajudarão programadores experientes a
refrescar a memória com ideias advindas dos cursos de ciência da
computação, ao mesmo tempo que lhes permitirão conhecer alguns
recursos avançados da linguagem. Programadores autodidatas acelerarão
sua educação em ciência da computação ao conhecer problemas clássicos
na linguagem de sua escolha: Python. Este livro inclui uma variedade
muito grande de técnicas de resolução de problemas, a ponto de
realmente haver algo que todos aproveitem.
Este livro não é uma introdução a Python. Há inúmeros livros excelentes
da Manning e de outras editoras nessa linha.1 Este livro, no entanto,
pressupõe que você já seja um programador Python de nível
intermediário ou avançado. Embora o livro exija Python 3.7, um domínio
de todos os aspectos da versão mais recente de Python não é exigido. Com
efeito, o conteúdo do livro foi escrito partindo-se do pressuposto de que
serviria como material de aprendizagem para ajudar os leitores a alcançar
esse domínio. Por outro lado, este livro não é apropriado para leitores a
quem Python seja totalmente uma novidade.
Por que Python?
Python é usado com objetivos muito diversos, por exemplo, na área de
ciência de dados, produção de lmes, educação em ciência da
computação, gerenciamento de TI e muito mais. Não há, realmente,
nenhuma área da computação com a qual Python não tenha entrado em
contato (exceto, talvez, a área de desenvolvimento de kernels). Python é
amado pela exibilidade, pela sintaxe bonita e sucinta, pela pureza na
orientação a objetos e pela comunidade vibrante. Uma comunidade forte
é importante porque implica que Python é acessível aos iniciantes e tem
um ecossistema grande de bibliotecas disponíveis para que os
desenvolvedores usem como base.
Pelos motivos já citados, Python às vezes é considerada uma linguagem
receptiva para quem está começando, e essa caracterização provavelmente
é verdadeira. A maioria das pessoas concordaria que é mais fácil aprender
Python do que C++, por exemplo, e que sua comunidade é mais afável
para com os iniciantes. Como resultado, muitas pessoas aprendem Python
porque é uma linguagem acessível, e começam a escrever os programas
que querem de modo razoavelmente rápido. Contudo, essas pessoas talvez
jamais venham a ter uma educação em ciência da computação por meio
da qual aprendam todas as técnicas e cazes disponíveis de resolução de
problemas. Se você é um desses programadores que conhece Python, mas
não conhece ciência da computação, este livro foi escrito para você.
Outras pessoas aprendem Python como segunda, terceira, quarta ou
quinta linguagem, depois de muito tempo trabalhando com
desenvolvimento de software. Para elas, ver velhos problemas já
conhecidos de outra linguagem as ajudará a acelerar o aprendizado com
Python. Para essas pessoas, este livro pode ser muito bom a m de
refrescar a memória antes de uma entrevista de emprego ou para expô-las
a algumas técnicas de resolução de problemas as quais não haviam
pensado em explorar antes em seus trabalhos. Eu as incentivaria a passar
os olhos pelo índice para ver se há assuntos neste livro que as
empolguem.
O que é um problema clássico de ciência da computação?
Alguns dizem que os computadores estão para a ciência da computação
assim como os telescópios estão para a astronomia. Se for verdade, talvez
uma linguagem de programação seja então como a lente de um telescópio.
Qualquer que seja o caso, a expressão “problemas clássicos de ciência da
computação” neste livro signi ca “problemas de programação tipicamente
ensinados no currículo de um curso de graduação em ciência da
computação”.
Há certos problemas de programação que são apresentados aos novos
programadores para que sejam resolvidos e que se tornaram comuns a
ponto de serem considerados como clássicos – seja em um ambiente de
sala de aula durante uma graduação (em ciência da computação,
engenharia de software e em cursos semelhantes), seja no meio de livros
sobre programação de nível intermediário (por exemplo, um livro inicial
sobre inteligência arti cial ou algoritmos). Um conjunto selecionado
desses problemas é o que você encontrará neste livro.
Os problemas variam dos triviais, que podem ser solucionados com
algumas linhas de código, aos complexos, que exigem a construção de
sistemas ao longo de vários capítulos. Alguns problemas resvalam para o
lado da inteligência arti cial, enquanto outros apenas exigem bom senso.
Alguns problemas são práticos, enquanto outros são lúdicos.
Quais são os tipos de problemas incluídos neste livro?
O Capítulo 1 apresenta técnicas de resolução de problemas com as quais,
provavelmente, a maioria dos leitores terá familiaridade. Técnicas como
recursão, memoização (memoization) e manipulação de bits são blocos de
construção essenciais para outras técnicas exploradas em capítulos
subsequentes.
Essa introdução suave é seguida do Capítulo 2, que tem como foco os
problemas de pesquisa. A pesquisa é um assunto tão amplo que você
poderia, sem dúvida, colocar a maior parte dos problemas deste livro sob
a sua alçada. O Capítulo 2 apresenta os algoritmos básicos de pesquisa,
incluindo busca binária (binary search), busca em profundidade (depthrst search), busca em largura (breadth- rst search) e A*. Esses algoritmos
serão reutilizados no resto do livro.
No Capítulo 3, construiremos um framework para solucionar uma
in nidade de problemas que podem ser de nidos de modo abstrato por
variáveis com domínios limitados, que têm restrições entre si. Esses
problemas incluem clássicos como o problema das oito rainhas, o
problema de colorir o mapa da Austrália e o problema de criptoaritmética
SEND+MORE=MONEY.
O Capítulo 4 explora o mundo dos algoritmos de grafos que, para os
não iniciados, são surpreendentemente amplos quanto a sua
aplicabilidade. Nesse capítulo, você construirá uma estrutura de dados
para grafos e então usará essa estrutura para resolver vários problemas
clássicos de otimização.
O Capítulo 5 explora os algoritmos genéticos: uma técnica menos
determinística que a maioria das técnicas abordadas no livro, mas que,
ocasionalmente, possibilita resolver problemas que algoritmos
tradicionais não resolvem em um intervalo de tempo razoável.
O Capítulo 6 descreve o clustering (agrupamento) k-means, e talvez seja
o capítulo mais especí co do livro no que concerne aos algoritmos. Essa
técnica de clustering é simples de implementar, fácil de entender e
amplamente aplicável.
O Capítulo 7 visa explicar o que é uma rede neural e dar ao leitor uma
amostra da aparência de uma rede neural muito simples. O objetivo não é
fazer uma abordagem completa dessa área empolgante e em evolução.
Nesse capítulo, construiremos uma rede neural usando princípios básicos,
sem bibliotecas externas, para que você de fato veja como uma rede
neural funciona.
O Capítulo 8 trata da busca competitiva (adversarial search) em jogos de
informação perfeita (perfect information games) para dois jogadores. Você
verá um algoritmo de busca conhecido como minimax, que pode ser
usado para desenvolver um adversário arti cial, capaz de participar de
jogos como xadrez, damas e Connect Four2.
Por m, o Capítulo 9 aborda problemas interessantes (e divertidos) que
não se enquadram muito bem em outros capítulos do livro.
A quem este livro se destina?
Este livro foi escrito para programadores de nível intermediário e
programadores experientes. Programadores experientes que queiram
aprofundar seus conhecimentos em Python encontrarão problemas com
os quais terão bastante familiaridade advinda de seus estudos em ciência
da computação ou programação. Programadores de nível intermediário
serão apresentados a esses problemas clássicos na linguagem escolhida
por eles: Python. Desenvolvedores que estejam se preparando para
entrevistas provavelmente considerarão o livro como um material valioso
para preparação.
Além dos programadores pro ssionais, é bem provável que os alunos
matriculados em cursos de graduação em ciência da computação com
interesse em Python achem este livro útil. A obra não faz nenhuma
tentativa de ser uma introdução rigorosa às estruturas de dados e
algoritmos. Este não é um livro didático sobre estruturas de dados e
algoritmos. Você não encontrará provas nem um uso intensivo da notação
big-O nestas páginas. Em vez disso, o livro se apresenta como um tutorial
acessível e prático para as técnicas de resolução de problemas, que
deveriam ser o resultado das aulas de estrutura de dados, algoritmos e
inteligência arti cial.
Mais uma vez, partimos do pressuposto de que a sintaxe e a semântica
de Python são conhecidas. Um leitor sem nenhuma experiência com
programação aproveitará bem pouco este livro, e é quase certo que um
programador sem nenhuma experiência com Python tenha di culdades.
Em outras palavras, Problemas Clássicos de Ciência da Computação com
Python é um livro para programadores Python pro ssionais e para alunos
de ciência da computação.
Versões de Python, repositório de códigos-fontes e dicas de tipo
Os códigos-fontes que estão neste livro foram escritos de modo a serem
compatíveis com a versão 3.7 da linguagem Python. Eles utilizam recursos
de Python que se tornaram disponíveis somente na versão 3.7, portanto,
parte do código não executará em versões mais antigas. Em vez de lutar
com o código e tentar fazer com que os exemplos executem em uma
versão anterior, faça o download da versão mais recente de Python antes
de começar a ler o livro.
Este livro utiliza apenas a biblioteca-padrão de Python (com uma
pequena exceção no Capítulo 2, no qual o módulo typing_extensions é
instalado); desse modo, todos os códigos que estão no livro devem
executar em qualquer plataforma que aceite Python (macOS, Windows,
GNU/Linux e assim por diante). O código do livro foi testado somente
com CPython (o principal interpretador Python disponibilizado por
python.org), embora seja provável que a maior parte dele execute em
outro interpretador Python com versão compatível com Python 3.7.
Este livro não explica como usar ferramentas Python como editores,
IDEs, depuradores e o REPL de Python. Os códigos-fontes do livro estão
disponíveis
online
no
repositório
do
GitHub:
https://github.com/davecom/ClassicComputerScienceProblemsInPython. Os
códigos estão organizados em pastas por capítulo. À medida que ler cada
capítulo, você verá o nome de um arquivo-fonte no cabeçalho de cada
listagem de código. Esse arquivo-fonte poderá ser encontrado em sua
respectiva pasta no repositório. Você deverá ser capaz de executar o código
para o problema digitando python3 nomedoarquivo.py ou python
nomedoarquivo.py, conforme a con guração de seu computador no que diz
respeito ao nome do interpretador de Python 3.
Todas as listagens de código neste livro fazem uso das dicas de tipo
(type hints) de Python, também conhecidas como anotações de tipo (type
annotations). Essas anotações são um recurso relativamente novo na
linguagem Python e podem parecer intimidadoras para um programador
Python que não as tenha visto antes. Elas são usadas por três motivos:
1. Proporcionam clareza quanto aos tipos das variáveis, aos parâmetros
de função e aos valores de retorno das funções.
2. De certo modo, promovem uma documentação para o código, como
consequência do motivo listado em 1. Em vez de ter de procurar um
comentário ou uma docstring para descobrir o tipo devolvido por
uma função, você pode apenas olhar para a sua assinatura.
3. Permitem que seja feita uma veri cação de tipos no código para ver se
está correto. Uma ferramenta de veri cação de tipos conhecida para
Python é o mypy.
Nem todos são fãs das dicas de tipo e, honestamente, optar por usá-las em
todo o livro foi uma aposta. Espero que elas ajudem, em vez de di cultar.
É um pouco mais demorado para escrever um código Python com dicas
de tipo, mas elas proporcionam mais clareza quando retornamos depois
para ler esse código. Uma observação interessante é que as dicas de tipo
não têm nenhum efeito na execução propriamente dita do código no
interpretador Python. Você pode remover as dicas de tipo de qualquer
código deste livro, e esse código deverá continuar funcionando. Se você
ainda não conhecia as dicas de tipo e acha que precisa de uma introdução
mais completa antes de mergulhar de cabeça no livro, consulte o Apêndice
C, que contém um curso rápido sobre elas.
Sem saída grá ca ou código de UI: apenas a biblioteca-padrão
Não há nenhum exemplo neste livro que gere uma saída grá ca ou que
faça uso de uma GUI (Graphical User Interface, ou Interface Grá ca de
Usuário). Por quê? O objetivo é resolver os problemas propostos com
soluções que sejam tão concisas e legíveis quanto possível. Muitas vezes,
gerar saídas grá cas atrapalha ou deixa as soluções signi cativamente
mais complexas do que o necessário para demonstrar a técnica ou o
algoritmo em questão.
Além do mais, por não fazer uso de nenhum framework para GUI, todo
o código do livro é extremamente portável. Ele pode ser facilmente
executado em uma distribuição dedicada de Python executando no
Linux, assim como em um desktop com Windows. Além disso, uma
decisão consciente foi feita quanto a usar pacotes somente da bibliotecapadrão de Python, em vez de usar alguma biblioteca externa, como faz a
maioria dos livros sobre Python. Por quê? O objetivo é ensinar as técnicas
de resolução de problemas começando do básico – não é efetuar um “pip
install de uma solução”. Ao ter de trabalhar com todos os problemas a
partir do zero, esperamos que você compreenda como as bibliotecas
conhecidas funcionam internamente. No mínimo, usar apenas a
biblioteca-padrão deixa o código deste livro mais portável e mais fácil de
ser executado.
Isso não equivale a dizer que soluções com saídas grá cas não sejam
ocasionalmente mais ilustrativas de um algoritmo do que soluções
baseadas em texto. Esse apenas não é o foco deste livro, pois outra camada
de complexidade desnecessária seria acrescentada.
Parte de uma série
Este é o segundo livro de uma série intitulada Problemas Clássicos de
Ciência da Computação (Classic Computer Science Problems) publicada
pela Manning. O primeiro livro foi Classic Computer Science Problems in
Swift, publicado em 2018. Em cada livro da série, nosso objetivo é
apresentar insights especí cos sobre uma linguagem, ao mesmo tempo
em que ensinamos através das lentes dos mesmos problemas (na maioria
das vezes) de ciência da computação.
Se você gostar deste livro e planeja conhecer outra linguagem abordada
na série, talvez ache que passar de um livro para outro seja um modo fácil
de aperfeiçoar o seu domínio sobre essa linguagem. Por enquanto, a série
inclui apenas Swift e Python. Escrevi pessoalmente os dois primeiros
livros porque tenho experiência signi cativa com essas duas linguagens,
mas já estamos discutindo planos para futuros livros da série em
coautoria com pessoas que são especialistas em outras linguagens.
Incentivo você a procurá-los, caso goste deste livro. Para obter outras
informações sobre a série, acesse https://classicproblems.com/.
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
Para começar, exploraremos alguns problemas simples que, para serem
resolvidos, não precisam de nada além de algumas funções relativamente
pequenas. Apesar de serem pequenos, esses problemas nos permitirão
explorar algumas técnicas interessantes de resolução de problemas. Pense
neles como um bom aquecimento.
1.1 Sequência de Fibonacci
A sequência de Fibonacci é uma sequência de números tal que qualquer
número, exceto o primeiro e o segundo, é a soma dos dois números
anteriores:
0, 1, 1, 2, 3, 5, 8, 13, 21...
O valor do primeiro número de Fibonacci na sequência é 0. O valor do
quarto número de Fibonacci é 2. Segue-se daí que, para obter o valor de
qualquer número de Fibonacci n na sequência, a seguinte fórmula pode
ser usada:
fib(n) = fib(n - 1) + fib(n - 2)
1.1.1 Uma primeira tentativa com recursão
A fórmula anterior para calcular um número da sequência de Fibonacci
(mostrado na Figura 1.1) é uma espécie de pseudocódigo que pode ser
facilmente traduzida em uma função Python recursiva. (Uma função
recursiva é uma função que chama a si mesma.) Essa tradução mecânica
servirá como nossa primeira tentativa de escrever uma função que
devolva um dado valor da sequência de Fibonacci.
Figura 1.1 – A altura de cada homem-palito é a soma das alturas dos dois
homens-palito anteriores.
Listagem 1.1 – b1.py
def fib1(n: int) -> int:
return fib1(n - 1) + fib1(n - 2)
Vamos tentar executar essa função chamando-a com um valor.
Listagem 1.2 – Continuação de b1.py
if __name__ == "__main__":
print(fib1(5))
Ah, não! Se tentarmos executar b1.py, um erro será gerado:
RecursionError: maximum recursion depth exceeded
O problema é o fato de fib1() executar inde nidamente, sem devolver um
resultado de nitivo. Toda chamada a fib1() resultará em outras duas
chamadas para fib1(), sem que haja um nal à vista. Chamamos uma
situação como essa de recursão in nita (veja a Figura 1.2), e ela é análoga a
um loop in nito.
Figura 1.2 – A função recursiva fib(n) chama a si mesma com os argumentos
n-2 e n-1.
1.1.2 Utilizando casos de base
Observe que até executar fib1() não havia nenhuma informação de seu
ambiente Python dizendo que houvesse algo de errado com ela. Evitar
uma recursão in nita é responsabilidade do programador, e não do
compilador ou do interpretador. A recursão in nita se dá porque não
especi camos um caso de base. Em uma função recursiva, um caso de
base serve como ponto de parada.
No exemplo da função de Fibonacci, temos casos de base naturais na
forma dos dois primeiros valores especiais da sequência, 0 e 1. Nem 0 nem
1 são a soma dos dois números anteriores da sequência. Esses são os dois
primeiros valores especiais. Vamos tentar especi cá-los como casos de
base.
Listagem 1.3 – b2.py
def fib2(n: int) -> int:
if n < 2: # caso de base
return n
return fib2(n - 2) + fib2(n - 1) # caso recursivo
NOTA A versão fib2() da função de Fibonacci devolve 0 como o número da posição zero
(fib2(0)), em vez de ser o primeiro número, como em nossa proposição original. Em um
contexto de programação, até faz sentido, pois estamos acostumados com sequências que
começam com o elemento na posição zero.
fib2() pode ser chamada com sucesso e devolverá resultados corretos.
Experimente chamá-la com alguns valores baixos.
Listagem 1.4 – Continuação de b2.py
if __name__ == "__main__":
print(fib2(5))
print(fib2(10))
Não tente chamar fib2(50). A execução jamais terminará! Por quê? Toda
chamada a fib2() resulta em outras duas chamadas de fib2() por causa das
chamadas recursivas fib2(n - 1) e fib2(n - 2) (veja a Figura 1.3).
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
Se você contabilizar as chamadas (e, como veremos, se adicionar algumas
instruções para exibição), verá que há 9 chamadas para fib2() somente
para calcular o elemento de número 4! A situação piora. São 15 chamadas
necessárias para calcular o elemento de número 5, 177 chamadas para
calcular o elemento de número 10 e 21.891 chamadas para calcular o
elemento de número 20. Podemos fazer algo melhor que isso.
1.1.3 Memoização para nos salvar
A memoização (memoization) é a técnica segundo a qual armazenamos os
resultados de tarefas computacionais quando estas são concluídas, de
modo que, quando precisarmos novamente desses resultados, será
possível consultá-los, em vez de ter de calculá-los uma segunda vez (ou
pela milionésima vez) – veja a Figura 1.4.1
Figura 1.4 – A máquina de memoização humana.
Vamos criar outra versão da função de Fibonacci que utiliza um
dicionário Python para memoização.
Listagem 1.5 – b3.py
from typing import Dict
memo: Dict[int, int] = {0: 0, 1: 1} # nossos casos de base
def fib3(n: int) -> int:
if n not in memo:
memo[n] = fib3(n - 1) + fib3(n - 2) # memoização
return memo[n]
Agora você pode chamar fib3(50) de forma segura.
Listagem 1.6 – Continuação de b3.py
if __name__ == "__main__":
print(fib3(5))
print(fib3(50))
Uma chamada a fib3(20) resultará em apenas 39 chamadas para fib3(), em
oposição às 21.891 chamadas a fib2() resultantes da chamada a fib2(20).
memo é preenchida previamente com os primeiros casos de base, isto é, para
0 e 1, evitando a complexidade de outra instrução if em fib3().
1.1.4 Memoização automática
pode ser simpli cada mais ainda. Python tem um decorador
embutido para memoizar qualquer função “automagicamente”. Em fib4(),
o decorador @functools.lru_cache() é usado exatamente com o mesmo
código de fib2(). Sempre que fib4() for executada com um novo
argumento, o decorador fará com que o valor de retorno seja armazenado
em cache. Em futuras chamadas de fib4() com o mesmo argumento, o
valor de retorno anterior de fib4() para esse argumento será recuperado
do cache e devolvido.
fib3()
Listagem 1.7 – b4.py
from functools import lru_cache
@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))
Observe que podemos calcular fib4(50) instantaneamente, mesmo que o
corpo da função de Fibonacci seja igual ao corpo de fib2(). A propriedade
maxsize de @lru_cache indica quantas das chamadas mais recentes da função
que ela está decorando devem ser armazenadas em cache. De ni-la com
None signi ca que não há um limite.
1.1.5 Fibonacci simples
Há uma opção com um desempenho melhor ainda. Podemos solucionar a
sequência de Fibonacci usando uma abordagem iterativa tradicional.
Listagem 1.8 – b5.py
def fib5(n: int) -> int:
if n == 0: return n # caso especial
last: int = 0 # inicialmente definido para fib(0)
next: int = 1 # inicialmente definido para fib(1)
for _ in range(1, n):
last, next = next, last + next
return next
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.
1.1.6 Gerando números de Fibonacci com um gerador
Até agora, escrevemos funções que geram um único valor da sequência de
Fibonacci. E se, em vez disso, quiséssemos gerar a sequência completa, até
certo valor? É fácil converter fib5() em um gerador Python usando a
instrução yield. Nas iterações do gerador, cada iteração gerará um valor da
sequência de Fibonacci usando uma instrução yield.
Listagem 1.9 – b6.py
from typing import Generator
def fib6(n: int) -> Generator[int, None, None]:
yield 0 # caso especial
if n > 0: yield 1 # caso especial
last: int = 0 # inicialmente definido para fib(0)
next: int = 1 # inicialmente definido para fib(1)
for _ in range(1, n):
last, next = next, last + next
yield next # passo principal da geração
if __name__ == "__main__":
for i in fib6(50):
print(i)
Se você executar b6.py, verá 51 números da sequência de Fibonacci
exibidos. Para cada iteração do laço for i in fib6(50):, fib6() executará até
uma instrução yield. Se o nal da função for alcançado e não houver mais
instruções yield, o laço terminará a iteração.
1.2 Compactação trivial
Economizar espaço (virtual ou real) muitas vezes é importante. Usar
menos espaço é mais e ciente, e é possível economizar dinheiro. Se você
estivesse alugando um apartamento que fosse maior do que o necessário
para suas coisas e a sua família, seria possível fazer um “downsize” para
um lugar menor, mais barato. Se você paga por byte para armazenar seus
dados em um servidor, talvez queira compactá-los para que a
armazenagem tenha um custo menor. A compactação é o ato de tomar os
dados e codi cá-los (modi car o seu formato) de modo que ocupem
menos espaço. A descompactação é o processo inverso, que faz com que os
dados retornem ao seu formato original.
Se compactar os dados é mais e caz para a armazenagem, por que não
se compactam todos os dados? Há uma relação de custo-benefício entre
tempo e espaço. Compactar uma porção de dados e descompactá-los de
volta para o formato original exige tempo. Desse modo, a compactação de
dados somente fará sentido em situações em que um tamanho menor
tenha mais prioridade em relação a uma execução rápida. Pense em
arquivos grandes sendo transmitidos pela internet. Compactá-los faz
sentido, pois demorará mais para transferir os arquivos do que para
descompactá-los depois que forem recebidos. Além do mais, o tempo
gasto para compactar os arquivos e armazená-los no servidor original terá
de ser considerado apenas uma vez.
Os ganhos mais simples com a compactação de dados surgem quando
você percebe que os tipos de dados armazenados usam mais bits do que
são estritamente necessários para o seu conteúdo. Por exemplo, pensando
no baixo nível, se um inteiro sem sinal que jamais excederia 65.535 é
armazenado como um inteiro de 64 bits sem sinal na memória, ele está
sendo armazenado de modo ine ciente. Esse dado poderia ser
armazenado como um inteiro de 16 bits sem sinal. Isso reduziria o
consumo de espaço do número propriamente dito em 75% (16 bits, em
vez de 64 bits). Se milhões desses números forem armazenados de modo
ine ciente, o espaço desperdiçado poderá totalizar megabytes.
Em Python, às vezes, por questões de simplicidade (o que, sem dúvida, é
um objetivo legítimo), o desenvolvedor é protegido para não pensar em
bits. Não há nenhum tipo inteiro de 64 bits sem sinal, e não há nenhum
tipo inteiro de 16 bits sem sinal. Há apenas um único tipo int que pode
armazenar números com precisão arbitrária. A função sys.getsizeof() pode
ajudar você a descobrir quantos bytes de memória seus objetos Python
estão consumindo. Contudo, em razão do overhead inerente do sistema
de objetos de Python, não há nenhuma maneira de criar um int que
ocupe menos de 28 bytes (224 bits) em Python 3.7. Um único int pode ser
estendido, um bit de cada vez (como faremos no próximo exemplo),
porém consome um mínimo de 28 bytes.
NOTA Se você estiver um pouco enferrujado no que diz respeito aos binários, lembre-se de que
um bit corresponde a um valor único que pode ser 1 ou 0. Uma sequência de 1s e 0s é lida em
base 2 para representar um número. Nesta seção, não será necessário fazer nenhuma operação
matemática em base 2, mas você deve compreender que o número de bits que um tipo
armazena determina quantos valores diferentes podem ser representados. Por exemplo, 1 bit é
capaz de representar 2 valores (0 ou 1), 2 bits podem representar 4 valores (00, 01, 10, 11), 3 bits
podem representar 8 valores e assim por diante.
Se o número de possíveis valores diferentes que um tipo deve representar
for menor que o número de valores que os bits usados para armazená-lo
podem representar, é provável que ele seja armazenado de modo mais
e ciente. Considere os nucleotídeos que formam um gene no DNA.2 Cada
nucleotídeo pode assumir apenas um entre quatro valores: A, C, G ou T.
(Veremos mais sobre isso no Capítulo 2.) No entanto, se o gene for
armazenado como uma str, que pode ser imaginada como uma coleção
de caracteres Unicode, cada nucleotídeo será representado por um
caractere, o qual, em geral, exige 8 bits para armazenagem. Em binário,
apenas 2 bits são necessários para armazenar um tipo com quatro valores
possíveis: 00, 01, 10 e 11 são os quatro valores diferentes que podem ser
representados por 2 bits. Se atribuirmos o valor 00 a A, 01 a C, 10 a G e 11
a T, a área de armazenagem necessária para uma string de nucleotídeos
poderá ser reduzida em 75% (de 8 bits para 2 bits por nucleotídeo).
Em vez de armazenar nossos nucleotídeos como uma str, eles poderão
ser armazenados como uma cadeia de bits (veja a Figura 1.5). Uma cadeia
de bits é exatamente o que parece ser: uma sequência de 1s e 0s de
tamanho arbitrário. Infelizmente, a biblioteca-padrão de Python não
contém nenhuma construção pronta para trabalhar com cadeias de bits de
tamanho arbitrário. O código a seguir converte uma str composta de As,
Cs, Gs e Ts em uma cadeia de bits, e vice-versa. A cadeia de bits é
armazenada em um int. Como o tipo int de Python pode ter qualquer
tamanho, ele pode ser usado como uma cadeia de bits de qualquer
tamanho. Para fazer a conversão de volta para str, implementaremos o
método especial __str__() de Python.
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)
recebe 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
CompressedGene
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.
Listagem 1.12 – Continuação de trivial_compression.py
def decompress(self) -> str:
gene: str = ""
for i in range(0, self.bit_string.bit_length() - 1, 2): # - 1 para excluir
# a sentinela
bits: int = self.bit_string >> i & 0b11 # obtém apenas 2 bits relevantes
if bits == 0b00: # A
gene += "A"
elif bits == 0b01: # C
gene += "C"
elif bits == 0b10: # G
gene += "G"
elif bits == 0b11: # T
gene += "T"
else:
raise ValueError("Invalid bits:{}".format(bits))
return gene[::-1] # [::-1] inverte a string usando fatiamento com inversão
def __str__(self) -> str: # representação em string para exibição elegante
return self.decompress()
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.
Listagem 1.13 – Continuação de trivial_compression.py
if __name__ == "__main__":
from sys import getsizeof
original: str =
"TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCATGGAT
CGATTATA" * 100
print("original is {} bytes".format(getsizeof(original)))
compressed: CompressedGene = CompressedGene(original) # compacta
print("compressed is {} bytes".format(getsizeof(compressed.bit_string)))
print(compressed) # descompacta
print("original and decompressed are the same: {}".format(original ==
compressed.decompress()))
Ao usar o método sys.getsizeof(), podemos mostrar na saída se realmente
zemos uma economia de quase 75% no custo de memória para
armazenar o gene utilizando esse esquema de compactação.
Listagem 1.14 – Saída de trivial_compression.py
original is 8649 bytes
compressed is 2320 bytes
TAGGGATTAACC…
original and decompressed are the same: True
NOTA Na classe CompressedGene, usamos instruções if intensamente a m de decidir entre
uma série de casos nos métodos tanto de compactação como de descompactação. Como
Python não tem uma instrução switch, de certo modo, isso é comum. O que você verá
ocasionalmente em Python é um uso intenso de dicionários no lugar de utilizar várias
instruções if para lidar com um conjunto de casos. Suponha, por exemplo, que tivéssemos
um dicionário no qual pudéssemos consultar os respectivos bits de cada nucleotídeo. Às vezes,
isso poderá ser mais legível, mas talvez venha acompanhado de um custo para o desempenho.
Mesmo que, tecnicamente, uma consulta em um dicionário tenha complexidade O(1), o custo
de executar uma função de hash signi ca que, às vezes, um dicionário terá um pior
desempenho do que uma série de ifs. Isso dependerá do que as instruções if de um
programa em particular tiverem de avaliar para tomar suas decisões. Você pode executar testes
de desempenho nos dois métodos caso tenha de tomar uma decisão entre ifs e consultas em
dicionário em uma seção crítica do código.
1.3 Criptogra a inquebrável
Um one-time pad (cifra de uso único) é uma forma de criptografar uma
porção de dados combinando-os com dados dummy aleatórios e não
signi cativos, de modo que os dados originais não sejam reconstituídos
sem acessar tanto o produto como os dados dummy. Basicamente, isso
deixa a criptogra a com um par de chaves. Uma chave é o produto e a
outra são os dados dummy aleatórios. Uma chave por si só é inútil;
somente a combinação das duas chaves permite descriptografar os dados
originais. Se for executado corretamente, um one-time pad é uma forma
de criptogra a inquebrável. A Figura 1.6 mostra o processo.
Figura 1.6 – Um one-time pad resulta em duas chaves que podem ser
separadas e então recombinadas para recriar os dados originais.
1.3.1 Deixando os dados em ordem
Neste exemplo, criptografaremos uma str usando um one-time pad. Uma
forma de pensar em uma str de Python 3 é como uma sequência de bytes
UTF-8 (em que o UTF-8 é uma codi cação de caracteres Unicode). Uma
str pode ser convertida em uma sequência de bytes UTF-8 (representada
com o tipo bytes) por meio do método encode(). De modo similar, uma
sequência de bytes UTF-8 pode ser convertida de volta em uma str
usando o método decode() no tipo bytes.
Há três critérios aos quais os dados dummy usados em uma operação
de criptogra a one-time pad devem obedecer para que o produto
resultante seja inquebrável. Os dados dummy devem ter o mesmo
tamanho dos dados originais, devem ser de fato aleatórios e totalmente
secretos. O primeiro e o terceiro critérios dizem respeito ao bom senso. Se
os dados dummy se repetirem por serem muito pequenos, poderá haver
um padrão observável. Se uma das chaves não for realmente secreta
(talvez seja reutilizada em outros lugares ou é parcialmente revelada), o
invasor terá uma pista. O segundo critério leva a uma questão particular:
podemos gerar dados realmente aleatórios? A resposta para a maior parte
dos computadores é não.
Neste exemplo, usaremos a função geradora de dados pseudoaleatórios
token_ bytes(), do módulo secrets (incluída pela primeira vez na bibliotecapadrão de Python 3.6). Nossos dados não serão verdadeiramente
aleatórios, pois o pacote secrets utiliza um gerador de números
pseudoaleatórios internamente; para nós, porém, será su ciente. Vamos
gerar uma chave aleatória para ser usada como dados dummy.
Listagem 1.15 – unbreakable_encryption.py
from secrets import token_bytes
from typing import Tuple
def random_key(length: int) -> int:
# gera length bytes aleatórios
tb: bytes = token_bytes(length)
# converte esses bytes em uma cadeia de bits e a devolve
return int.from_bytes(tb, "big")
Essa função cria um int preenchido com length bytes aleatórios. O método
int.from_bytes() é usado para converter de bytes para int. De que modo
vários bytes podem ser convertidos em um único inteiro? A resposta se
encontra na Seção 1.2. Naquela seção, vimos que o tipo int pode ter um
tamanho arbitrário e pode ser usado como uma cadeia de bits genérica.
int é usado do mesmo modo neste caso. Por exemplo, o método
from_bytes() receberá 7 bytes (7 bytes * 8 bits = 56 bits) e os converterá em
um inteiro de 56 bits. Por que isso é conveniente? Operações bit-a-bit
podem ser executadas com mais facilidade e com um melhor desempenho
em um único int (leia-se “uma cadeia longa de bits”), em comparação
com vários bytes individuais em sequência. Além disso, estamos prestes a
usar a operação bit a bit XOR.
1.3.2 Criptografando e descriptografando
Como os dados dummy serão combinados com os dados originais que
queremos criptografar? A operação XOR servirá para isso. XOR é uma
operação lógica bit a bit (atua no nível dos bits) que devolve true se um de
seus operandos for verdadeiro, mas false se ambos ou nenhum deles for
verdadeiro. Como você deve ter adivinhado, XOR quer dizer exclusive or
(ou exclusivo).
Em Python, o operador XOR é ^. No contexto dos bits de números
binários, XOR devolve 1 para 0 ^ 1 e 1 ^ 0, mas 0 para 0 ^ 0 e 1 ^ 1. Se os
bits de dois números forem combinados com XOR, uma propriedade
conveniente é que o produto pode ser recombinado com um dos
operandos para gerar o outro operando:
A ^ B = C
C ^ B = A
C ^ A = B
Esse insight essencial é a base da criptogra a one-time pad. Para calcular
o nosso produto, simplesmente faremos um XOR de um int que
representa os bytes de nossa str original com um int gerado de forma
aleatória com o mesmo tamanho em bits (conforme gerado por
random_key()). Nosso par de chaves devolvido será formado pelos dados
dummy e pelo produto.
Listagem 1.16 – Continuação de unbreakable_encryption.py
def encrypt(original: str) -> Tuple[int, int]:
original_bytes: bytes = original.encode()
dummy: int = random_key(len(original_bytes))
original_key: int = int.from_bytes(original_bytes, "big")
encrypted: int = original_key ^ dummy # XOR
return dummy, encrypted
NOTA int.from_bytes() recebe dois argumentos. O primeiro são os bytes que queremos
converter para um int. O segundo é o endianness desses bytes ("big"). O endianness se refere
à ordem dos bytes, usada para armazenar os dados. O byte mais signi cativo vem antes, ou é o
byte menos signi cativo que vem antes? Em nosso caso, não importa, desde que a mesma
ordem seja usada tanto para criptografar como para descriptografar, pois estamos
manipulando os dados apenas no nível dos bits individuais. Em outras situações, se você não
tiver o controle das duas extremidades do processo de codi cação, a ordem poderá ser muito
importante, portanto, tome cuidado!
A descriptogra a é simplesmente uma questão de recombinar o par de
chaves que geramos com encrypt(). Isso é feito, mais uma vez, efetuando
uma operação de XOR entre todo e qualquer bit das duas chaves. A saída
de nitiva deve ser convertida de volta em uma str. Inicialmente, o int é
convertido em bytes usando int.to_bytes(). Esse método exige o número de
bytes a ser convertido do int. Para obter esse número, dividimos o
tamanho em bits por oito (o número de bits em um byte). Por m, o
método decode() de bytes nos devolve a str.
Listagem 1.17 – Continuação de unbreakable_encryption.py
def decrypt(key1: int, key2: int) -> str:
decrypted: int = key1 ^ key2 # XOR
temp: bytes = decrypted.to_bytes((decrypted.bit_length()+ 7) // 8, "big")
return temp.decode()
Foi necessário somar 7 ao tamanho dos dados descriptografados antes de
usar a divisão por inteiro (//) para dividir por 8 a m de garantir que
“arredondaríamos para cima”, evitando um erro de o -by-one (erro por
um). Se nossa criptogra a one-time pad realmente funcionar, seremos
capazes de criptografar e descriptografar a mesma string Unicode sem
problemas.
Listagem 1.18 – Continuação de unbreakable_encryption.py
if __name__ == "__main__":
key1, key2 = encrypt("One Time Pad!")
result: str = decrypt(key1, key2)
print(result)
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...
Você perceberá que o numerador da série in nita permanece igual a 4,
enquanto o denominador aumenta de 2, e a operação nos termos se
alterna entre uma adição e uma subtração.
Podemos modelar a série de forma direta, traduzindo partes da fórmula
em variáveis de uma função. O numerador pode ser uma constante 4. O
denominador pode ser uma variável que começa em 1 e é incrementada
de 2. A operação pode ser representada como -1 ou 1 de acordo com o
fato de estarmos somando ou subtraindo. Por m, a variável pi é usada na
Listagem 1.19 para armazenar a soma da série à medida que o laço for é
executado.
Listagem 1.19 – calculating_pi.py
def calculate_pi(n_terms: int) -> float:
numerator: float = 4.0
denominator: float = 1.0
operation: float = 1.0
pi: float = 0.0
for _ in range(n_terms):
pi += operation * (numerator / denominator)
denominator += 2.0
operation *= -1.0
return pi
if __name__ == "__main__":
print(calculate_pi(1000000))
DICA Na maioria das plataformas, os floats 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.
1.5 Torres de Hanói
Três pinos verticais (daí o nome “torres”) encontram-se de pé. Nós os
chamaremos de A, B e C. Discos em formato de rosquinhas estão na torre
A. O disco maior está embaixo, e nós o chamaremos de disco 1. Os
demais discos acima do disco 1 recebem números crescentes e são cada
vez menores. Por exemplo, se trabalhássemos com três discos, o disco
maior, que está embaixo, seria o disco 1. O próximo disco maior, o disco
2, estaria sobre o disco 1. Por m, o disco menor, o disco 3, estaria sobre o
disco 2. Nosso objetivo é mover todos os discos da torre A para a torre C,
dadas as seguintes restrições:
• Somente um disco pode ser movido por vez.
• O disco mais acima em qualquer torre é o único disponível para ser
movido.
• Um disco maior não pode estar em cima de um disco menor.
A Figura 1.7 sintetiza o problema.
Figura 1.7 – O desa o consiste em mover os três discos, um de cada vez, da
torre A para a torre C. Um disco maior não pode estar em cima de um disco
menor.
1.5.1 Modelando as torres
Uma pilha é uma estrutura de dados modelada com base no conceito de
LIFO (Last-In-First-Out, ou o último que entra é o primeiro que sai). O
último item inserido na pilha será o primeiro a ser removido. As duas
operações básicas em uma pilha são push (inserir) e pop (remover). Um
push insere um novo item em uma pilha, enquanto um pop remove e
devolve o último item inserido. Podemos modelar facilmente uma pilha
em Python usando uma list como base para a armazenagem.
Listagem 1.20 – hanoi.py
from typing import TypeVar, Generic, List
T = TypeVar('T')
class Stack(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []
def push(self, item: T) -> None:
self._container.append(item)
def pop(self) -> T:
return self._container.pop()
def __repr__(self) -> str:
return repr(self._container)
NOTA Essa classe Stack implementa __repr__() para que possamos explorar facilmente o
conteúdo de uma torre. __repr__() é o que será exibido quando print() for aplicado em
uma Stack.
NOTA Conforme descrito na introdução, utilizamos as dicas de tipos (type hints) em todo o
livro. A importação de Generic do módulo typing permite que Stack seja genérica sobre um
tipo particular das dicas de tipos. O tipo arbitrário T está de nido em T = TypeVar('T'). T
pode ser qualquer tipo. Quando uma dica de tipo for usada mais tarde com uma Stack para
resolver o problema das Torres de Hanói, o tipo informado será Stack[int], o que signi ca
que T será preenchido com o tipo int. Em outras palavras, a pilha será uma pilha de inteiros.
Se você está tendo di culdade com as dicas de tipo, consulte o Apêndice C.
As pilhas são representantes perfeitas para as torres nas Torres de Hanói.
Se quisermos colocar um disco em uma torre, podemos apenas fazer um
push. Se quisermos mover um disco de uma torre para outra, podemos
fazer um pop da primeira torre e um push na segunda.
Vamos de nir nossas torres como Stacks e preencher a primeira torre
com os discos.
Listagem 1.21 – Continuação de hanoi.py
num_discs: int = 3
tower_a: Stack[int] = Stack()
tower_b: Stack[int] = Stack()
tower_c: Stack[int] = Stack()
for i in range(1, num_discs + 1):
tower_a.push(i)
1.5.2 Solucionando as Torres de Hanói
Como as Torres de Hanói podem ser resolvidas? Suponha que estamos
tentando mover apenas um disco. Saberíamos como fazer isso, certo? De
fato, mover um disco é o nosso caso de base para uma solução recursiva
das Torres de Hanói. O caso recursivo é mover mais de um disco. Assim, o
principal insight é o fato de termos, basicamente, dois cenários para os
quais devemos escrever um código: mover um disco (o caso de base) e
mover mais de um disco (o caso recursivo).
Vamos analisar um exemplo especí co para compreender o caso
recursivo. Suponha que temos três discos (um em cima, um no meio e um
embaixo) na torre A, e queremos movê-los para a torre C. (Fazer um
diagrama do problema à medida que o descrevemos talvez ajude.)
Poderíamos mover inicialmente o disco de cima para a torre C. Então
moveríamos o disco do meio para a torre B. Em seguida, poderíamos
mover o disco de cima da torre C para a torre B. Agora temos o disco de
baixo ainda na torre A e os dois discos de cima na torre B. Basicamente,
movemos dois discos de uma torre (A) para outra (B) com sucesso. Mover
o disco de baixo de A para C é o nosso caso de base (mover um único
disco). Agora podemos mover os dois discos de cima, de B para C, usando
o mesmo procedimento utilizado de A para B. Movemos o disco de cima
para A, o disco do meio para C e, por m, o disco de cima em A para C.
DICA Em uma aula de ciência da computação, não é incomum ver um pequeno modelo das
torres feita de pinos e rosquinhas de plástico. Você pode construir o próprio modelo usando
três lápis e três pedaços de papel. Isso pode ajudar a visualizar a solução.
Em nosso exemplo com três discos, tínhamos um caso de base simples
que consistia em mover um único disco, e um caso recursivo, que
consistia em mover todos os outros discos (dois, no caso), usando a
terceira torre como temporária. Podemos separar o caso recursivo em três
passos:
1. Mover os n-1 discos de cima da torre A para a torre B (a torre
temporária), usando C como intermediária.
2. Mover o único disco que está mais embaixo, de A para C.
3. Mover os n-1 discos da torre B para a torre C usando A como
intermediária.
O incrível é que esse algoritmo recursivo funciona não só para três discos,
mas para qualquer quantidade deles. Escreveremos o código de uma
função chamada hanoi() que será responsável por mover discos de uma
torre para outra, dada uma terceira torre temporária.
Listagem 1.22 – Continuação de hanoi.py
def hanoi(begin: Stack[int], end: Stack[int], temp: Stack[int], n: int) -> None:
if n == 1:
end.push(begin.pop())
else:
hanoi(begin, temp, end, n - 1)
hanoi(begin, end, temp, 1)
hanoi(temp, end, begin, n - 1)
Depois de chamar hanoi(), você deve analisar as torres A, B e C para
veri car se os discos foram movidos com sucesso.
Listagem 1.23 – Continuação de hanoi.py
if __name__ == "__main__":
hanoi(tower_a, tower_c, tower_b, num_discs)
print(tower_a)
print(tower_b)
print(tower_c)
Você perceberá que os discos foram movidos com sucesso. Ao escrever o
código para a solução das Torres de Hanói, não tivemos necessariamente
de entender todos os passos exigidos para mover vários discos da torre A
para a torre C. No entanto, conseguimos entender o algoritmo recursivo
genérico para mover qualquer quantidade de discos e escrevemos o
código, deixando que o computador zesse o resto. Eis a e cácia de
formular soluções recursivas para os problemas: muitas vezes, é possível
pensar nas soluções de modo abstrato, sem o trabalho complicado de ter
de entender todas as ações individuais em nossa mente.
A propósito, a função hanoi() executará um número exponencial de
vezes, como função do número de discos, o que faz com que solucionar o
problema para 64 discos seja inimaginável. Você pode testar a função com
várias outras quantidades de discos modi cando a variável num_ discs. O
número exponencialmente crescente de passos necessários à medida que a
quantidade de discos aumenta está na origem da lenda das Torres de
Hanói; você pode ler mais sobre o assunto em inúmeras fontes. Talvez
esteja interessado também em ler mais sobre a matemática por trás de sua
solução recursiva; veja a explicação de Carl Burch em “About the Towers
of Hanoi” (Sobre as Torres de Hanói) em http://mng.bz/c1i2.
1.6 Aplicações no mundo real
As diversas técnicas apresentadas neste capítulo (recursão, memoização,
compactação e manipulação no nível de bits) são tão comuns no
desenvolvimento moderno de software que chega a ser impossível
imaginar o mundo da computação sem elas. Embora seja possível resolver
os problemas sem essas técnicas, em geral será mais lógico ou haverá um
melhor desempenho se forem solucionados com elas.
A recursão, em particular, está no coração não só de vários algoritmos,
mas até mesmo em linguagens de programação completas. Em algumas
linguagens de programação funcional, como Scheme e Haskell, a recursão
substitui os laços usados nas linguagens imperativas. Contudo, vale a
pena lembrar que tudo que pode ser feito com uma técnica recursiva
também pode sê-lo com uma técnica iterativa.
A memoização tem sido aplicada com sucesso para agilizar o trabalho
dos parsers (programas que interpretam linguagens). É útil para todos os
problemas nos quais o resultado de um cálculo recente será
provavelmente solicitado de novo. Outra aplicação da memoização está
nos runtimes de linguagens. Alguns runtimes de linguagens (versões de
Prolog, por exemplo) armazenam os resultados das chamadas de funções
automaticamente (automemoização), de modo que a função não precisará
executar da próxima vez que a mesma chamada for feita. É semelhante ao
modo como o decorador @lru_cache() funciona em fib6().
A compactação tem feito com que um mundo conectado pela internet
com limitações de largura de banda seja mais tolerável. A técnica de
cadeia de bits analisada na Seção 1.2 pode ser usada para tipos de dados
simples do mundo real que tenham um número limitado de valores
possíveis, para os quais mesmo um byte poderia ser um exagero. A
maioria dos algoritmos de compactação, porém, atua encontrando
padrões ou estruturas em um conjunto de dados, os quais permitem que
informações repetidas sejam eliminadas. São signi cativamente mais
complicados do que aquilo que descrevemos na Seção 1.2.
One-time pads (cifras de uso único) não são práticos para criptogra as
genéricas. Eles exigem que tanto quem criptografa como quem
descriptografa possuam uma das chaves (os dados dummy, em nosso
exemplo) para que os dados originais sejam reconstruídos, o que é
inconveniente e vai contra o objetivo da maior parte dos esquemas de
criptogra a (manter as chaves secretas). Entretanto, talvez você esteja
interessado em saber que o nome “one-time pad”3 tem origem nos espiões
que usavam bloquinhos de papel de verdade contendo dados dummy
para gerar comunicações criptografadas durante a Guerra Fria.
Essas técnicas formam os blocos de construção da programação, com
base nos quais outros algoritmos são construídos. Nos próximos
capítulos, veremos essas técnicas serem amplamente aplicadas.
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.
1 Donald Michie, um famoso cientista britânico da área de computação, cunhou o termo
memoização (memoization). Donald Michie, Memo functions: a language feature with “rotelearning” properties (Funções memo: um recurso da linguagem com propriedades de
“aprendizado por repetição”) (Universidade de Edimburgo, Departamento de Inteligência de
Máquina e Percepção, 1967).
2 Esse exemplo foi inspirado no livro Algorithms, 4ª edição, de Robert Sedgewick e Kevin Wayne
(Addison-Wesley Professional, 2011), página 819.
3 N.T.: One-time pad pode ser literalmente traduzido como uma folha de bloquinho de papel,
usado uma só vez.
CAPÍTULO 2
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.
2.1 Busca em DNA
Os genes são comumente representados em um software de computador
por uma sequência de caracteres A, C, G e T. Cada letra representa um
nucleotídeo, e a combinação de três nucleotídeos é chamada de códon. A
Figura 2.1 mostra isso.
Figura 2.1 – Um nucleotídeo é representado por uma das letras A, C, G ou T.
Um códon é composto de três nucleotídeos, e um gene é composto de vários
códons.
Um códon codi ca um aminoácido especí co que, junto com outros
aminoácidos, pode compor uma proteína. Uma tarefa clássica dos
softwares de bioinformática consiste em encontrar um códon especí co
em um gene.
2.1.1 Armazenando um DNA
Podemos representar um nucleotídeo como um IntEnum simples, com
quatro casos.
Listagem 2.1 – dna_search.py
from enum import IntEnum
from typing import Tuple, List
Nucleotide: IntEnum = IntEnum('Nucleotide', ('A', 'C', 'G', 'T'))
Nucleotide é do tipo IntEnum, e não apenas Enum, porque IntEnum
oferece
operadores de comparação(<, >= e assim por diante) “gratuitamente”. Ter
esses operadores em um tipo de dado é necessário para que os algoritmos
de busca que implementaremos atuem nesses dados. Tuple e List são
importados do pacote typing para assistência às dicas de tipos (type hints).
Os códons podem ser de nidos como uma tupla de três Nucleotides. Um
gene pode ser de nido como uma lista de Codons.
Listagem 2.2 – Continuação de dna_search.py
Codon = Tuple[Nucleotide, Nucleotide, Nucleotide] # alias de tipo para códons
Gene = List[Codon] # alias de tipo para genes
NOTA Ainda que, mais tarde, tenhamos de comparar um Codon com outro, não será
necessário de nir uma classe personalizada com o operador < explicitamente implementado
para Codon. Isso porque Python tem suporte embutido para comparações entre tuplas que
sejam compostas de tipos que também sejam comparáveis.
Em geral, os genes na internet estarão em um formato de arquivo
contendo uma string gigantesca que representa todos os nucleotídeos na
sequência do gene. De niremos uma string desse tipo para um gene
imaginário e a chamaremos de gene_str.
Listagem 2.3 – Continuação de dna_search.py
gene_str: str = "ACGTGGCTCTCTAACGTACGTACGTACGGGGTTTATATATACCCTAGGACTCCCTTT"
Também precisaremos de uma função utilitária para converter uma str
em um Gene.
Listagem 2.4 – Continuação de dna_search.py
def string_to_gene(s: str) -> Gene:
gene: Gene = []
for i in range(0, len(s), 3):
if (i + 2) >= len(s): # não avança para além do final!
return gene
# inicializa codon a partir de três nucleotídeos
codon: Codon = (Nucleotide[s[i]], Nucleotide[s[i + 1]], Nucleotide[s[i +
2]])
gene.append(codon) # adiciona condon em gene
return gene
string_to_gene()percorre continuamente a str fornecida e converte seus três
próximos caracteres em Codons que são adicionados no nal de um novo
Gene. Se for determinado que não há nenhum Nucleotide duas posições após
a posição atual no s sendo analisado (veja a instrução if no laço), então a
função saberá que chegou ao nal de um gene incompleto e ignorará
esses últimos um ou dois nucleotídeos.
string_to_gene() pode ser usado para converter a str gene_str em um Gene.
Listagem 2.5 – Continuação de dna_search.py
my_gene: Gene = string_to_gene(gene_str)
2.1.2 Busca linear
Uma operação básica que podemos executar em um gene é procurar um
códon especí co. O objetivo é apenas descobrir se o códon está presente
ou não no gene.
Uma busca linear percorre todos os elementos em um espaço de busca,
na ordem em que está a estrutura de dados original, até que o dado sendo
procurado seja encontrado ou o nal da estrutura de dados seja
alcançado. Com efeito, uma busca linear é o modo mais simples, natural e
óbvio de procurar algo. No pior caso, uma busca linear exigirá passar por
todos os elementos de uma estrutura de dados, portanto sua
complexidade é O(n), em que n é o número de elementos da estrutura. A
Figura 2.2 mostra isso.
De nir uma função que faça uma busca linear é trivial. Basta que ela
percorra todos os elementos de uma estrutura de dados e veri que sua
equivalência com o item procurado. O código a seguir de ne uma função
desse tipo para um Gene e um Codon, e então faz um teste para my_gene e para
Codons de nomes acg e gat.
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
acg: Codon = (Nucleotide.A, Nucleotide.C, Nucleotide.G)
gat: Codon = (Nucleotide.G, Nucleotide.A, Nucleotide.T)
print(linear_contains(my_gene, acg)) # True
print(linear_contains(my_gene, gat)) # False
NOTA Essa função tem apenas nalidade ilustrativa. Todos os tipos embutidos de Python para
sequências (list, tuple, range) implementam o método __contains__(), que nos permite
fazer uma busca por um item especí co usando apenas o operador in. Na verdade, o operador
in pode ser utilizado com qualquer tipo que implemente __contains__(). Por exemplo,
poderíamos pesquisar my_gene em busca de acg e exibir o resultado escrevendo print(acg
in my_gene).
2.1.3 Busca binária
Há um modo mais rápido de fazer uma busca do que veri car cada um
dos elementos, mas exige que saibamos algo sobre a ordem da estrutura
de dados com antecedência. Se soubermos que a estrutura está ordenada
e pudermos acessar instantaneamente qualquer item por meio de seu
índice, será possível fazer uma busca binária. Com base nesse critério,
uma list Python ordenada será uma candidata perfeita para uma busca
binária.
Em uma busca binária, observamos o elemento central em um conjunto
ordenado de elementos, comparamos esse elemento com o elemento
procurado, reduzimos o conjunto pela metade com base nessa
comparação e reiniciamos o processo. Vamos analisar um exemplo
concreto.
Suponha que temos uma list contendo palavras em ordem alfabética,
como ["cat", "dog", "kangaroo", "llama", "rabbit", "rat", "zebra"] e estamos
procurando a palavra “rat”:
1. Podemos determinar que o elemento central dessa lista de sete
palavra é “llama”.
2. Podemos determinar que “rat” vem depois de “llama” na ordem
alfabética, portanto ela deverá estar na metade (aproximadamente) da
lista que vem depois de “llama”. (Se tivéssemos achado “rat” neste
passo, devolveríamos a sua posição; se descobríssemos que nossa
palavra vem antes da palavra central que esamos veri cando, teríamos
a garantia de que ela estará na metade da lista que vem antes de
“llama”.)
3. Podemos executar novamente os passos 1 e 2 na metade da lista na
qual sabemos que “rat” possivelmente deve estar. Com efeito, essa
metade passará a ser a nossa nova lista de base. Esses passos são
continuamente executados até que “rat” seja encontrado ou o
intervalo que estamos veri cando não contenha mais nenhum
elemento para ser pesquisado, o que signi ca que “rat” não está
presente na lista de palavras.
A Figura 2.3 mostra uma busca binária. Observe que, diferente de uma
busca linear, ela não envolve uma busca em todos os elementos.
Figura 2.3 – No pior caso de uma busca binária, analisaremos apenas lg(n)
elementos da lista.
Uma busca binária reduz continuamente o espaço de pesquisa pela
metade, portanto, a execução de pior caso será O(lg n). Entretanto, há um
tipo de porém. De modo diferente de uma busca linear, uma busca
binária exige uma estrutura de dados ordenada para pesquisar, e uma
ordenação exige tempo. De fato, ordenar demora O(n lg n) nos melhores
algoritmos de ordenação. Se vamos executar nossa busca apenas uma vez
e nossa estrutura de dados original não está ordenada, provavelmente fará
sentido fazer apenas uma busca linear. Porém, se a busca vai ser efetuada
várias vezes, o custo de tempo para fazer a ordenação compensará,
permitindo colher os frutos do custo bastante reduzido de tempo de cada
busca individual.
Escrever uma função de busca binária para um gene e um códon não é
diferente de escrever uma função para qualquer outro tipo de dado, pois o
tipo Codon pode ser comparado com outros de seu tipo, e o tipo Gene é
apenas uma list.
Listagem 2.7 – Continuação de dna_search.py
def binary_contains(gene: Gene, key_codon: Codon) -> bool:
low: int = 0
high: int = len(gene) - 1
while low <= high: # enquanto ainda houver um espaço para pesquisa
mid: int = (low + high) // 2
if gene[mid] < key_codon:
low = mid + 1
elif gene[mid] > key_codon:
high = mid - 1
else:
return True
return False
Vamos analisar essa função linha a linha.
low: int = 0
high: int = len(gene) - 1
Começamos observando o intervalo que engloba a lista toda (o gene).
while low <= high:
Continuamos a busca enquanto houver um intervalo para ser pesquisado.
Quando low for maior do que high, é sinal de que não há mais nenhuma
posição a ser pesquisada na lista.
mid: int = (low + high) // 2
Calculamos o meio, mid, usando uma divisão por inteiro e a fórmula
simples de média que aprendemos na escola.
if gene[mid] < key_codon:
low = mid + 1
Se o elemento que estamos procurando estiver depois do elemento que
está no meio do intervalo pesquisado, modi camos o intervalo que será
analisado na próxima iteração do laço movendo low para que esteja um
elemento depois do elemento central atual. É nessa operação que
dividimos o intervalo ao meio para a próxima iteração.
elif gene[mid] > key_codon:
high = mid - 1
De modo semelhante, dividimos no meio na outra direção quando o
elemento que estamos procurando for menor que o elemento central.
else:
return True
Se o elemento em questão não for menor nem maior que o elemento
central, é sinal de que nós o encontramos! E, é claro, se não houver mais
iterações no laço, devolvemos false (não foi reproduzido aqui), indicando
que o elemento não foi encontrado.
Podemos tentar executar nossa função com o mesmo gene e os mesmos
códons, mas devemos nos lembrar de fazer uma ordenação antes.
Listagem 2.8 – Continuação de dna_search.py
my_sorted_gene: Gene = sorted(my_gene)
print(binary_contains(my_sorted_gene, acg)) # True
print(binary_contains(my_sorted_gene, gat)) # False
DICA Você pode implementar uma busca binária com um bom desempenho usando o
módulo bisect da biblioteca-padrão de Python: https://docs.python.org/3/library/bisect.html.
2.1.4 Um exemplo genérico
As funções linear_contains() e binary_contains() podem ser generalizadas
para funcionar com praticamente qualquer sequência Python. As versões
genéricas a seguir são quase idênticas às versões que vimos antes, apenas
com alguns nomes e dicas de tipo alterados.
NOTA Há muitos tipos importados na listagem de código a seguir. Reutilizaremos o arquivo
generic_search.py em vários algoritmos de busca genéricos deste capítulo e, com isso, as
importações estarão resolvidas.
NOTA Antes de prosseguir com a leitura do livro, será necessário instalar o módulo typing_
extensions usando pip
install
typing_extensions ou pip3
install
typing_extensions, dependendo de como estiver con gurado o seu interpretador Python.
Você precisará desse módulo para acessar o tipo Protocol, que estará na biblioteca-padrão
em uma versão futura de Python (conforme especi cado na PEP 544). Desse modo, em uma
versão futura de Python, importar o módulo typing_extensions será desnecessário, e você
poderá usar from typing import Protocol no lugar de from typing_extensions import
Protocol.
Listagem 2.9 – generic_search.py
from __future__ import annotations
from typing import TypeVar, Iterable, Sequence, Generic, List, Callable, Set,
Deque, Dict, Any, Optional
from typing_extensions import Protocol
from heapq import heappush, heappop
T = TypeVar('T')
def linear_contains(iterable: Iterable[T], key: T) -> bool:
for item in iterable:
if item == key:
return True
return False
C = TypeVar("C", bound="Comparable")
class Comparable(Protocol):
def __eq__(self, other: Any) -> bool:
...
def __lt__(self: C, other: C) -> bool:
...
def __gt__(self: C, other: C) -> bool:
return (not self < other) and self != other
def __le__(self: C, other: C) -> bool:
return self < other or self == other
def __ge__(self: C, other: C) -> bool:
return not self < other
def binary_contains(sequence: Sequence[C], key: C) -> bool:
low: int = 0
high: int = len(sequence) - 1
while low <= high: # enquanto ainda houver um espaço para pesquisa
mid: int = (low + high) // 2
if sequence[mid] < key:
low = mid + 1
elif sequence[mid] > key:
high = mid - 1
else:
return True
return False
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
class Cell(str, Enum):
EMPTY = " "
BLOCKED = "X"
START = "S"
GOAL = "G"
PATH = "*"
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
def _randomly_fill(self, rows: int, columns: int, sparseness: float):
for row in range(rows):
for column in range(columns):
if random.uniform(0, 1.0) < sparseness:
self._grid[row][column] = Cell.BLOCKED
Agora que temos um labirinto, também queremos exibi-lo de forma
sucinta no console. Queremos que caracteres estejam próximos para que
pareça um labirinto de verdade.
Listagem 2.13 – Continuação de maze.py
# devolve uma versão do labirinto com uma formatação elegante para exibição
def __str__(self) -> str:
output: str = ""
for row in self._grid:
output += "".join([c.value for c in row]) + "\n"
return output
Vá em frente e teste essas funções de labirinto.
maze: Maze = Maze()
print(maze)
2.2.2 Miscelânea de minúcias sobre labirintos
Mais tarde, será conveniente ter uma função que veri que se atingimos o
nosso objetivo durante a busca. Em outras palavras, queremos veri car se
um determinado MazeLocation alcançado pela busca é o objetivo. Podemos
acrescentar um método em Maze.
Listagem 2.14 – Continuação de maze.py
def goal_test(self, ml: MazeLocation) -> bool:
return ml == self.goal
Como podemos nos deslocar por nossos labirintos? Suponha que
podemos nos mover na horizontal e na vertical, uma posição de cada vez,
a partir de uma dada posição no labirinto. Usando esse critério, uma
função successors() poderá encontrar as próximas posições possíveis a
partir de um dado MazeLocation. No entanto, a função successors() será
diferente para cada Maze, pois cada Maze tem um tamanho e um conjunto
de paredes diferentes. Portanto, de niremos essa função como um
método de Maze.
Listagem 2.15 – Continuação de maze.py
def successors(self, ml: MazeLocation) -> List[MazeLocation]:
locations: List[MazeLocation] = []
if ml.row + 1 < self._rows and self._grid[ml.row + 1][ml.column] !=
Cell.BLOCKED:
locations.append(MazeLocation(ml.row + 1, ml.column))
if ml.row - 1 >= 0 and self._grid[ml.row - 1][ml.column] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row - 1, ml.column))
if ml.column + 1 < self._columns and self._grid[ml.row][ml.column + 1] !=
Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column + 1))
if ml.column - 1 >= 0 and self._grid[ml.row][ml.column - 1] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column - 1))
return locations
successors() simplesmente veri
ca as posições acima, abaixo, à direita e à
esquerda de um MazeLocation em um Maze para ver se é capaz de encontrar
espaços vazios para onde ir a partir dessa posição. A função também evita
que posições além das bordas de Maze sejam veri cadas. Todo MazeLocation
encontrado é inserido em uma lista que, em última análise, é devolvida
para quem chamou a função.
2.2.3 Busca em profundidade
Uma DFS (Depth-First Search, ou Busca em Prfundidade) é o que seu
nome sugere: uma busca que vai até a máxima profundidade possível
antes de retroceder ao seu último ponto de decisão caso encontre um
caminho sem saída. Implementaremos uma busca em profundidade
genérica, capaz de resolver o nosso problema de labirinto. Ela também
será reutilizável em outros problemas. A Figura 2.4 exibe uma busca em
profundidade em andamento em um labirinto.
Figura 2.4 – Em uma busca em profundidade, a busca se dá por um caminho
continuamente mais profundo, até que uma barreira seja atingida e seja
necessário retroceder ao último ponto de decisão.
Pilhas
O algoritmo de busca em profundidade faz uso de uma estrutura de
dados conhecida como pilha (stack). (Se você leu a respeito das pilhas no
Capítulo 1, sinta-se à vontade para ignorar esta seção.) Uma pilha é uma
estrutura de dados que funciona sob o princípio de LIFO (Last-In-FirstOut, ou o último que entra é o primeiro que sai). Pense em uma pilha de
papéis. A última folha de papel colocada no topo da pilha será a primeira
folha a ser retirada. É comum que uma pilha seja implementada com base
em uma estrutura de dados mais primitiva, como uma lista.
Implementaremos nossa pilha com base no tipo list de Python.
Em geral, as pilhas têm pelo menos duas operações:
• push()—insere um item no topo da pilha;
• pop()—remove o item do topo da pilha e o devolve.
Implementaremos as duas funções, assim como uma propriedade empty
para veri car se a pilha contém mais algum item. Adicionaremos o código
da pilha no arquivo generic_search.py com o qual estávamos trabalhando
antes neste capítulo. Já zemos todas as importações necessárias.
Listagem 2.16 – Continuação de generic_search.py
class Stack(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:
self._container.append(item)
def pop(self) -> T:
return self._container.pop() # LIFO
def __repr__(self) -> str:
return repr(self._container)
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
def __lt__(self, other: Node) -> bool:
return (self.cost + self.heuristic) < (other.cost + other.heuristic)
DICA O tipo Optional indica que o valor de um tipo parametrizado pode ser referenciado
pela variável, ou a variável pode referenciar None.
DICA No início do arquivo, from __future__ import annotations permite que Node
referencie a si mesmo nas dicas de tipo de seus métodos. Sem ela, teríamos de colocar a dica
de tipo entre aspas, na forma de uma string (por exemplo, 'Node'). Em futuras versões de
Python, não será necessário importar annotations. Consulte a PEP 563, “Postponed
Evaluation of Annotations” (Avaliação postergada das anotações) para obter mais
informações: http://mng.bz/pgzR.
Uma busca em profundidade em andamento deve manter o controle de
duas estruturas de dados: a pilha de estados (ou “lugares”) que estamos
considerando buscar, que chamaremos de frontier, e um conjunto de
estados que já buscamos, o qual chamaremos de explored. Enquanto
houver mais estados para visitar na fronteira, a DFS continuará
veri cando se eles são o objetivo (se um estado for o objetivo, a DFS
parará e o devolverá) e adicionando seus sucessores à fronteira. Ela
também marcará cada estado já pesquisado como explorado, de modo
que a busca não se torne cíclica, alcançando estados que tenham estados
visitados antes como sucessores. Se a fronteira estiver vazia, é sinal de que
não resta mais lugares para procurar.
Listagem 2.18 – Continuação de generic_search.py
def dfs(initial: T, goal_test: Callable[[T], bool], successors: Callable[[T],
List[T]]) -> Optional[Node[T]]:
# frontier correspondea os lugares que ainda não visitamos
frontier: Stack[Node[T]] = Stack()
frontier.push(Node(initial, None))
# explored representa os lugares em que já estivemos
explored: Set[T] = {initial}
# continua enquanto houver mais lugares para explorar
while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha sido
explorado
for child in successors(current_state):
if child in explored: # ignora os filhos que já tenham sido
explorados
continue
explored.add(child)
frontier.push(Node(child, current_node))
return None # passamos por todos os lugares e não atingimos o objetivo
Se dfs() for bem-sucedida, ela devolverá o Node que encapsula o estado
referente ao objetivo. O caminho do início até o objetivo pode ser
reconstruído se zermos o caminho inverso, partindo desse Node e
caminhando em direção a seus antecessores usando a propriedade parent.
Listagem 2.19 – Continuação de generic_search.py
def node_to_path(node: Node[T]) -> List[T]:
path: List[T] = [node.state]
# trabalha no sentido inverso, do final para o início
while node.parent is not None:
node = node.parent
path.append(node.state)
path.reverse()
return path
Para exibição, será conveniente marcar o labirinto com o caminho que
teve sucesso, o estado inicial e o estado referente ao objetivo. Também será
conveniente ser capaz de remover um caminho para que possamos testar
algoritmos diferentes de busca no mesmo labirinto. Os dois métodos a
seguir devem ser acrescentados na classe Maze, em maze.py.
Listagem 2.20 – Continuação de maze.py
def mark(self, path: List[MazeLocation]):
for maze_location in path:
self._grid[maze_location.row][maze_location.column] = Cell.PATH
self._grid[self.start.row][self.start.column] = Cell.START
self._grid[self.goal.row][self.goal.column] = Cell.GOAL
def clear(self, path: List[MazeLocation]):
for maze_location in path:
self._grid[maze_location.row][maze_location.column] = Cell.EMPTY
self._grid[self.start.row][self.start.column] = Cell.START
self._grid[self.goal.row][self.goal.column] = Cell.GOAL
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)
Uma solução bem-sucedida terá um aspecto semelhante a este:
S****X X
X *****
X*
XX******X
X*
X**X
X *****
*
X *X
*G
Os asteriscos representam o caminho que nossa função de busca em
profundidade encontrou, do início até o objetivo. Lembre-se de que,
como cada labirinto é gerado de modo aleatório, nem todos os labirintos
terão uma solução.
2.2.4 Busca em largura
Você pode ter percebido que os caminhos encontrados como solução para
os labirintos pela travessia em profundidade não parecem naturais. Em
geral, não são os caminhos mais curtos. Uma BFS (Breadth-First Search,
ou Busca em Largura) sempre encontra o caminho mais curto ao analisar
sistematicamente uma camada de nós mais distante do estado inicial em
cada iteração da busca. Há problemas especí cos em que uma busca em
profundidade tem mais chances de encontrar uma solução mais
rapidamente do que uma busca em largura, e vice-versa. Desse modo,
escolher entre as duas opções às vezes é uma solução de compromisso
entre a possibilidade de encontrar uma solução de forma rápida e a
certeza de encontrar o caminho mais curto até o objetivo (se houver um).
A Figura 2.5 exibe uma busca em largura em andamento em um labirinto.
Figura 2.5 – Em uma busca em largura, os elementos mais próximos da
posição inicial são pesquisados antes.
Para entender por que uma busca em profundidade às vezes devolve um
resultado de modo mais rápido que uma busca em largura, imagine que
você está procurando uma marca em uma camada especí ca de uma
cebola. Uma pessoa que esteja fazendo a procura com uma estratéga de
busca em profundidade poderia en ar uma faca até o meio da cebola e
analisar a esmo as partes cortadas. Se a camada marcada por acaso estiver
próxima da parte cortada, há uma chance de que quem está procurando a
encontre mais rapidamente do que outra pessoa que esteja usando uma
estratégia de busca em largura, pois ela descascará meticulosamente a
cebola, uma camada de cada vez.
Para obter uma imagem melhor sobre o motivo pelo qual uma busca em
largura sempre encontra a solução com o caminho mais curto, se houver,
considere tentar encontrar o caminho com o menor número de paradas
entre Boston e Nova York, de trem. Se você continuar avançando na
mesma direção e rando atingir um caminho sem saída (como na busca
em profundidade), poderá encontrar inicialmente um caminho até Seattle
antes que esse se conecte de volta a Nova York. No entanto, em uma busca
em largura, você veri cará antes todas as estações que estão a uma
estação de distância de Boston. Em seguida, veri cará todas as estações
que estão a duas paradas de distância de Boston. Então veri cará todas as
estações que estão a três paradas de distância de Boston. Você continuará
fazendo isso até encontrar Nova York. Assim, quando encontrá-la, você
saberá que encontrou a rota com o menor número de paradas, pois já
veri cou todas as estações com menos paradas a partir de Boston, e
nenhuma delas era Nova York.
Filas
Para implementar uma BFS, uma estrutura de dados conhecida como la
(queue) é necessária. Enquanto uma pilha é uma LIFO, uma la é uma
FIFO (First-In-First-Out, ou o primeiro que entra é o primeiro que sai).
Uma la é como uma la para usar um banheiro. A primeira pessoa que
entrar na la vai antes ao banheiro. Uma la tem, no mínimo, os mesmos
métodos push() e pop() de uma pilha. Com efeito, nossa implementação de
(com base em um deque Python) é quase idêntica à nossa
implementação de Stack; as únicas mudanças estão na remoção de
elementos da extremidade esquerda do _container, em vez de remover da
extremidade direita, e a mudança de uma list para um deque. (Usei a
palavra “esquerda” nesse caso para me referir ao início da área de
armazenagem.) Os elementos da extremidade esquerda são os elementos
mais antigos que ainda estão no deque (quanto ao tempo de chegada),
portanto serão os primeiros elementos a serem removidos.
Queue
Listagem 2.22 – Continuação de generic_search.py
class Queue(Generic[T]):
def __init__(self) -> None:
self._container: Deque[T] = Deque()
@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)
def pop(self) -> T:
return self._container.popleft() # FIFO
def __repr__(self) -> str:
return repr(self._container)
DICA Por que a implementação de Queue utiliza um deque como base para armazenagem,
enquanto a implementação de Stack usou uma list? Isso tem a ver com o local de remoção
(pop). Em uma pilha, inserimos e removemos da direita. Em uma la, inserimos à direita
também, porém removemos da esquerda. A estrutura de dados list de Python tem remoções
e cientes da direita, mas não da esquerda. Um deque é capaz de remover itens de modo e caz
de qualquer lado. Como resultado, há um método embutido em deque chamado popleft(),
mas não há nenhum método equivalente em list. Sem dúvida, você poderia encontrar outras
maneiras de usar uma list como base para armazenagem de uma la, mas seriam menos
e cientes. Remover da esquerda em um deque é uma operação de complexidade O(1),
enquanto é uma operação O(n) em uma list. No caso da list, depois de fazer uma remoção
da esquerda, cada elemento subsequente deverá ser movido de uma posição para a esquerda,
fazendo com que ela seja ine ciente.
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}
# continua enquanto houver mais lugares para explorar
while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha sido
explorado
for child in successors(current_state):
if child in explored: # ignora os filhos que já tenham sido
explorados
continue
explored.add(child)
frontier.push(Node(child, current_node))
return None # passamos por todos os lugares e não atingimos o objetivo
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)
É incrível que você possa manter um algoritmo igual, alterando apenas a
estrutura de dados que ele acessa, e obtenha resultados extremamente
distintos. A seguir, apresentamos o resultado da chamada a bfs() no
mesmo labirinto para o qual chamamos dfs() antes. Observe como o
caminho marcado pelos asteriscos é mais direto, do início até o objetivo,
em comparação com o exemplo anterior.
S X X
*X
* X
*XX X
* X
* X X
*X
*
* X X
*********G
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
def pop(self) -> T:
return heappop(self._container) # remove de acordo com a prioridade
def __repr__(self) -> str:
return repr(self._container)
Para determinar a prioridade de um elemento em articular versus outro
do mesmo tipo, use heappush() e heappop() e compare-os com o operador <. É
por isso que tivemos de implementar __lt__() antes em Node. Um Node é
comparado com outro observando seus respectivos f(n), e f(n) é
simplesmente a soma das propriedades cost e heuristic.
Heurística
Uma heurística é uma intuição sobre o modo de resolver um problema.2
No caso da resolução de labirintos, uma heurística tem como objetivo
escolher o melhor local do labirinto para pesquisar na sequência, na
jornada para atingir o objetivo. Em outras palavras, é um palpite bem
fundamentado acerca dos nós da fronteira que estão mais próximos do
objetivo. Conforme mencionamos antes, se uma heurística usada com
uma busca A* gerar um resultado relativo preciso e for admissível (jamais
superestima a distância), então A* fornecerá o caminho mais curto.
Heurísticas que calculam valores menores acabam resultando em uma
busca que passa por mais estados, enquanto aquelas que se aproximam
mais da distância real exata (mas que não as ultrapassam, pois isso as
tornaria inadmissíveis) resultam em umanos estados. Desse modo, as
heurísticas ideais se aproximam o máximo possível da distância real, sem
jamais a ultrapassar.
Distância euclidiana
Conforme aprendemos em geometria, o caminho mais curto entre dois
pontos é uma linha reta. Então, faz sentido que a heurística de uma linha
reta seja sempre admissível para o problema da resolução de labirintos. A
distância euclidiana, derivada do teorema de Pitágoras, de ne que
distância = √((diferença em x)2 + (diferença em y)2). Em nossos labirintos, a
diferença em x é equivalente à diferença em colunas entre duas posições
do labirinto, e a diferença em y é equivalente à diferença em linhas.
Observe que implementamos isso em maze.py.
Listagem 2.26 – Continuação de maze.py
def euclidean_distance(goal: MazeLocation) -> Callable[[MazeLocation], float]:
def distance(ml: MazeLocation) -> float:
xdist: int = ml.column - goal.column
ydist: int = ml.row - goal.row
return sqrt((xdist * xdist) + (ydist * ydist))
return distance
euclidean_distance() é uma função que devolve outra função. Linguagens
como Python com suporte para funções de primeira classe possibilitam
usar esse padrão interessante. distance() captura o goal MazeLocation recebido
por euclidean_distance(). Capturar signi ca que distance() pode referenciar
essa variável sempre que for chamada (permanentemente). A função
devolvida faz uso de goal para fazer seus cálculos. Esse padrão permite a
criação de uma função que exija menos parâmetros. A fuistance()
devolvida recebe apenas a posição inicial no labirinto como argumento e
“conhece” permanentemente o objetivo.
A Figura 2.6 ilustra a distância euclidiana no contexto de uma grade,
como as ruas de Manhattan.
Figura 2.6 – A distância euclidiana é o comprimento de uma linha reta do
ponto de partida até o objetivo.
Distância de Manhattan
A distância euclidiana é interessante, mas, em nosso problema especí co
(um labirinto no qual podemos nos deslocar apenas em uma de quatro
direções), podemos fazer algo melhor. A distância de Manhattan tem
origem na forma de percorrer as ruas de Manhattan, a região mais famosa
da cidade de Nova York, que está organizada em um padrão de grade.
Para ir de um lugar para outro lugar qualquer em Manhattan, uma pessoa
deve andar por um determinado número de quarteirões na horizontal e
determinado número de quarteirões na vertical. (Praticamente não há
ruas diagonais em Manhattan.) A distância de Manhattan é obtida
simplesmente calculando a diferença de linhas entre duas posições do
labirinto e somando-a com a diferença de colunas. A Figura 2.7 ilustra a
distância de Manhattan.
Figura 2.7 – Na distância de Manhattan, não há diagonais. O caminho deve
percorrer linhas paralelas ou perpendiculares.
Listagem 2.27 – Continuação de maze.py
def manhattan_distance(goal: MazeLocation) -> Callable[[MazeLocation], float]:
def distance(ml: MazeLocation) -> float:
xdist: int = abs(ml.column - goal.column)
ydist: int = abs(ml.row - goal.row)
return (xdist + ydist)
return distance
Pelo fato de essa heurística estar mais em consonância com a realidade
exata de como navegar por nossos labirintos (mover-se na vertical e na
horizontal, em vez de percorrer linhas diagonais retas), ela se aproxima
mais da verdadeira distância entre qualquer ponto e o objetivo em um
labirinto, em comparação com a distância euclidiana. Assim, quando uma
busca A* é usada em conjunto com a distância de Manhattan, o resultado
será uma busca por menos estados do que em uma busca A* combinada
com a distância euclidiana em nossos labirintos. Os caminhos
encontrados como solução continuarão sendo ótimos, pois a distância de
Manhattan é admissível (jamais superestima a distância) para labirintos
em que apenas quatro direções para o movimento são permitidas.
Algoritmo A*
Para ir da BFS para a busca A*, precisamos fazer várias modi cações
pequenas. A primeira é modi car a fronteira, passando de uma la para
uma la de prioridades. Desse modo, os nós removidos da fronteira serão
aqueles com o menor f(n). A segunda é alterar o conjunto explorado para
que seja um dicionário. Um dicionário nos permitirá manter o controle
do menor custo (g(n)) de cada nó que possamos visitar. Com a função
heurística agora em ação, é possível que alguns nós sejam visitados duas
vezes se a heurística estiver inconsistente. Se o nó encontrado pela nova
rota tiver um custo menor para ser alcançado em comparação com a vez
anterior em que ele foi visitado, daremos preferência a essa nova rota.
Para simpli car, a função astar() não aceita uma função de cálculo de
custo como parâmetro. Em vez disso, consideramos apenas que cada
passo em nosso labirinto tem custo igual a 1. Cada novo Node tem um
custo atribuído com base nessa fórmula simples, bem como uma
pontuação heurística que utiliza uma nova função passada como
parâmetro para a função de busca, a qual se chama heuristic(). Afora
essas mudanças, astar() é muito semelhante a bfs(). Analise-as lado a lado
para comparar.
Listagem 2.28 – generic_search.py
def astar(initial: T, goal_test: Callable[[T], bool], successors: Callable[[T],
List[T]], heuristic: Callable[[T], float]) -> Optional[Node[T]]:
# frontier corresponde aos lugares que ainda devemos visitar
frontier: PriorityQueue[Node[T]] = PriorityQueue()
frontier.push(Node(initial, None, 0.0, heuristic(initial)))
# explored representa os lugares em que já estivemos
explored: Dict[T, float] = {initial: 0.0}
# continua enquanto houver mais lugares para explorar
while not frontier.empty:
current_node: Node[T] = frontier.pop()
current_state: T = current_node.state
# se encontrarmos o objetivo, terminamos
if goal_test(current_state):
return current_node
# verifica para onde podemos ir em seguida e que ainda não tenha sido
explorado
for child in successors(current_state):
new_cost: float = current_node.cost + 1 # 1 supõe uma grade, é
necessária
# uma função de custo para aplicações mais sofisticadas
if child not in explored or explored[child] > new_cost:
explored[child] = new_cost
frontier.push(Node(child, current_node, new_cost, heuristic()
return None # passamos por todos os lugares e não atingimos o objetivo
Parabéns. Se você nos acompanhou até agora, não só aprendeu a resolver
um labirinto, mas também conheceu algumas funções de busca genéricas
que poderão ser usadas em várias aplicações diferentes de busca. A DFS e
a BFS são apropriadas para vários conjuntos menores de dados e estados,
nos quais o desempenho não seja crítico. Em algumas situações, a DFS
terá um desempenho melhor que a BFS, mas a BFS tem a vantagem de
sempre fornecer um caminho ótimo. O interessante é que a BFS e a DFS
têm implementações idênticas, diferenciadas somente pelo uso de uma
la em vez de utilizar uma pilha para a fronteira. A busca A*, um pouco
mais complicada, em conjunto com uma boa heurística admissível e
consistente, não só fornece caminhos ótimos como também tem um
desempenho muito melhor que a BFS. Como todas essas três funções
foram implementadas de modo genérico, para usá-las em praticamente
qualquer espaço de busca, basta executar apenas um import generic_search.
Vá em frente e experimente usar astar() com o mesmo labirinto que está
na seção de testes de maze.py.
Listagem 2.29 – Continuação de maze.py
# Teste de A*
distance: Callable[[MazeLocation], float] = manhattan_distance(m.goal)
solution3: Optional[Node[MazeLocation]] =
astar(m.start, m.goal_test, m.successors, distance)
if solution3 is None:
print("No solution found using A*!")
else:
path3: List[MazeLocation] = node_to_path(solution3)
m.mark(path3)
print(m)
De modo interessante, será um pouco diferente da saída de bfs(), ainda
que tanto bfs() como astar() encontremhos ótimos (equivalentes no
tamanho). Em razão de sua heurística, astar() leva imediatamente a uma
diagonal em direção ao objetivo. Em última instância, ela buscará menos
estados do que bfs(), resultando em um melhor desempenho. Adicione
um contador de estados para cada um se quiser comprovar por si mesmo.
S** X X
X**
* X
XX* X
X*
X**X
X ****
*
X * X
**G
2.3 Missionários e canibais
Três missionários e três canibais estão na margem oeste de um rio. Eles
têm uma canoa capaz de transportar duas pessoas, e todos devem passar
para a margem leste do rio. Não pode haver mais canibais do que
missionários em qualquer lado do rio; do contrário, os canibais devorarão
os missionários. Além disso, a canoa deve ter pelo menos uma pessoa a
bordo para atravessar o rio. Qual sequência de travessias será bemsucedida para levar todo o grupo para o outro lado do rio? A Figura 2.8
ilustra o problema.
Figura 2.8 – Os missionários e os canibais devem usar a única canoa para
que todos cruzem o rio, de oeste para leste. Se os canibais estiverem em um
número maior do que os missionários, eles devorarão os últimos.
2.3.1 Representando o problema
Representaremos o problema com uma estrutura que mantenha o
controle da margem oeste. Quantos missionários e canibais estão na
margem oeste? O barco está na margem oeste? Depois que tivermos
conhecimento dessas informações, será possível descobrir quem e o que
está na margem leste, pois tudo que não estiver na margem oeste estará na
margem leste.
Inicialmente, criaremos uma pequena variável conveniente para manter
o controle do número máximo de missionários ou de canibais. Em
seguida, de niremos a classe principal.
Listagem 2.30 – missionaries.py
from __future__ import annotations
from typing import List, Optional
from generic_search import bfs, Node, node_to_path
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
def __str__(self) -> str:
return ("On the west bank there are {} missionaries and {} cannibals.\n"
"On the east bank there are {} missionaries and {} cannibals.\n"
"The boat is on the {} bank.")\
.format(self.wm, self.wc, self.em, self.ec, ("west" if self.boat else
"east"))
A classe MCState é inicializada com base no número de missionários e de
canibais na margem oeste, bem como com a localização do barco. Ela
também sabe como exibir a si mesma de forma elegante, o que será
importante mais tarde, quando exibirmos a solução do problema.
Trabalhar com nossas funções de busca atuais signi ca que devemos
de nir uma função para testar se um estado corresponde ao objetivo, e
uma função para encontrar os sucessores de qualquer estado. A função
para testar o objetivo, como no problema da resolução de labirintos, é
bem simples. O objetivo é simplesmente atingir um estado permitido
(legal), com todos os missionários e canibais na margem leste.
Adicionaremos essa função como um método de MCState.
Listagem 2.31 – Continuação de missionaries.py
def goal_test(self) -> bool:
return self.is_legal and self.em == MAX_NUM and self.ec == MAX_NUM
Para criar uma função para os sucessores, é necessário percorrer todos os
movimentos possíveis que possam ser feitos de uma margem para a outra,
e então veri car se cada um desses movimentos resultará em um estado
permitido. Lembre-se de que um estado permitido é um estado em que os
canibais não estejam em um número maior do que o número de
missionários em qualquer margem. Para determinar isso, podemos de nir
uma propriedade conveniente (como um método de MCState) que veri que
se um estado é permitido.
Listagem 2.32 – Continuação de missionaries.py
@property
def is_legal(self) -> bool:
if self.wm < self.wc and self.wm > 0:
return False
if self.em < self.ec and self.em > 0:
return False
return True
A função de sucessores é um pouco extensa, por questões de clareza. Ela
tenta adicionar todas as combinações possíveis de uma ou duas pessoas
cruzando o rio, a partir da margem em que está a canoa no momento.
Depois de ter adicionado todos os movimentos possíveis, um ltro é
aplicado para obter aquelas que são realmente permitidas usando uma
list comprehension (abrangência de listas). Novamente, esse é um método
de MCState.
Listagem 2.33 – Continuação de missionaries.py
def successors(self) -> List[MCState]:
sucs: List[MCState] = []
if self.boat: # barco na margem oeste
if self.wm > 1:
sucs.append(MCState(self.wm - 2, self.wc, not self.boat))
if self.wm > 0:
sucs.append(MCState(self.wm - 1, self.wc, not self.boat))
if self.wc > 1:
sucs.append(MCState(self.wm, self.wc - 2, not self.boat))
if self.wc > 0:
sucs.append(MCState(self.wm, self.wc - 1, not self.boat))
if (self.wc > 0) and (self.wm > 0):
sucs.append(MCState(self.wm - 1, self.wc - 1, not self.boat))
else: # barco na margem leste
if self.em > 1:
sucs.append(MCState(self.wm + 2, self.wc, not self.boat))
if self.em > 0:
sucs.append(MCState(self.wm + 1, self.wc, not self.boat))
if self.ec > 1:
sucs.append(MCState(self.wm, self.wc + 2, not self.boat))
if self.ec > 0:
sucs.append(MCState(self.wm, self.wc + 1, not self.boat))
if (self.ec > 0) and (self.em > 0):
sucs.append(MCState(self.wm + 1, self.wc + 1, not self.boat))
return [x for x in sucs if x.is_legal]
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
A função display_solution() tira proveito do fato de que MCState sabe como
exibir um resumo elegante de si mesma usando __str__().
Nossa última tarefa é resolver de fato o problema dosmissionários e
canibais. Para isso, podemos reutilizar convenientemente uma função de
busca, a qual já está implementada, pois nós as implementamos de forma
genérica. Essa solução utiliza bfs() (porque usar dfs() exigiria marcar
estados referencialmente diferentes com o mesmo valor como iguais, e
astar() exigiria uma heurística).
Listagem 2.35 – Continuação de missionaries.py
if __name__ == "__main__":
start: MCState = MCState(MAX_NUM, MAX_NUM, True)
solution: Optional[Node[MCState]] = bfs(start, MCState.goal_test,
MCState.successors)
if solution is None:
print("No solution found!")
else:
path: List[MCState] = node_to_path(solution)
display_solution(path)
É interessante ver quão exíveis podem ser nossas funções de busca
genéricas. Elas podem ser facilmente adaptadas para solucionar um
conjunto diversi cado de problemas. Você verá uma saída parecida com
esta (abreviada):
On the west bank there are 3 missionaries and 3 cannibals.
On the east bank there are 0 missionaries and 0 cannibals.
The boast is on the west bank.
0 missionaries and 2 cannibals moved from the west bank to the east bank.
On the west bank there are 3 missionaries and 1 cannibals.
On the east bank there are 0 missionaries and 2 cannibals.
The boast is on the east bank.
0 missionaries and 1 cannibals moved from the east bank to the west bank.
…
On the west bank there are 0 missionaries and 0 cannibals.
On the east bank there are 3 missionaries and 3 cannibals.
The boast is on the east bank.
2.4 Aplicações no mundo real
As buscas desempenham algum papel em todos os softwares úteis. Em
alguns casos, elas são o elemento central (Google Search, Spotlight,
Lucene); em outros, são a base para utilizar as estruturas subjacentes à
armazenagem de dados. Conhecer o algoritmo de busca correto a ser
aplicado em uma estrutura de dados é essencial para o desempenho. Por
exemplo, seria muito custoso usar uma busca linear em vez de uma busca
binária em uma estrutura de dados ordenada.
O A* é um dos algoritmos mais implantados para busca de caminhos
(path nding). Só é superado por algoritmos que fazem cálculos prévios
no espaço de busca. Em uma busca cega (blind search), o A* ainda está
para ser vencido em todos os cenários, e isso tem feito dele um
componente essencial para tudo, de planejamento de rotas a descobrir o
caminho mais curto ou fazer parse de uma linguagem de programação. A
maioria dos softwares de mapa que fornecem rotas (pense no Google
Maps) utiliza o algoritmo de Dijkstra (do qual o A* é uma variante) para
navegação. (Mais informações sobre o algoritmo de Dijkstra no Capítulo
4.) Sempre que uma personagem de IA em um jogo encontra o caminho
mais curto de uma extremidade a outra no mundo, sem intervenção
humana, provavelmente ela estará usando A*.
A busca em largura e a busca em profundidade muitas vezes são a base
para algoritmos de busca mais complexos, como a busca de custo
uniforme (uniform-cost search) e a busca com backtracking (qmos no
próximo capítulo). A busca em largura com frequência é uma técnica
su ciente para encontrar o caminho mais curto em um grafo
razoavelmente pequeno. Contudo, em razão de sua semelhança com o A*,
é fácil fazer uma troca para A* se houver uma boa heurística em um grafo
maior.
2.5 Exercícios
1. Mostre a vantagem da busca binária em comparação com a busca
linear quanto ao desempenho, criando uma lista com um milhão de
números e calculando o tempo que demora para que as funções
linear_ contains() e binary_contains() de nidas neste capítulo encontrem
diversos números na lista.
2. Acrescente um contador em dfs(), bfs() e astar() para ver quantos
estados cada uma busca no mesmo labirinto. Determine os contadores
para 100 labirintos diferentes a
m de obter resultados
estatisticamente signi cativos.
3. Encontre uma solução para o problema dos missionários e canibais
para um número inicial diferente de missionários e canibais. Dica:
você talvez precise adicionar métodos para sobrescrever __eq__() e
__hash__() em MCState.
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
Problemas de satisfação de restrições
Um grande número de problemas que as ferramentas de computação
estão acostumadas a resolver pode ser amplamente classi cado como CSP
(Constraint-Satisfaction Problems, ou Problemas de Satisfação de
Restrições). Os CSPs são compostos de variáveis com valores possíveis,
que se encontram em intervalos conhecidos como domínios. As restrições
entre as variáveis devem ser satisfeitas para que os problemas de satisfação
de restrições sejam resolvidos. Esses três conceitos básicos – variáveis,
domínios e restrições – são fáceis de entender, e o fato de serem genéricos
é fundamental para a ampla aplicabilidade da resolução de problemas de
satisfação de restrições.
Vamos considerar um problema como exemplo. Suponha que você
esteja tentando agendar uma reunião na sexta-feira para Joe, Mary e Sue.
Sue deve estar na reunião com pelo menos mais uma pessoa. Para esse
problema de agendamento, as três pessoas – Joe, Mary e Sue – podem ser
as variáveis. Os respectivos horários disponíveis podem ser o domínio
para cada variável. Por exemplo, a variável Mary tem como domínio 14h,
15h e 16h. Esse problema também tem duas restrições. Uma é o fato de
Sue ter de estar na reunião. A outra é que deve haver pelo menos duas
pessoas participando dela. Um código que resolva o problema da
satisfação de restrições receberá as três variáveis, os três domínios e as
duas restrições, e então resolverá o problema sem que o usuário tenha de
explicar exatamente como. A Figura 3.1 ilustra esse exemplo.
Linguagens de programação como Prolog e Picat têm recursos
embutidos para resolver problemas de satisfação de restrições. A técnica
usual em outras linguagens consiste em construir um framework que
incorpore uma busca com backtracking (backtracking search) e várias
heurísticas para melhorar o desempenho dessa busca. Neste capítulo,
construiremos inicialmente um framework para CSPs, que os resolva
utilizando uma busca recursiva simples com backtracking. Em seguida,
usaremos o framework para solucionar diversos exemplos de problemas
diferentes.
Figura 3.1 – Problemas de agendamento são uma aplicação clássica dos
frameworks de satisfação de restrições.
3.1 Construindo um framework para problemas de satisfação de
restrições
As restrições serão de nidas usando uma classe Constraint. Cada Constraint
é constituída das variables que ela restringe e um método, satisfied(), que
veri ca se ela é satisfeita. Determinar se uma restrição é satisfeita é a
lógica principal presente na de nição de um problema especí co de
satisfação de restrições. A implementação default deve ser sobrescrita.
Com efeito, ela deve ser, pois vamos de nir nossa classe Constraint como
uma classe-base abstrata. Classes-base abstratas não devem ser
instanciadas. Apenas as subclasses que sobrescrevem e implementam seus
@abstractmethods devem ser usadas.
Listagem 3.1 – csp.py
from typing import Generic, TypeVar, Dict, List, Optional
from abc import ABC, abstractmethod
V = TypeVar('V') # tipo para variável
D = TypeVar('D') # tipo para domínio
# Classe-base para todas as restrições
class Constraint(Generic[V, D], ABC):
# As variáveis sujeitas à restrição
def __init__(self, variables: List[V]) -> None:
self.variables = variables
# deve ser sobrescrito pelas subclasses
@abstractmethod
def satisfied(self, assignment: Dict[V, D]) -> bool:
...
DICA Classes-base abstratas servem como templates em uma hierarquia de classes. São mais
comuns em outras linguagens, como C++, como um recurso disponível ao usuário, em
comparação com Python. De fato, elas só foram introduzidas em Python aproximadamente na
metade do tempo de vida da linguagem. Apesar disso, muitas das classes de coleção da
biblioteca-padrão de Python são implementadas com classes-base abstratas. O conselho geral
é não utilizar essas classes em seu próprio código, a menos que você tenha certeza de que está
construindo um framework com base no qual outras pessoas desenvolverão seus códigos, e
não apenas uma hierarquia de classes para uso interno. Para mais informações, consulte o
Capítulo 11 de Python Fluente de Luciano Ramalho (Novatec, 2015).
A peça central de nosso framework de satisfação de restrições será uma
classe chamada CSP. Essa classe é o ponto de união entre variáveis,
domínios e restrições. Quanto às dicas de tipo, ela usa genéricos (generics)
para que seja su cientemente exível de modo a trabalhar com qualquer
tipo de variável e valores de domínio (chaves V e valores de domínio D). Na
CSP, os conjuntos variables, domains e constraints são dos tipos esperados. A
coleção variables é uma list de variáveis, domains é um dict que mapeia
variáveis a listas de possíveis valores (os domínios dessas variáveis) e
constraints é um dict que mapeia cada variável a uma list das restrições
impostas a ela.
Listagem 3.2 – Continuação de csp.py
# Um problema de satisfação de restrições é composto de variáveis do tipo V
# que têm intervalos de valores conhecidos como domínios do tipo D e restrições
# que determinam se a escolha de domínio de uma variável em particular é válida
class CSP(Generic[V, D]):
def __init__(self, variables: List[V], domains: Dict[V, List[D]]) -> None:
self.variables: List[V] = variables # variáveis a serem restringidas
self.domains: Dict[V, List[D]] = domains # domínio de cada variável
self.constraints: Dict[V, List[Constraint[V, D]]] = {}
for variable in self.variables:
self.constraints[variable] = []
if variable not in self.domains:
raise LookupError("Every variable should have a domain assigned
to it.")
def add_constraint(self, constraint: Constraint[V, D]) -> None:
for variable in constraint.variables:
if variable not in self.variables:
raise LookupError("Variable in constraint not in CSP")
else:
self.constraints[variable].append(constraint)
A função de inicialização __init__() cria o dict constraints. O método
add_constraint() percorre todas as variáveis afetadas por uma dada restrição
e a adiciona no mapeamento constraints para cada uma delas. Os dois
métodos têm uma veri cação de erro básica implementada e lançarão
uma exceção se uma variable não tiver um domínio ou se uma constraint
for imposta a uma variável inexistente.
Como sabemos se uma dada con guração de variáveis e valores de
domínio selecionados satisfazem as restrições? Chamaremos uma dada
con guração desse tipo de “assignment” (atribuição). Precisamos de uma
função que veri que todas as restrições para uma dada variável em
relação a uma atribuição a m de ver se o valor da variável na atribuição
está de acordo com as restrições. A seguir, implementamos uma função
consistent() como um método de CSP.
Listagem 3.3 – Continuação de csp.py
# Verifica se a atribuição de valor é consistente consultando todas as restrições
# para a dada variável em relação a essa atribuição
def consistent(self, variable: V, assignment: Dict[V, D]) -> bool:
for constraint in self.constraints[variable]:
if not constraint.satisfied(assignment):
return False
return True
consistent() percorre todas as restrições para uma dada variável (sempre
será a variável que acabou de ser adicionada na atribuição) e veri ca se a
restrição é satisfeita, dada a nova atribuição. Se a atribuição satisfaz todas
as restrições, True será devolvido. Se alguma restrição imposta à variável
não for satisfeita, False será devolvido.
O framework de satisfação de restrições usará uma busca simples com
backtracking a m de encontrar soluções para os problemas. Segundo a
ideia de backtracking (retroceder), uma vez atingido um obstáculo em sua
busca, você deverá retroceder até o último ponto conhecido, anterior a
esse obstáculo e no qual uma decisão foi tomada, e deverá escolher um
caminho diferente. Se achar que isso se assemelha à busca em
profundidade que vimos no Capítulo 2, então você é perspicaz. A busca
com backtracking implementada na função backtracking_search() a seguir é
uma espécie de busca em profundidade recursiva, que combina ideias que
vimos nos capítulos 1 e 2. Essa função foi adicionada como um método
da classe CSP.
Listagem 3.4 – Continuação de csp.py
def backtracking_search(self, assignment: Dict[V, D] = {}) -> Optional[Dict[V,
D]]:
# assignment estará completa se todas as variáveis receberem uma
# atribuição (nosso caso de base)
if len(assignment) == len(self.variables):
return assignment
# obtém todas as variáveis que estão na CSP, mas não em assignment
unassigned: List[V] = [v for v in self.variables if v not in assignment]
# obtém todos os valores possíveis no domínio da primeira variável sem
atribuição
first: V = unassigned[0]
for value in self.domains[first]:
local_assignment = assignment.copy()
local_assignment[first] = value
# se continuamos consistentes, fazemos uma recursão (prosseguimos)
if self.consistent(first, local_assignment):
result: Optional[Dict[V, D]] =
self.backtracking_search(local_assignment)
# se não encontramos o resultado, faremos um backtracking
if result is not None:
return result
return None
Vamos descrever backtracking_search() linha a linha.
if len(assignment) == len(self.variables):
return assignment
O caso de base da busca recursiva é ter encontrado uma atribuição válida
para todas as variáveis. Uma vez que isso aconteça, devolvemos a primeira
ocorrência de uma solução válida. (Não continuamos a busca.)
unassigned: List[V] = [v for v in self.variables if v not in assignment]
first: V = unassigned[0]
Para selecionar uma nova variável cujo domínio exploraremos, basta
percorrer todas as variáveis e encontrar a primeira que não tenha uma
atribuição. Para isso, criamos uma list de variáveis que estão em
self.variables, mas não estão em assignment usando uma list
comprehension, e chamamos essa lista de unassigned. Em seguida, lemos o
primeiro valor de unassigned.
for value in self.domains[first]:
local_assignment = assignment.copy()
local_assignment[first] = value
Tentamos atribuir todos os valores possíveis do domínio para essa
variável, um de cada vez. A nova atribuição de cada valor é armazenada
em um dicionário local chamado local_assignment.
if self.consistent(first, local_assignment):
result: Optional[Dict[V, D]] = self.backtracking_search(local_assignment)
if result is not None:
return result
Se a nova atribuição em local_assignment for consistente em relação a todas
as restrições (é isso que consistent() veri ca), continuamos buscando
recursivamente, com a nova atribuição de nida. Se a nova atribuição se
mostrar completa (o caso de base), devolvemos a nova atribuição para a
cadeia de recursão.
return None # sem solução
Por m, se passarmos por todos os valores possíveis do domínio de uma
variável em particular, mas não houver uma solução utilizando o conjunto
existente de atribuições, devolveremos None, sinalizando que não há
solução. Isso levará a um backtracking na cadeia de recursão, até o ponto
em que uma atribuição anterior diferente poderia ter sido feita.
3.2 Problema de coloração do mapa da Austrália
Suponha que você tenha um mapa da Austrália que queira colorir por
estado/território (os quais chamaremos coletivamente de “regiões”). Duas
regiões adjacentes não devem ter a mesma cor. Você é capaz de colorir as
regiões usando apenas três cores diferentes?
A resposta é sim. Tente você mesmo. (O modo mais fácil é imprimir um
mapa da Austrália com um fundo branco.) Como seres humanos,
podemos descobrir rapidamente a solução fazendo uma inspeção e com
um pouco de tentativa e erro. É um problema trivial, na verdade, e um
ótimo problema inicial para o nosso código de resolução de satisfação de
restrições com backtracking. A Figura 3.2 ilustra esse problema.
Figura 3.2 – Em uma solução para o problema da coloração do mapa da
Austrália, duas partes adjacentes da Austrália não podem ter a mesma cor.
Para modelar o problema como um CSP, é necessário de nir as
variáveis, os domínios e as restrições. As variáveis são as sete regiões da
Austrália (ao menos, as sete às quais nos restringiremos): Western
Australia (Austrália Ocidental), Northern Territory (Territórios do Norte),
South Australia (Austrália Meridional ou do Sul), Queensland, New
South Wales (Nova Gales do Sul), Victoria (Vitória) e Tasmania
(Tasmânia). Em nosso CSP, elas podem ser modeladas como strings. O
domínio de cada variável são as três cores diferentes que podem ser
atribuídas. (Usaremos vermelho, verde e azul.) As restrições são a parte
complicada. Duas regiões adjacentes não podem ter a mesma cor,
portanto, nossas restrições dependerão de qual região faz fronteira com
qual região. Podemos usar o que é conhecido como restrições binárias
(restrições entre duas variáveis). Cada par de regiões que compartilhe uma
fronteira também compartilhará uma restrição binária informando que
elas não podem ter a mesma cor atribuída.
Para implementar essas restrições binárias no código, precisamos criar
uma subclasse da classe Constraint. A subclasse MapColoringConstraint
aceitará duas variáveis em seu construtor: as duas regiões que
compartilham uma fronteira. O método satisfied() que ela sobrescreve
veri cará inicialmente se as duas regiões têm valores de domínio (cores)
atribuídos a elas; se uma delas não tiver, a restrição será trivialmente
satisfeita até que tenham. (Não pode haver con ito se uma região ainda
não tem uma cor.) Em seguida, será veri cado se as duas regiões tiveram a
mesma cor atribuída. Obviamente, haverá um con ito, o que signi ca que
a restrição não está sendo satisfeita, se as cores forem iguais.
A classe será apresentada a seguir por completo. A MapColoringConstraint
em si não é genérica no que concerne às dicas de tipo, mas é uma
subclasse de uma versão parametrizada da classe Constraint genérica, a
qual informa que tanto as variáveis como os domínios são do tipo str.
Listagem 3.5 – map_coloring.py
from csp import Constraint, CSP
from typing import Dict, List, Optional
class MapColoringConstraint(Constraint[str, str]):
def __init__(self, place1: str, place2: str) -> None:
super().__init__([place1, place2])
self.place1: str = place1
self.place2: str = place2
def satisfied(self, assignment: Dict[str, str]) -> bool:
# se uma das regiões não está na atribuição, ainda não é
# possível que suas cores estejam em conflito
if self.place1 not in assignment or self.place2 not in assignment:
return True
# verifica se a cor atribuída a place1 não é igual à
# cor atribuída a place2
return assignment[self.place1] != assignment[self.place2]
DICA super() às vezes é usado para chamar um método da superclasse, mas você também
pode usar o próprio nome da classe, como em Constraint.__init__([place1, place2]).
Isso é particularmente conveniente quando lidamos com herança múltipla, para que você
saiba o método de qual superclasse você está chamando.
Agora que temos um modo de implementar as restrições entre as regiões,
resolver o problema da coloração do mapa da Austrália com nosso código
de resolução de CSP é simplesmente uma questão de preencher os
domínios e as variáveis e, em seguida, acrescentar as restrições.
Listagem 3.6 – Continuação de map_coloring.py
if __name__ == "__main__":
variables: List[str] = ["Western Australia", "Northern Territory", "South
Australia", "Queensland", "New South Wales", "Victoria", "Tasmania"]
domains: Dict[str, List[str]] = {}
for variable in variables:
domains[variable] = ["red", "green", "blue"]
csp: CSP[str, str] = CSP(variables, domains)
csp.add_constraint(MapColoringConstraint("Western Australia", "Northern
Territory"))
csp.add_constraint(MapColoringConstraint("Western Australia", "South
Australia"))
csp.add_constraint(MapColoringConstraint("South Australia", "Northern
Territory"))
csp.add_constraint(MapColoringConstraint("Queensland", "Northern Territory"))
csp.add_constraint(MapColoringConstraint("Queensland", "South Australia"))
csp.add_constraint(MapColoringConstraint("Queensland", "New South Wales"))
csp.add_constraint(MapColoringConstraint("New South Wales", "South
Australia"))
csp.add_constraint(MapColoringConstraint("Victoria", "South Australia"))
csp.add_constraint(MapColoringConstraint("Victoria", "New South Wales"))
csp.add_constraint(MapColoringConstraint("Victoria", "Tasmania"))
Por m, backtracking_search() é chamado para encontrar uma solução.
Listagem 3.7 – Continuação de map_coloring.py
solution: Optional[Dict[str, str]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)
Uma solução correta incluirá uma cor atribuída a cada região.
{'Western Australia': 'red', 'Northern Territory': 'green', 'South Australia':
'blue', 'Queensland': 'red', 'New South Wales': 'green', 'Victoria': 'red',
'Tasmania': 'green'}
3.3 Problema das oito rainhas
Um tabuleiro de xadrez é uma grade de quadrados de oito por oito. Uma
rainha é uma peça de xadrez que pode se mover por qualquer quantidade
de quadrados do tabuleiro em qualquer linha, coluna ou diagonal. Uma
rainha atacará outra peça se, em um único movimento, puder se mover
para o quadrado em que está essa peça, sem pular por cima de outras
peças. (Em outras palavras, se a outra peça estiver na linha de visão da
rainha, essa peça poderá ser atacada por ela.) O problema das oito
rainhas propõe a questão sobre como oito rainhas podem ser
posicionadas em um tabuleiro de xadrez sem que nenhuma rainha possa
atacar outra. A Figura 3.3 ilustra esse problema.
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)
Para resolver o problema, precisaremos de uma restrição que veri que se
duas rainhas quaisquer estão na mesma linha ou diagonal. (A cada uma
delas, foi atribuída uma coluna sequencial diferente no início.) Veri car se
estão na mesma linha é trivial, mas veri car se estão na mesma diagonal
exige um pouco de matemática. Se duas rainhas quaisquer estiverem na
mesma diagonal, a diferença entre suas linhas será igual à diferença entre
suas colunas. Você é capaz de ver em que local essas veri cações ocorrem
em QueensConstraint? Observe que o código a seguir está no início de nosso
arquivo-fonte.
Listagem 3.9 – Continuação de queens.py
from csp import Constraint, CSP
from typing import Dict, List, Optional
class QueensConstraint(Constraint[int, int]):
def __init__(self, columns: List[int]) -> None:
super().__init__(columns)
self.columns: List[int] = columns
def satisfied(self, assignment: Dict[int, int]) -> bool:
# q1c = coluna da rainha 1, q1r = linha da rainha 1
for q1c, q1r in assignment.items():
# q2c = coluna da rainha 2
for q2c in range(q1c + 1, len(self.columns) + 1):
if q2c in assignment:
q2r: int = assignment[q2c] # q2r = linha da rainha 2
if q1r == q2r: # mesma linha?
return False
if abs(q1r - q2r) == abs(q1c - q2c): # mesma diagonal?
return False
return True # não há conflito
Tudo que resta a fazer é adicionar a restrição e executar a busca.
Voltaremos agora para o nal do arquivo.
Listagem 3.10 – Continuação de queens.py
csp.add_constraint(QueensConstraint(columns))
solution: Optional[Dict[int, int]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)
Observe que conseguimos reutilizar o framework de resolução de
problemas de satisfação de restrições que construímos para a coloração
do mapa de modo razoavelmente simples para um tipo de problema
totalmente diferente. Eis a e cácia de escrever um código de forma
genérica! Os algoritmos devem ser implementados de modo que sejam
amplamente aplicáveis o máximo possível, a menos que uma otimização
de desempenho para uma aplicação em particular exija uma
especialização.
Uma solução correta atribuirá uma coluna e uma linha para cada
rainha.
{1: 1, 2: 5, 3: 8, 4: 6, 5: 3, 6: 7, 7: 2, 8: 4}
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
Grid = List[List[str]] # alias de tipo para grades
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)]
def display_grid(grid: Grid) -> None:
for row in grid:
print("".join(row))
Para descobrir em que lugar as palavras poderão ser inseridas na grade,
vamos gerar seus domínios. O domínio de uma palavra é uma lista de
listas dos possíveis lugares para todas as suas letras
(List[List[GridLocation]]). No entanto, as palavras não podem ser
simplesmente colocadas em qualquer lugar. Elas devem estar em uma
linha, coluna ou diagonal que esteja dentro dos limites da grade. Em
outras palavras, as palavras não devem avançar para além das fronteiras
da grade. O propósito de generate_domain() é construir essas listas para cada
palavra.
Listagem 3.13 – Continuação de word_search.py
def generate_domain(word: str, grid: Grid) -> List[List[GridLocation]]:
domain: List[List[GridLocation]] = []
height: int = len(grid)
width: int = len(grid[0])
length: int = len(word)
for row in range(height):
for col in range(width):
columns: range = range(col, col + length + 1)
rows: range = range(row, row + length + 1)
if col + length <= width:
# da esquerda para a direita
domain.append([GridLocation(row, c) for c in columns])
# diagonal em direção ao canto inferior direito
if row + length <= height:
domain.append([GridLocation(r, col + (r - row)) for r in
rows])
if row + length <= height:
# de cima para baixo
domain.append([GridLocation(r, col) for r in rows])
# diagonal em direção ao canto inferior esquerdo
if col - length >= 0:
domain.append([GridLocation(r, col - (r - row)) for r in
rows])
return domain
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
def satisfied(self, assignment: Dict[str, List[GridLocation]]) -> bool:
# se houver alguma posição duplicada na grade, é sinal de que há uma
sobreposição
all_locations = [locs for values in assignment.values() for locs in
values]
return len(set(all_locations)) == len(all_locations)
Finalmente estamos prontos para executar o código. Neste exemplo, temos
cinco palavras em uma grade de nove por nove. A solução obtida deverá
conter mapeamentos entre cada palavra e as posições em que suas letras
podem ser inseridas na grade.
Listagem 3.15 – Continuação de word_search.py
if __name__ == "__main__":
grid: Grid = generate_grid(9, 9)
words: List[str] = ["MATTHEW", "JOE", "MARY", "SARAH", "SALLY"]
locations: Dict[str, List[List[GridLocation]]] = {}
for word in words:
locations[word] = generate_domain(word, grid)
csp: CSP[str, List[GridLocation]] = CSP(words, locations)
csp.add_constraint(WordSearchConstraint(words))
solution: Optional[Dict[str, List[GridLocation]]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
for word, grid_locations in solution.items():
# inversão aleatória na metade das vezes
if choice([True, False]):
grid_locations.reverse()
for index, letter in enumerate(word):
(row, col) = (grid_locations[index].row,
grid_locations[index].column)
grid[row][col] = letter
display_grid(grid)
Há um toque nal no código que preenche a grade com as palavras.
Algumas palavras são escolhidas aleatoriamente para serem invertidas.
Isso é válido porque esse exemplo não permite que as palavras se
sobreponham. Sua saída deverá ter um aspecto semelhante àquela que
apresentamos a seguir. Você é capaz de encontrar Matthew, Joe, Mary,
Sarah e Sally?
LWEHTTAMJ
MARYLISGO
DKOJYHAYE
IAJYHALAG
GYZJWRLGM
LLOTCAYIX
PEUTUSLKO
AJZYGIKDU
HSLZOFNNR
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
Ela é totalmente solucionável manualmente, usando um pouco de álgebra
e intuição. Contudo, um programa de computador bem simples é capaz
de resolvê-la de modo mais rápido usando de força bruta ao considerar as
várias soluções possíveis. Vamos representar SEND+MORE=MONEY
como um problema de satisfação de restrições.
Listagem 3.16 – send_more_money.py
from csp import Constraint, CSP
from typing import Dict, List, Optional
class SendMoreMoneyConstraint(Constraint[str, int]):
def __init__(self, letters: List[str]) -> None:
super().__init__(letters)
self.letters: List[str] = letters
def satisfied(self, assignment: Dict[str, int]) -> bool:
# se houver valores duplicados, então não será uma solução
if len(set(assignment.values())) < len(assignment):
return False
# se uma atribuição foi feita para todas as variáveis,
# verifique se a soma está correta
if len(assignment) == len(self.letters):
s: int = assignment["S"]
e: int = assignment["E"]
n: int = assignment["N"]
d: int = assignment["D"]
m: int = assignment["M"]
o: int = assignment["O"]
r: int = assignment["R"]
y: int = assignment["Y"]
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
return send + more == money
return True # não há conflito
O método satisfied() de SendMoreMoneyConstraint executa algumas tarefas.
Inicialmente, ele veri ca se várias letras representam os mesmos dígitos.
Se isso ocorrer, a solução será inválida e False será devolvido. Em seguida,
ele veri ca se houve uma atribuição para todas as letras. Em caso
a rmativo, a função veri cará se a fórmula (SEND+MORE=MONEY)
está correta com a atribuição sendo considerada. Se estiver, é sinal de que
uma solução foi encontrada e True será devolvido. Do contrário, a função
devolverá False. Por m, se nem todas as letras tiveram um valor atribuído,
True será devolvido. Isso serve para garantir que o trabalho nessa solução
parcial tenha continuidade.
Vamos experimentar executar o código.
Listagem 3.17 – Continuação de send_more_money.py
if __name__ == "__main__":
letters: List[str] = ["S", "E", "N", "D", "M", "O", "R", "Y"]
possible_digits: Dict[str, List[int]] = {}
for letter in letters:
possible_digits[letter] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
possible_digits["M"] = [1] # para que não tenhamos respostas que comecem com
0
csp: CSP[str, int] = CSP(letters, possible_digits)
csp.add_constraint(SendMoreMoneyConstraint(letters))
solution: Optional[Dict[str, int]] = csp.backtracking_search()
if solution is None:
print("No solution found!")
else:
print(solution)
Você perceberá que atribuímos previamente a resposta para a letra M. Isso
serve para garantir que a resposta não inclua um valor 0 para M, pois, se
você pensar no assunto, nossa restrição não tem nenhuma noção do
conceito de que um número não pode começar com zero. Sinta-se à
vontade para testar sem essa resposta previamente atribuída.
A solução deverá ter um aspecto semelhante a este:
{'S': 9, 'E': 5, 'N': 6, 'D': 7, 'M': 1, 'O': 0, 'R': 8, 'Y': 2}
3.6 Layout de placa de circuitos
Um fabricante precisa acomodar determinados chips retangulares em
uma placa de circuitos retangular. Basicamente, esse problema faz a
seguinte pergunta: “De que modo vários retângulos de tamanhos
diferentes podem se acomodar perfeitamente em outro retângulo?”. Um
código para resolução de problemas de satisfação de restrições é capaz de
encontrar a solução. A Figura 3.5 ilustra o problema.
O problema do layout da placa de circuitos é parecido com o problema do
caça-palavras. Em vez de retângulos de 1xN (palavras), o problema
apresenta retângulos de M×N. Assim como no problema do caçapalavras, os retângulos não podem se sobrepor. Além disso, não podem
ser colocados em diagonais, portanto, nesse aspecto, o problema, na
verdade, é mais simples do que o problema do caça-palavras.
Figura 3.5 – O problema do layout da placa de circuitos é muito parecido
com o problema do caça-palavras, porém os retângulos têm largura variada.
Experimente reescrever por conta própria a solução usada no caçapalavras a m de adaptá-la para o layout da placa de circuitos. Você pode
reutilizar boa parte do código, incluindo o código para a grade.
3.7 Aplicações no mundo real
Conforme foi mencionado na introdução deste capítulo, os códigos para
resolução de problemas de satisfação de restrições são comuns em
agendamentos. Várias pessoas devem estar em uma reunião, e elas são as
variáveis. Os domínios são constituídos dos horários disponíveis em suas
agendas. As restrições podem envolver as combinações necessárias de
pessoas na reunião.
Códigos que resolvem problemas de satisfação de restrições também são
usados no planejamento de movimentos. Pense em um braço de robô que
deva ser encaixado dentro de um tudo. Ele tem restrições (as paredes do
tudo), variáveis (as junções) e os domínios (possíveis movimentos das
junções).
Há também aplicações em biologia computacional. Podemos pensar em
restrições entre moléculas necessárias em uma reação química. E, é claro,
como é comum em IA, há aplicações em jogos. Escrever um código para
resolver um Sudoku será um dos exercícios a seguir, mas muitas charadas
envolvendo lógica podem ser resolvidas usando resolução de problemas
de satisfação de restrições.
Neste capítulo, construímos um framework simples de resolução de
problemas com backtracking e busca em profundidade. No entanto, ele
pode ser bastante aperfeiçoado com o acréscimo de heurísticas (você se
lembra do A*?) – intuições que podem ajudar no processo de busca. Uma
técnica mais nova que o backtracking, conhecida como propagação de
restrições, é também uma opção e caz para aplicações no mundo real.
Para mais informações, consulte o Capítulo 6 do livro Arti cial
Intelligence: A Modern Approach, 3ª edição, de Stuart Russell e Peter
Norvig (Pearson, 2010)1.
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.
1 N.T.: Edição publicada no Brasil: Inteligência Arti cial (Campus, 2013).
CAPÍTULO 4
Problemas de grafos
Um grafo é uma construção matemática abstrata usada para modelar um
problema do mundo real por meio do qual esse é dividido em um
conjunto de nós conectados. Chamamos a cada um dos nós de vértice e
cada uma das conexões de aresta. Por exemplo, podemos pensar em um
mapa de metrô como um grafo que representa uma rede de transporte.
Cada um dos pontos representa uma estação, e cada uma das linhas
representa uma rota entre duas estações. Na terminologia dos grafos,
chamaríamos as estações de “vértices” e as rotas de “arestas”.
Por que isso é conveniente? Os grafos não só nos ajudam a pensar de
forma abstrata em um problema, mas também nos permitem aplicar
várias técnicas de busca e otimização muito bem compreendidas e de
bom desempenho. Por exemplo, no caso do metrô, suponha que
quiséssemos saber qual é a rota mais curta de uma estação para outra. Ou
suponha que quiséssemos saber qual é a quantidade mínima de trilhos
necessária para conectar todas as estações. Os algoritmos de grafo que
veremos neste capítulo podem resolver esses dois problemas. Além do
mais, algoritmos de grafo podem ser aplicados em qualquer tipo de
problema de rede – não apenas em redes de transporte. Pense em redes de
computadores, redes de distribuição e redes de serviços públicos
essenciais. Problemas de busca e de otimização em todos esses domínios
podem ser resolvidos usando algoritmos de grafo.
4.1 Mapa como um grafo
Neste capítulo, não trabalharemos com um grafo de estações de metrô,
mas de cidades dos Estados Unidos e possíveis rotas entre elas. A Figura
4.1 é um mapa da parte continental dos Estados Unidos e as 15 maiores
MSAs (Metropolitan Statistical Areas, ou Áreas Metropolitanas) do país,
conforme estimativa do U.S. Census Bureau (Departamento de Censo dos
Estados Unidos).1
Figura 4.1 – Um mapa com as 15 maiores MSAs dos Estados Unidos.
O famoso empresário Elon Musk sugeriu a construção de uma nova
rede de transportes de alta velocidade composta de cápsulas que
trafegariam em tubos pressurizados. De acordo com Musk, as cápsulas
viajariam a aproximadamente 1.100 km/h e seriam adequadas para um
transporte de custo viável entre cidades que estejam a menos de cerca de
1.500 quilômetros de distância.2 Ele chamou esse novo sistema de
transportes de “Hyperloop”. Neste capítulo, exploraremos problemas
clássicos de grafos no contexto da construção dessa nova rede de
transporte.
Inicialmente, Musk propôs a ideia do Hyperloop para conectar Los
Angeles e San Francisco. Se uma rede Hyperloop nacional fosse
construída por alguém, faria sentido que fosse entre as maiores áreas
metropolitanas dos Estados Unidos. Na Figura 4.2, o contorno dos
estados que estavam na Figura 4.1 foram removidos. Além disso, cada
uma das MSAs está conectada a algumas MSAs vizinhas. Para deixar o
grafo um pouco mais interessante, essas vizinhas nem sempre são as
vizinhas mais próximas da MSA.
A Figura 4.2 mostra um grafo com vértices que representam as 15
maiores MSAs dos Estados Unidos e arestas representando possíveis rotas
do Hyperloop entre as cidades. As rotas foram escolhidas com propósitos
ilustrativos. Sem dúvida, outras rotas possíveis poderiam fazer parte de
uma nova rede Hyperloop.
Figura 4.2 – Um grafo com vértices que representam as 15 maiores MSAs dos
Estados Unidos. e as arestas representando possíveis rotas do Hyperloop
entre elas.
Essa representação abstrata de um problema do mundo real dá ênfase à
e cácia dos grafos. Com essa abstração, podemos ignorar a geogra a dos
Estados Unidos e nos concentrar em pensar na possível rede Hyperloop
apenas no contexto da conexão entre as cidades. De fato, desde que as
mesmas arestas sejam mantidas, podemos pensar no problema usando
um grafo de aspecto diferente. Na Figura 4.3, por exemplo, a localização
de Miami foi alterada. O grafo da Figura 4.3, por ser uma representação
abstrata, pode ser usado para os mesmos problemas fundamentais de
computação que o grafo da Figura 4.2, ainda que Miami não esteja no
local em que esperaríamos que estivesse. Contudo, para preservar a nossa
sanidade, vamos nos ater à representação que está na Figura 4.2.
Figura 4.3 – Um grafo equivalente ao grafo da Figura 4.2, com a localização
de Miami alterada.
4.2 Construindo um framework de grafos
Python pode ser programado usando vários estilos diferentes. Entretanto,
em sua essência, Python é uma linguagem de programação orientada a
objetos. Nesta seção, de niremos dois tipos diferentes de grafos: sem peso
(unweighted) e com peso (weighted). Grafos com peso, que serão
discutidos mais adiante neste capítulo, associam um peso (leia-se um
número, por exemplo, uma distância, em nosso exemplo) a cada aresta.
Faremos uso do modelo de herança, fundamental nas hierarquias de
classes orientadas a objetos de Python, para que não haja duplicação de
nossos esforços. As classes com peso em nosso modelo de dados serão
subclasses de suas contrapartidas sem peso. Isso lhes permitirá herdar
muitas das funcionalidades, com pequenos ajustes nas partes que tornam
um grafo com peso distinto de um grafo sem peso.
Queremos que esse framework de grafos tenha o máximo de
exibilidade para que ele represente o maior número possível de
problemas. Para alcançar esse objetivo, faremos uso de genéricos
(generics) com o intuito de abstrair o tipo dos vértices. Em última
instância, cada vértice terá um índice inteiro atribuído, mas será
armazenado com o tipo genérico de nido pelo usuário.
Vamos começar a trabalhar no framework de nindo a classe Edge, que
será o recurso mais simples de nosso framework de grafos.
Listagem 4.1 – edge.py
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Edge:
u: int # o vértice "de"
v: int # o vértice "para"
def reversed(self) -> Edge:
return Edge(self.v, self.u)
def __str__(self) -> str:
return f"{self.u} -> {self.v}"
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
V = TypeVar('V') # tipo dos vértices no grafo
class Graph(Generic[V]):
def __init__(self, vertices: List[V] = []) -> None:
self._vertices: List[V] = vertices
self._edges: List[List[Edge]] = [[] for _ in vertices]
A lista _vertices é o coração de um Graph. Todos os vértices serão
armazenados na lista, porém, mais tarde, nós os referenciaremos pelo seu
índice, que é um inteiro. O vértice em si pode ser de um tipo de dado
complexo, porém seu índice sempre será um int, com o qual é mais fácil
trabalhar. Em outro nível, ao colocar esse índice entre os algoritmos de
grafo e o array _vertices, é possível ter dois vértices iguais no mesmo grafo.
(Pense em um grafo com as cidades de um país como vértices, cujo país
tenha mais de uma cidade chamada “Spring eld”.) Apesar de serem iguais,
elas terão índices inteiros distintos.
Há várias maneiras de implementar uma estrutura de dados de grafo,
mas as duas mais comuns são usar uma matriz de vértices ou utilizar listas
de adjacência. Em uma matriz de vértices, cada célula da matriz
representa a intersecção entre dois vértices do grafo, e o valor dessa célula
informa a conexão entre eles (ou a falta dela). A estrutura de dados de
nosso grafo utiliza listas de adjacência. Nessa representação de grafo, cada
vértice tem uma lista de vértices ao qual ele está conectado. Nossa
representação especí ca utiliza uma lista de listas de arestas, portanto,
para cada vértice, há uma lista de arestas por meio das quais o vértice está
conectado com outros vértices. _edges é essa lista de listas.
O resto da classe Graph será apresentado por completo a seguir. Você
notará o uso de métodos em sua maioria pequenos, de uma só linha, com
nomes longos e claros. Com isso, o resto da classe deverá ser
autoexplicativa em sua maior parte; no entanto, comentários breves foram
incluídos para que não haja espaço para problemas de interpretação.
Listagem 4.3 – Continuação de graph.py
@property
def vertex_count(self) -> int:
return len(self._vertices) # Número de vértices
@property
def edge_count(self) -> int:
return sum(map(len, self._edges)) # Número de arestas
# Adiciona um vértice ao grafo e devolve o seu índice
def add_vertex(self, vertex: V) -> int:
self._vertices.append(vertex)
self._edges.append([]) # Adiciona uma lista vazia para conter as arestas
return self.vertex_count - 1 # Devolve o índice do vértice adicionado
# Este é um grafo não direcionado,
# portanto, sempre adicionamos arestas nas duas direções
def add_edge(self, edge: Edge) -> None:
self._edges[edge.u].append(edge)
self._edges[edge.v].append(edge.reversed())
# Adiciona uma aresta usando índices dos vértices (método auxiliar)
def add_edge_by_indices(self, u: int, v: int) -> None:
edge: Edge = Edge(u, v)
self.add_edge(edge)
# Adiciona uma aresta consultando os índices dos vértices (método auxiliar)
def add_edge_by_vertices(self, first: V, second: V) -> None:
u: int = self._vertices.index(first)
v: int = self._vertices.index(second)
self.add_edge_by_indices(u, v)
# Encontra o vértice em um índice específico
def vertex_at(self, index: int) -> V:
return self._vertices[index]
# Encontra o índice de um vértice no grafo
def index_of(self, vertex: V) -> int:
return self._vertices.index(vertex)
# Encontra os vértices aos quais um vértice com determinado índice está conectado
def neighbors_for_index(self, index: int) -> List[V]:
return list(map(self.vertex_at, [e.v for e in self._edges[index]]))
# Consulta o índice de um vértice e encontra seus vizinhos (método auxiliar)
def neighbors_for_vertex(self, vertex: V) -> List[V]:
return self.neighbors_for_index(self.index_of(vertex))
# Devolve todas as arestas associadas a um vértice em um índice
def edges_for_index(self, index: int) -> List[Edge]:
return self._edges[index]
# Consulta o índice de um vértice e devolve suas arestas (método auxiliar)
def edges_for_vertex(self, vertex: V) -> List[Edge]:
return self.edges_for_index(self.index_of(vertex))
# Facilita a exibição elegante de um Graph
def __str__(self) -> str:
desc: str = ""
for i in range(self.vertex_count):
desc += f"{self.vertex_at(i)} -> {self.neighbors_for_index(i)}\n"
return desc
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]]))
_edges[index] é a lista de adjacências, isto é, a lista de arestas por meio das
quais o vértice em questão está conectado a outros vértices. Na list
comprehension passada para a chamada a map(), e representa uma aresta
em particular e e.v representa o índice do vizinho ao qual a aresta está
conectada. map() devolverá todos os vértices (e não apenas os seus índices),
pois map() aplica o método vertex_at() em cada e.v.
Outro fato importante a ser observado é o modo como add_edge()
funciona. add_edge()inicialmente adiciona uma aresta na lista de
adjacências do vértice “de” (u) e, em seguida, adiciona uma versão inversa
da aresta à lista de adjacências do vértice “para” (v). O segundo passo é
necessário porque esse grafo não é direcionado. Queremos que toda
aresta seja adicionada nas duas direções; isso signi ca que u será vizinho
de v, do mesmo modo que v é vizinho de u. Podemos pensar em um grafo
não direcionado como sendo “bidirecional” caso isso ajude você a
lembrar que isso signi ca que qualquer aresta pode ser percorrida nas
duas direções.
def add_edge(self, edge: Edge) -> None:
self._edges[edge.u].append(edge)
self._edges[edge.v].append(edge.reversed())
Conforme mencionamos antes, estamos trabalhando apenas com grafos
não direcionados neste capítulo. Afora o fato de ser não direcionado ou
direcionado, os grafos também podem ser sem peso ou com peso. Um
grafo com peso é um grafo que tem algum valor comparável, geralmente
numérico, associado a cada uma de suas arestas. Poderíamos pensar nos
pesos em nossa possível rede Hyperloop como as distâncias entre as
estações. Por enquanto, porém, trabalharemos com uma versão sem peso
do grafo. Uma aresta sem peso é simplesmente uma conexão entre dois
vértices; assim, a classe Edge não tem peso, e a classe Graph também não.
Outra forma de expressar isso é dizer que, em um grafo sem peso,
sabemos quais vértices estão conectados, enquanto, em um grafo com
peso, sabemos quais vértices estão conectados, além de conhecermos
também algo sobre essas conexões.
4.2.1 Trabalhando com Edge e Graph
Agora que temos uma implementação concreta de Edge e Graph, podemos
criar uma representação para a possível rede Hyperloop. Os vértices e as
arestas em city_graph correspondem aos vértices e arestas representados na
Figura 4.2. Fazendo uso de genéricos, podemos especi car que os vértices
serão do tipo str (Graph[str]). Em outras palavras, a variável de tipo V será
preenchida com o tipo str.
Listagem 4.4 – Continuação de graph.py
if __name__ == "__main__":
# teste para uma construção básica de Graph
city_graph: Graph[str] = Graph(["Seattle", "San Francisco", "Los Angeles",
"Riverside", "Phoenix", "Chicago", "Boston", "New York", "Atlanta", "Miami",
"Dallas", "Houston", "Detroit", "Philadelphia", "Washington"])
city_graph.add_edge_by_vertices("Seattle", "Chicago")
city_graph.add_edge_by_vertices("Seattle", "San Francisco")
city_graph.add_edge_by_vertices("San Francisco", "Riverside")
city_graph.add_edge_by_vertices("San Francisco", "Los Angeles")
city_graph.add_edge_by_vertices("Los Angeles", "Riverside")
city_graph.add_edge_by_vertices("Los Angeles", "Phoenix")
city_graph.add_edge_by_vertices("Riverside", "Phoenix")
city_graph.add_edge_by_vertices("Riverside", "Chicago")
city_graph.add_edge_by_vertices("Phoenix", "Dallas")
city_graph.add_edge_by_vertices("Phoenix", "Houston")
city_graph.add_edge_by_vertices("Dallas", "Chicago")
city_graph.add_edge_by_vertices("Dallas", "Atlanta")
city_graph.add_edge_by_vertices("Dallas", "Houston")
city_graph.add_edge_by_vertices("Houston", "Atlanta")
city_graph.add_edge_by_vertices("Houston", "Miami")
city_graph.add_edge_by_vertices("Atlanta", "Chicago")
city_graph.add_edge_by_vertices("Atlanta", "Washington")
city_graph.add_edge_by_vertices("Atlanta", "Miami")
city_graph.add_edge_by_vertices("Miami", "Washington")
city_graph.add_edge_by_vertices("Chicago", "Detroit")
city_graph.add_edge_by_vertices("Detroit", "Boston")
city_graph.add_edge_by_vertices("Detroit", "Washington")
city_graph.add_edge_by_vertices("Detroit", "New York")
city_graph.add_edge_by_vertices("Boston", "New York")
city_graph.add_edge_by_vertices("New York", "Philadelphia")
city_graph.add_edge_by_vertices("Philadelphia", "Washington")
print(city_graph)
city_graph tem vértices do tipo str, e representamos cada vértice com o
nome da MSA que ele representa. A ordem na qual adicionamos as
arestas em city_graph não é relevante. Como implementamos __str__() para
que exiba uma bela descrição do grafo, podemos agora fazer uma exibição
elegante dele (um pretty-print). Você verá uma saída semelhante a esta:
Seattle -> ['Chicago', 'San Francisco']
San Francisco -> ['Seattle', 'Riverside', 'Los Angeles']
Los Angeles -> ['San Francisco', 'Riverside', 'Phoenix']
Riverside -> ['San Francisco', 'Los Angeles', 'Phoenix', 'Chicago']
Phoenix -> ['Los Angeles', 'Riverside', 'Dallas', 'Houston']
Chicago -> ['Seattle', 'Riverside', 'Dallas', 'Atlanta', 'Detroit']
Boston -> ['Detroit', 'New York']
New York -> ['Detroit', 'Boston', 'Philadelphia']
Atlanta -> ['Dallas', 'Houston', 'Chicago', 'Washington', 'Miami']
Miami -> ['Houston', 'Atlanta', 'Washington']
Dallas -> ['Phoenix', 'Chicago', 'Atlanta', 'Houston']
Houston -> ['Phoenix', 'Dallas', 'Atlanta', 'Miami']
Detroit -> ['Chicago', 'Boston', 'Washington', 'New York']
Philadelphia -> ['New York', 'Washington']
Washington -> ['Atlanta', 'Miami', 'Detroit', 'Philadelphia']
4.3 Encontrando o caminho mínimo
O Hyperloop é tão rápido que, para otimizar o tempo de viagem de uma
estação para outra, provavelmente importarão menos as distâncias entre
as estações e mais a quantidade de paradas (quantidade de estações que
devem ser visitadas) necessárias para ir de uma estação a outra. Cada
estação pode envolver uma escala, portanto, assim como nos voos, quanto
menos paradas, melhor.
Na teoria de grafos, um conjunto de arestas que conectam dois vértices
é conhecido como um caminho (path). Em outras palavras, um caminho é
uma forma de ir de um vértice para outro. No contexto da rede
Hyperloop, um conjunto de tubos (arestas) representa o caminho de uma
cidade (vértice) a outra (vértice). Encontrar caminhos ótimos entre os
vértices é um dos problemas mais comuns para uso dos grafos.
De modo informal, podemos pensar também em um caminho como
uma lista de vértices sequencialmente conectados por meio de arestas.
Essa descrição é apenas o outro lado da mesma moeda. É como tomar
uma lista de arestas, descobrir quais vértices elas conectam, preservar essa
lista de vértices e jogar fora as arestas. No exemplo rápido a seguir,
encontraremos uma lista de vértices como essa, que conecta duas cidades
em nosso Hyperloop.
4.3.1 Retomando a busca em largura (BFS)
Em um grafo sem peso, encontrar o caminho mínimo signi ca encontrar
o caminho que tem o menor número de arestas entre o vértice de início e
o vértice de destino. Para construir a rede Hyperloop, talvez faça sentido
conectar inicialmente as cidades mais distantes nas costas marítimas mais
populosas. Isso leva à seguinte pergunta: “Qual é o caminho mínimo
entre Boston e Miami?”.
DICA Esta seção pressupõe que você leu o Capítulo 2. Antes de prosseguir, certi que-se de
estar à vontade com o conteúdo sobre a busca em largura (breadth- rst search) que está no
Capítulo 2.
Felizmente já temos um algoritmo para encontrar caminhos mínimos, e
podemos reutilizá-lo para responder a essa pergunta. A busca em largura,
apresentada no Capítulo 2, é tão viável para grafos quanto para labirintos.
De fato, os labirintos com os quais trabalhamos no Capítulo 2, na
verdade, são grafos. Os vértices são as posições no labirinto, e as arestas
são os movimentos que podem ser feitos de um local para outro. Em um
grafo sem peso, uma busca em largura encontrará o caminho mínimo
entre dois vértices quaisquer.
Podemos reutilizar a implementação da busca em largura do Capítulo 2
e usá-la para trabalhar com Graph. De fato, podemos reutilizá-la sem
nenhuma alteração. Eis a e cácia de escrever um código de forma
genérica!
Lembre-se de que a bfs() do Capítulo 2 exige três parâmetros: um
estado inicial, um Callable (leia-se um objeto do tipo função) para testar
um objetivo e um Callable que encontre os estados sucessores de um dado
estado. O estado inicial será o vértice representado pela string “Boston”. O
teste para veri car o objetivo será uma lambda que veri ca se um vértice é
equivalente a “Miami”. Por m, os vértices sucessores podem ser gerados
pelo método neighbors_for_vertex() de Graph.
Com esse plano em mente, podemos acrescentar um código no nal da
seção principal de graph.py para encontrar a rota mais curta entre Boston
e Miami em city_graph.
NOTA Na Listagem 4.5, bfs, Node e node_to_path foram importados do módulo
generic_search do pacote Chapter2. Para isso, o diretório pai de graph.py foi adicionado no
path de busca de Python ('..'). Isso funciona porque a estrutura de código do repositório do
livro inclui cada capítulo em seu próprio diretório; desse modo, nossa estrutura de diretórios
inclui, grosso modo, Book->Chapter2->generic_search.py e Book->Chapter4->graph.py. Se a
sua estrutura de diretórios for signi cativamente diferente, será necessário encontrar um
modo de adicionar generic_search.py em seu path e, possivelmente, modi car a instrução
import. Em um cenário de pior caso, você poderia simplesmente copiar generic_search.py
para o mesmo diretório que contém graph.py e alterar a instrução import para from
generic_search import bfs, Node, node_to_path.
Listagem 4.5 – Continuação de graph.py
# Reutiliza a BFS do Capítulo 2 em city_graph
import sys
sys.path.insert(0, '..') # para que possamos acessar o pacote Chapter2 no
diretório pai
from Chapter2.generic_search import bfs, Node, node_to_path
bfs_result: Optional[Node[V]] = bfs("Boston", lambda x: x == "Miami",
city_graph.neighbors_for_vertex)
if bfs_result is None:
print("No solution found using breadth-first search!")
else:
path: List[V] = node_to_path(bfs_result)
print("Path from Boston to Miami:")
print(path)
A saída deverá ter um aspecto semelhante a este:
Path from Boston to Miami:
['Boston', 'Detroit', 'Washington', 'Miami']
De Boston para Detroit, depois para Washington e então para Miami,
composta de três arestas, é a rota mínima entre Boston e Miami no que
diz respeito ao número de arestas. A Figura 4.4 exibe essa rota em
destaque.
Figura 4.4 – A rota mínima entre Boston e Miami, no que diz respeito ao
número de arestas, está em destaque.
4.4 Minimizando o custo de construção da rede
Suponha que queremos conectar todas as 15 maiores MSAs na rede
Hyperloop. Nosso objetivo é minimizar o custo de construção da rede,
portanto, signi ca usar uma quantidade mínima de trilhos. Desse modo,
a pergunta é: “Como podemos conectar todas as MSAs usando a
quantidade mínima de trilhos?”.
4.4.1 Trabalhando com pesos
Para saber qual é a quantidade de trilhos que uma aresta em particular
poderia exigir, temos de saber qual é a distância que a aresta representa.
Essa é uma oportunidade de apresentar novamente o conceito de pesos.
Na rede Hyperloop, o peso de uma aresta é a distância entre as duas
MSAs que ela conecta. A Figura 4.5 é igual à Figura 4.2, exceto pelo fato
de ter um peso adicionado a cada aresta, o qual representa a distância em
milhas entre os dois vértices conectados pela aresta.
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
def reversed(self) -> WeightedEdge:
return WeightedEdge(self.v, self.u, self.weight)
# para que possamos ordenar as arestas por peso a fim de encontrar
# a aresta de menor peso
def __lt__(self, other: WeightedEdge) -> bool:
return self.weight < other.weight
def __str__(self) -> str:
return f"{self.u} {self.weight}> {self.v}"
A implementação de WeightedEdge não é muito diferente da implementação
de Edge. Ela só difere quanto ao acréscimo de uma propriedade weight e na
implementação do operador < por meio de __lt__(), de modo que duas
WeightedEdges sejam comparáveis. O operador < está preocupado apenas
em observar os pesos (em oposição a incluir as propriedades herdadas u e
v), pois o algoritmo de Jarník está interessado em encontrar a menor
aresta de acordo com o peso.
Uma WeightedGraph herda boa parte de suas funcionalidades de Graph.
Afora isso, ela tem métodos init, métodos auxiliares para adicionar
WeightedEdges e implementa sua própria versão de __str__(). Há também um
novo método, neighbors_for_index_with_weights(), que devolve não só cada
vizinho, mas também o peso da aresta que conduz até ele. Esse método
será útil na nova versão de __str__().
Listagem 4.7 – weighted_graph.py
from typing import TypeVar, Generic, List, Tuple
from graph import Graph
from weighted_edge import WeightedEdge
V = TypeVar('V') # tipo dos vértices no grafo
class WeightedGraph(Generic[V], Graph[V]):
def __init__(self, vertices: List[V] = []) -> None:
self._vertices: List[V] = vertices
self._edges: List[List[WeightedEdge]] = [[] for _ in vertices]
def add_edge_by_indices(self, u: int, v: int, weight: float) -> None:
edge: WeightedEdge = WeightedEdge(u, v, weight)
self.add_edge(edge) # chama a versão da superclasse
def add_edge_by_vertices(self, first: V, second: V, weight: float) -> None:
u: int = self._vertices.index(first)
v: int = self._vertices.index(second)
self.add_edge_by_indices(u, v, weight)
def neighbors_for_index_with_weights(self, index: int) -> List[Tuple[V,
float]]:
distance_tuples: List[Tuple[V, float]] = []
for edge in self.edges_for_index(index):
distance_tuples.append((self.vertex_at(edge.v), edge.weight))
return distance_tuples
def __str__(self) -> str:
desc: str = ""
for i in range(self.vertex_count):
desc += f"{self.vertex_at(i)} ->
{self.neighbors_for_index_with_weights(i)}\n"
return desc
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)
Como WeightedGraph implementa __str__(), podemos fazer uma exibição
elegante de city_graph2. Na saída, vemos tanto os vértices aos quais cada
vértice está conectado como também os pesos dessas conexões.
Seattle -> [('Chicago', 1737), ('San Francisco', 678)]
San Francisco -> [('Seattle', 678), ('Riverside', 386), ('Los Angeles', 348)]
Los Angeles -> [('San Francisco', 348), ('Riverside', 50), ('Phoenix', 357)]
Riverside -> [('San Francisco', 386), ('Los Angeles', 50), ('Phoenix', 307),
('Chicago', 1704)]
Phoenix -> [('Los Angeles', 357), ('Riverside', 307), ('Dallas', 887),
('Houston', 1015)]
Chicago -> [('Seattle', 1737), ('Riverside', 1704), ('Dallas', 805),
('Atlanta', 588), ('Detroit', 238)]
Boston -> [('Detroit', 613), ('New York', 190)]
New York -> [('Detroit', 482), ('Boston', 190), ('Philadelphia', 81)]
Atlanta -> [('Dallas', 721), ('Houston', 702), ('Chicago', 588), ('Washington',
543), ('Miami', 604)]
Miami -> [('Houston', 968), ('Atlanta', 604), ('Washington', 923)]
Dallas -> [('Phoenix', 887), ('Chicago', 805), ('Atlanta', 721), ('Houston',
225)]
Houston -> [('Phoenix', 1015), ('Dallas', 225), ('Atlanta', 702), ('Miami',
968)]
Detroit -> [('Chicago', 238), ('Boston', 613), ('Washington', 396), ('New
York', 482)]
Philadelphia -> [('New York', 81), ('Washington', 123)]
Washington -> [('Atlanta', 543), ('Miami', 923), ('Detroit', 396),
('Philadelphia', 123)]
4.4.2 Encontrando a árvore geradora mínima
Uma árvore é um tipo especial de grafo que tem um, e somente um,
caminho entre dois vértices quaisquer. Isso implica que não há ciclos em
uma árvore (às vezes, essas árvores são chamadas de acíclicas). Podemos
pensar em um ciclo como um laço: se for possível percorrer um grafo a
partir de um vértice inicial e retornar até esse mesmo vértice sem repetir
nenhuma aresta, é sinal de que há um ciclo. Qualquer grafo que não seja
uma árvore poderá se tornar uma se “podarmos” algumas arestas. A
Figura 4.6 mostra a poda de uma aresta para transformar um grafo em
uma árvore.
Um grafo conectado é um grafo que permite ir de qualquer vértice para
qualquer outro vértice de alguma maneira. (Todos os grafos que estamos
analisando neste capítulo são conectados.) Uma árvore geradora
(spanning tree) é uma árvore que conecta todos os vértices em um grafo.
Uma árvore geradora mínima (minimum spanning tree) é uma árvore que
conecta todos os vértices em um grafo com peso, cujo peso total é mínimo
(em comparação com outras árvores geradoras). Para qualquer grafo com
peso, é possível encontrar a sua árvore geradora mínima.
Figura 4.6 – No grafo à esquerda, há um ciclo entre os vértices B, C e D,
portanto não é uma árvore. No grafo à direita, a aresta que conecta C e D foi
removida, portanto o grafo é uma árvore.
Ufa – tivemos um bocado de terminologia! O ponto principal é que
encontrar uma árvore geradora mínima é equivalente a encontrar um
modo de conectar todos os vértices em um grafo com peso, de modo que
o peso seja mínimo. É um problema prático e importante para qualquer
pessoa que esteja projetando uma rede (rede de transporte, rede de
computadores, e assim por diante): de que modo cada um dos nós da
rede pode ser conectado para que o custo seja mínimo? Esse custo pode
estar associado a os, trilhos, estradas ou qualquer outro elemento. Por
exemplo, em uma rede de telefonia, outra forma de apresentar o problema
é: “Qual é o comprimento mínimo de cabos necessário para conectar
todos os telefones?”.
Retomando as las de prioridade
As las de prioridades (priority queues) foram abordadas no Capítulo 2.
Precisaremos de uma la de prioridades para o algoritmo de Jarník. Você
pode importar a classe PriorityQueue do pacote do Capítulo 2 (consulte a
nota imediatamente antes da Listagem 4.5 para ver os detalhes), ou
poderá copiar a classe para um novo arquivo a ser usado com o pacote
deste capítulo. Para que o código esteja completo, recriaremos aqui a
PriorityQueue do Capítulo 2, com instruções import especí cas que
pressupõem que ela estará em um arquivo próprio.
Listagem 4.9 – priority_queue.py
from typing import TypeVar, Generic, List
from heapq import heappush, heappop
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
def push(self, item: T) -> None:
heappush(self._container, item) # insere de acordo com a prioridade
def pop(self) -> T:
return heappop(self._container) # remove de acordo com a prioridade
def __repr__(self) -> str:
return repr(self._container)
Calculando o peso total de um grafo com peso
Antes de desenvolver um método para encontrar uma árvore geradora
mínima, implementaremos uma função que poderá ser usada para testar
o peso total de uma solução. A solução para o problema da árvore
geradora mínima será constituída de uma lista de arestas com peso que
compõem a árvore. Inicialmente de niremos uma WeightedPath como uma
lista de WeightedEdge. Em seguida, de niremos uma função total_weight()
que aceita uma lista de WeightedPath e encontra o peso total resultante da
soma dos pesos de todas as suas arestas.
Listagem 4.10 – mst.py
from typing import TypeVar, List, Optional
from weighted_graph import WeightedGraph
from weighted_edge import WeightedEdge
from priority_queue import PriorityQueue
V = TypeVar('V') # tipo dos vértices no grafo
WeightedPath = List[WeightedEdge] # alias de tipo para caminhos
def total_weight(wp: WeightedPath) -> float:
return sum([e.weight for e in wp])
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
def visit(index: int):
visited[index] = True # marca como visitado
for edge in wg.edges_for_index(index):
# adiciona todas as arestas que partem daqui em pq
if not visited[edge.v]:
pq.push(edge)
visit(start) # o primeiro vértice é onde tudo começa
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
def print_weighted_path(wg: WeightedGraph, wp: WeightedPath) -> None:
for edge in wp:
print(f"{wg.vertex_at(edge.u)} {edge.weight}> {wg.vertex_at(edge.v)}")
print(f"Total Weight: {total_weight(wp)}")
Vamos descrever mst() linha a linha.
def mst(wg: WeightedGraph[V], start: int = 0) -> Optional[WeightedPath]:
if start > (wg.vertex_count - 1) or start < 0:
return None
O algoritmo devolve um WeightedPath opcional que representa a árvore
geradora mínima. Não importa em que ponto o algoritmo começa
(supondo que o grafo seja conectado e não direcionado), portanto o
default é de nido com o vértice de índice 0. Caso aconteça de start ser
inválido, mst()devolverá None.
result: WeightedPath = [] # holds the final MST
pq: PriorityQueue[WeightedEdge] = PriorityQueue()
visited: [bool] = [False] * wg.vertex_count # locais já visitados
No nal, result armazenará o caminho com peso, contendo a árvore
geradora mínima. É aí que adicionaremos as WeightedEdges, pois a aresta de
menor peso será removida da la e nos levará para uma nova parte do
grafo. O algoritmo de Jarník é considerado um algoritmo guloso (greedy)
porque sempre seleciona a aresta de menor peso. pq é o local em que
arestas recém-descoberta são armazenadas e do qual a próxima aresta de
menor peso será removida. visited mantém o controle dos índices dos
vértices que já visitamos. Isso poderia ter sido feito também com um Set,
semelhante ao explored de bfs().
def visit(index: int):
visited[index] = True # marca como visitado
for edge in wg.edges_for_index(index):
# adiciona todas as arestas que partem daqui
if not visited[edge.v]:
pq.push(edge)
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
Enquanto ainda houver arestas na la de prioridades, elas serão
removidas e veri cadas para saber se conduzem a vértices que ainda não
estão na árvore. Como a la de prioridades está em ordem crescente, as
arestas de menor peso são removidas antes. Isso garante que o resultado
tenha realmente o peso total mínimo. Qualquer aresta removida e que
não conduza a um vértice inexplorado será ignorada. Caso contrário,
como a aresta é a de menor peso vista até agora, ela será adicionada ao
conjunto resultante, e o novo vértice ao qual ela conduz será explorado.
Quando não houver mais arestas restantes para explorar, o resultado será
devolvido.
Vamos, por m, retornar ao problema de conectar todas as 15 maiores
MSAs dos Estados Unidos com o Hyperloop, usando uma quantidade
mínima de trilhos. A rota que faz isso é simplesmente a árvore geradora
mínima de city_graph2. Vamos experimentar executar mst() em city_graph2.
Listagem 4.12 – Continuação de mst.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)
result: Optional[WeightedPath] = mst(city_graph2)
if result is None:
print("No solution found!")
else:
print_weighted_path(city_graph2, result)
Graças ao método printWeightedPath() para exibição elegante, a árvore
geradora mínima é fácil de ler.
Seattle 678> San Francisco
San Francisco 348> Los Angeles
Los Angeles 50> Riverside
Riverside 307> Phoenix
Phoenix 887> Dallas
Dallas 225> Houston
Houston 702> Atlanta
Atlanta 543> Washington
Washington 123> Philadelphia
Philadelphia 81> New York
New York 190> Boston
Washington 396> Detroit
Detroit 238> Chicago
Atlanta 604> Miami
Total Weight: 5372
Em outras palavras, esse é o menor conjunto cumulativo de arestas que
conectam todas as MSAs do grafo com peso. O comprimento mínimo de
trilhos necessário para conectar todas as MSAs é de 5.372 milhas
(aproximadamente 8.645 quilômetros). A Figura 4.7 exibe a árvore
geradora mínima.
Figura 4.7 – As arestas em destaque representam a árvore geradora mínima
que conecta todas as 15 MSAs.
4.5 Encontrando caminhos mínimos em um grafo com peso
À medida que a rede Hyperloop for construída, é improvável que os
construtores tenham a ambição de conectar todo o país de uma só vez.
Em vez disso, os construtores provavelmente vão querer minimizar o
custo de instalação dos trilhos entre as principais cidades. O custo para
ampliar a rede até cidades especí cas obviamente dependerá do ponto em
que os construtores iniciarem.
Encontrar o custo para qualquer cidade, partindo de alguma cidade
inicial, é uma versão do problema do “caminho mínimo com origem
única” (single-source shortest path). Esse problema faz a seguinte
pergunta: “Qual é o caminho mínimo (no que diz respeito ao peso total
das arestas) de algum vértice para todos os demais vértices em um grafo
com peso?”.
4.5.1 Algoritmo de Dijkstra
O algoritmo de Dijkstra resolve o problema do caminho mínimo com
origem única. Um vértice inicial é fornecido ao algoritmo, e ele devolverá
o caminho de menor peso para qualquer outro vértice em um grafo com
peso. Também devolverá o peso total mínimo para todos os outros
vértices, partindo do vértice inicial. O algoritmo de Dijkstra começa no
vértice único de origem e, em seguida, explora continuamente os vértices
mais próximos ao vértice inicial. Por esse motivo, assim como o algoritmo
de Jarník, o algoritmo de Dijkstra é guloso (greedy). Quando o algoritmo
de Dijkstra encontra um novo vértice, ele armazena a distância do vértice
inicial até esse vértice e atualiza esse valor caso encontre um caminho
mais curto. Ele também armazena qual aresta conduziu a cada vértice,
como em uma busca em largura.
Eis os passos completos do algoritmo:
1. Adicione o vértice inicial em uma la de prioridades.
2. Remova o vértice mais próximo da la de prioridades (no início, será
apenas o vértice inicial); nós o chamaremos de vértice atual.
3. Observe todos os vizinhos conectados ao vértice atual. Caso ainda
não tenham sido registrados antes, ou se a aresta oferecer um novo
caminho mínimo até eles, para cada um deles, registre sua distância a
partir do início, armazene a aresta que gerou essa distância e
acrescente o novo vértice na la de prioridades.
4. Repita os passos 2 e 3 até que a la de prioridades esteja vazia.
5. Devolva a distância mínima para todos os vértices a partir do vértice
inicial, e o caminho para cada um deles.
O código do algoritmo de Dijkstra inclui DijkstraNode, que é uma estrutura
de dados simples para manter o controle dos custos associados a cada
vértice explorado até agora e compará-los. É semelhante à classe Node do
Capítulo 2. Ele também inclui funções utilitárias para converter o array
de distâncias devolvido em algo mais fácil de ser usado para consultas por
vértice e para calcular um caminho mínimo até um vértice de destino
especí co, a partir do dicionário de caminhos devolvido por dijkstra().
Sem mais demora, eis o código do algoritmo de Dijkstra. Vamos
descrevê-lo linha a linha.
Listagem 4.13 – dijkstra.py
from __future__ import annotations
from typing import TypeVar, List, Optional, Tuple, Dict
from dataclasses import dataclass
from mst import WeightedPath, print_weighted_path
from weighted_graph import WeightedGraph
from weighted_edge import WeightedEdge
from priority_queue import PriorityQueue
V = TypeVar('V') # tipo dos vértices no grafo
@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
def dijkstra(wg: WeightedGraph[V], root: V) -> Tuple[List[Optional[float]],
Dict[int, WeightedEdge]]:
first: int = wg.index_of(root) # encontra o índice inicial
# inicialmente, as distâncias são desconhecidas
distances: List[Optional[float]] = [None] * wg.vertex_count
distances[first] = 0 # a raiz está a uma distância 0 da raiz
path_dict: Dict[int, WeightedEdge] = {} # como chegamos até cada vértice
pq: PriorityQueue[DijkstraNode] = PriorityQueue()
pq.push(DijkstraNode(first, 0))
while not pq.empty:
u: int = pq.pop().vertex # explora o vértice mais próximo a seguir
dist_u: float = distances[u] # caso já tenha sido visto
# analisa todas as arestas/vértices a partir deste vértice
for we in wg.edges_for_index(u):
# a distância anterior até este vértice
dist_v: float = distances[we.v]
# não há distância anterior ou um caminho mais curto foi encontrado
if dist_v is None or dist_v > we.weight + dist_u:
# atualiza a distância até este vértice
distances[we.v] = we.weight + dist_u
# atualiza a aresta no caminho mínimo até este vértice
path_dict[we.v] = we
# será explorado em breve
pq.push(DijkstraNode(we.v, we.weight + dist_u))
return distances, path_dict
# 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
# Recebe um dicionário de arestas para alcançar cada nó e devolve
# uma lista de arestas que vão de `start` até `end`
def path_dict_to_path(start: int, end: int, path_dict: Dict[int, WeightedEdge]) > WeightedPath:
if len(path_dict) == 0:
return []
edge_path: WeightedPath = []
e: WeightedEdge = path_dict[end]
edge_path.append(e)
while e.u != start:
e = path_dict[e.u]
edge_path.append(e)
return list(reversed(edge_path))
As primeiras linhas de dijkstra() utilizam estruturas de dados com as
quais já temos familiaridade, exceto por distances, que é uma área para
armazenar as distâncias de root até cada um dos vértices do grafo.
Inicialmente, todas essas distâncias são iguais a None porque ainda não
sabemos a distância até cada um desses vértices; estamos usando o
algoritmo de Dijkstra exatamente para descobrir essa informação!
def dijkstra(wg: WeightedGraph[V], root: V) -> Tuple[List[Optional[float]],
Dict[int, WeightedEdge]]:
first: int = wg.index_of(root) # encontra o índice inicial
# inicialmente, as distâncias são desconhecidas
distances: List[Optional[float]] = [None] * wg.vertex_count
distances[first] = 0 # a raiz está a uma distância 0 da raiz
path_dict: Dict[int, WeightedEdge] = {} # como chegamos até cada vértice
pq: PriorityQueue[DijkstraNode] = PriorityQueue()
pq.push(DijkstraNode(first, 0))
O primeiro nó inserido na la de prioridades contém o vértice raiz.
while not pq.empty:
u: int = pq.pop().vertex # explora o vértice mais próximo a seguir
dist_u: float = distances[u] # caso já tenha sido visto
Continuamos executando o algoritmo de Dijkstra até que a la de
prioridades esteja vazia. u é o vértice atual a partir do qual estamos
pesquisando e dist_u é a distância armazenada para chegar até u por meio
de rotas conhecidas. Todo vértice explorado nessa etapa já foi encontrado,
portanto deverá ter uma distância conhecida.
# analisa todas as arestas/vértices a partir deste vértice
for we in wg.edges_for_index(u):
# a distância anterior até este vértice
dist_v: float = distances[we.v]
Em seguida, toda aresta conectada a u é explorada. dist_v é a distância
para qualquer vértice conhecido conectado por uma aresta a partir de u.
# não há distância anterior ou um caminho mais curto foi encontrado
if dist_v is None or dist_v > we.weight + dist_u:
# atualiza a distância até este vértice
distances[we.v] = we.weight + dist_u
# atualiza a aresta no caminho mínimo
path_dict[we.v] = we
# será explorado em breve
pq.push(DijkstraNode(we.v, we.weight + dist_u))
Se encontramos um vértice que ainda não tenha sido explorado (dist_v é
None), ou se encontramos um novo caminho mínimo até ele, registramos
essa nova distância menor até v e a aresta que nos levou até ela. Por m,
inserimos qualquer vértice que tenha novos caminhos até eles na la de
prioridades.
return distances, path_dict
dijkstra() devolve tanto as distâncias até cada vértice do grafo com peso,
partindo do vértice raiz, e o path_dict que identi ca os caminhos mínimos
até eles.
É seguro executar o algoritmo de Dijkstra agora. Começaremos
encontrando a distância de Los Angeles até todas as demais MSAs no
grafo. Em seguida, encontraremos o caminho mínimo entre Los Angeles e
Boston. Por m, usaremos print_weighted_path() para exibir o resultado de
forma elegante.
Listagem 4.14 – Continuação de dijkstra.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)
distances, path_dict = dijkstra(city_graph2, "Los Angeles")
name_distance: Dict[str, Optional[int]] =
distance_array_to_vertex_dict(city_graph2, distances)
print("Distances from Los Angeles:")
for key, value in name_distance.items():
print(f"{key} : {value}")
print("") # linha em branco
print("Shortest path from Los Angeles to Boston:")
path: WeightedPath = path_dict_to_path(city_graph2.index_of("Los Angeles"),
city_graph2.index_of("Boston"), path_dict)
print_weighted_path(city_graph2, path)
A saída deverá ter um aspecto semelhante a este:
Distances from Los Angeles:
Seattle : 1026
San Francisco : 348
Los Angeles : 0
Riverside : 50
Phoenix : 357
Chicago : 1754
Boston : 2605
New York : 2474
Atlanta : 1965
Miami : 2340
Dallas : 1244
Houston : 1372
Detroit : 1992
Philadelphia : 2511
Washington : 2388
Shortest path from Los Angeles to Boston:
Los Angeles 50> Riverside
Riverside 1704> Chicago
Chicago 238> Detroit
Detroit 613> Boston
Total Weight: 2605
Talvez você tenha percebido que o algoritmo de Dijkstra tem certa
semelhança com o algoritmo de Jarník. Ambos são gulosos (greedy) e é
possível implementá-los usando um código bastante parecido se houver
motivação su ciente. Outro algoritmo que se assemelha ao algoritmo de
Dijkstra é o A* do Capítulo 2. O A* pode ser considerada uma
modi cação do algoritmo de Dijkstra. Acrescente uma heurística e
restrinja o algoritmo de Dijkstra de modo que encontre um único destino,
e os dois algoritmos serão iguais.
NOTA O algoritmo de Dijkstra foi concebido para grafos com pesos positivos. Grafos com
arestas de pesos negativos podem representar um desa o para o algoritmo de Dijkstra e
exigirão modi cações ou um algoritmo alternativo.
4.6 Aplicações no mundo real
Uma parte enorme de nosso mundo pode ser representada com grafos.
Neste capítulo, vimos quão e cientes eles são para trabalhar com redes de
transporte, mas vários outros tipos de redes diferentes têm os mesmos
problemas básicos de otimização: redes de telefonia, de computadores, de
serviços públicos essenciais (energia elétrica, água e esgoto, e assim por
diante). Como resultado, os algoritmos de grafo são essenciais para ter
e ciência em telecomunicações, fretes, transportes e no mercado de
serviços públicos essenciais.
As lojas de varejo precisam lidar com problemas complexos de
distribuição. Podemos pensar nas lojas e armazéns de distribuição como
vértices e nas distâncias entre eles como as arestas. Os algoritmos são os
mesmos. A própria internet é um grafo gigantesco, em que cada
dispositivo conectado é um vértice e cada conexão com ou sem o é uma
aresta. Independentemente de uma empresa estar economizando
combustível ou os, a árvore geradora mínima (minimum spanning tree)
e a resolução do problema de caminhos mínimos são úteis para outras
situações que vão além de apenas jogos. Algumas das marcas globais mais
famosas se tornaram bem-sucedidas otimizando problemas de grafos:
pense na Walmart construindo uma rede e ciente de distribuição, o
Google indexando a web (um grafo gigante) e a FedEx encontrando o
conjunto correto de centros de distribuição para conectar os vários
endereços no mundo.
Algumas aplicações óbvias de algoritmos de grafos são as redes sociais e
os aplicativos de mapa. Em uma rede social, as pessoas são os vértices, e
as conexões (amizades no Facebook, por exemplo) são as arestas. Com
efeito, uma das ferramentas de desenvolvedor mais proeminentes do
Facebook é conhecida como Graph API, isto é, API de Grafos
(https://developers.facebook.com/docs/graph-api). Em aplicativos de mapa,
como o Apple Maps e o Google Maps, algoritmos de grafo são usados
para fornecer rotas e calcular tempos de viagem.
Vários videogames populares também fazem uso explícito de algoritmos
de grafos. O Mini-Metro e o Ticket to Ride são dois exemplos de jogos
que imitam de perto os problemas resolvidos neste capítulo.
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 (AddisonWesley Professional, 2011), p. 619.
CAPÍTULO 5
Algoritmos genéticos
Os algoritmos genéticos não são usados para problemas de programação
do cotidiano. Lançamos mão deles quando abordagens algorítmicas
tradicionais são insu cientes para se chegar à solução de um problema em
tempo razoável. Em outras palavras, algoritmos genéticos em geral são
reservados para problemas complexos, sem soluções fáceis. Se você precisa
ter uma noção de quais possam ser esses problemas complexos, sinta-se à
vontade para ler a Seção 5.7 antes de prosseguir. Um exemplo interessante,
porém, é o docking de proteína-ligante (protein-ligand docking) e o
design de fármacos. Pro ssionais em biologia computacional precisam
fazer o design de moléculas que se ligarão a receptores para a condução de
medicamentos. Talvez não haja algoritmos óbvios para fazer o design de
uma molécula em particular, mas, como veremos, às vezes, os algoritmos
genéticos podem fornecer uma resposta sem que tenham muitas
orientações além de uma de nição do objetivo de um problema.
5.1 Background em biologia
Em biologia, a teoria da evolução é uma explicação sobre como a
mutação genética, junto com as limitações de um ambiente, levou a
mudanças em organismos com o passar do tempo (incluindo a
especiação, isto é, a criação de novas espécies). O mecanismo pelo qual os
organismos bem adaptados tiveram sucesso e os menos adaptados
falharam é conhecido como seleção natural. Cada geração de uma espécie
incluirá indivíduos com traços diferentes (às vezes, novos), que surgem em
decorrência de uma mutação genética. Todos os indivíduos competem
por recursos limitados para sobreviver, e, como há mais indivíduos do
que recursos, alguns devem perecer.
Um indivíduo com uma mutação que o torne mais bem adaptado para
sobreviver em seu ambiente terá uma probabilidade maior de viver e de se
reproduzir. Com o tempo, os indivíduos mais bem adaptados em um
ambiente terão mais descendentes e, por meio de herança, passarão suas
mutações a eles. Desse modo, uma mutação que seja vantajosa para a
sobrevivência provavelmente se proliferará no futuro, em uma população.
Por exemplo, se bactérias estiverem sendo mortas por um antibiótico
especí co, e uma bactéria individual na população tiver uma mutação em
um gene que a torne mais resistente ao antibiótico, é mais provável que ela
sobreviva e se reproduza. Se o antibiótico for continuamente aplicado, os
descendentes que tiverem herdado o gene para a resistência ao antibiótico
também terão mais chances de se reproduzir e ter descendentes próprios.
Em algum momento, toda a população poderá adquirir a mutação, pois
aplicações contínuas do antibiótico eliminarão os indivíduos sem a
mutação. O antibiótico não faz com que a mutação se desenvolva, porém
leva à proliferação de indivíduos com a mutação.
A seleção natural tem sido aplicada em esferas que vão além da biologia.
O darwinismo social é a seleção natural aplicada à esfera da teoria social.
Em ciência da computação, os algoritmos genéticos são uma simulação da
seleção natural para resolver desa os computacionais.
Um algoritmo genético inclui uma população (grupo) de indivíduos
conhecidos como cromossomos. Os cromossomos, cada qual composto de
genes que especi cam seus traços, competem para resolver algum
problema. A competência com que um cromossomo é capaz de resolver
um problema é de nida por uma função de aptidão ( tness function,
também chamada de função de avaliação).
O algoritmo genético passa por gerações. Em cada geração, há uma
chance maior de os cromossomos mais aptos serem selecionados para se
reproduzir. Há também uma probabilidade de que dois cromossomos
tenham seus genes misturados em cada geração. Isso é conhecido como
crossover. Por m, existe uma possibilidade relevante de que, em cada
geração, um gene em um cromossomo possa sofrer uma mutação
(modi car-se aleatoriamente).
Depois que a função de aptidão de alguns indivíduos da população
ultrapassar um limiar especi cado, ou o algoritmo executar um
determinado número máximo de gerações, o melhor indivíduo (aquele
com a maior pontuação na função de aptidão) será devolvido.
Os algoritmos genéticos não são uma boa solução para qualquer
problema. Eles dependem de três operações parcialmente ou totalmente
estocásticas (determinados aleatoriamente): seleção, crossover e mutação.
Desse modo, podem não encontrar uma solução ótima em tempo
razoável. Para a maioria dos problemas, há algoritmos mais
determinísticos, com garantias melhores. Contudo, há problemas para os
quais não há nenhum algoritmo determinístico rápido. Nesses casos, os
algoritmos genéticos são uma boa opção.
5.2 Algoritmo genético genérico
Os algoritmos genéticos muitas vezes são extremamente especializados e
ajustados para uma aplicação especí ca. Neste capítulo, de niremos um
algoritmo genético genérico que poderá ser usado em vários problemas,
apesar de não estar particularmente bem ajustado para nenhum deles. O
algoritmo incluirá algumas opções con guráveis, mas o objetivo é
demonstrar suas bases, e não a possibilidade de ajustes.
Começaremos de nindo uma interface para os indivíduos nos quais o
algoritmo poderá atuar. A classe abstrata Chromosome de ne quatro recursos
essenciais. Um cromossomo deve ser capaz de fazer o seguinte:
• determinar a sua própria aptidão;
• criar uma instância com genes selecionados aleatoriamente (para
preencher a primeira geração);
• implementar um crossover (combinar a si mesmo com outro do
mesmo tipo para gerar lhos) – em outras palavras, misturar a si
mesmo com outro cromossomo;
• efetuar uma mutação – fazer uma pequena modi cação razoavelmente
aleatória em si mesmo.
Eis o código de Chromosome, que implementa esses quatro recursos.
Listagem 5.1 – chromosome.py
from __future__ import annotations
from typing import TypeVar, Tuple, Type
from abc import ABC, abstractmethod
T = TypeVar('T', bound='Chromosome') # para devolver a si mesmo
# Classe-base para todos os cromossomos; todos os métodos devem ser sobrescritos
class Chromosome(ABC):
@abstractmethod
def fitness(self) -> float:
...
@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
C = TypeVar('C', bound=Chromosome) # tipo dos cromossomos
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
O código anterior inclui todas as propriedades do algoritmo genético, as
quais serão con guradas com __init__() no momento da criação.
initial_population são os cromossomos na primeira geração do algoritmo.
threshold é o nível de aptidão que indica que uma solução para o problema
que o algoritmo genético está tentando resolver foi encontrada.
max_generations é o número máximo de gerações que deve haver. Se
tivermos de executar esse número de gerações e nenhuma solução com
um nível de aptidão acima de threshold for encontrada, a melhor solução
encontrada será devolvida. mutation_chance é a probabilidade de cada
cromossomo em cada geração sofrer uma mutação. crossover_chance é a
probabilidade de dois pais selecionados para reproduzir gerarem lhos
que sejam uma mistura de seus genes; caso contrário, os lhos serão
apenas duplicatas dos pais. Por m, selection_type é o tipo do método de
seleção a ser usado, conforme representado pelo enum SelectionType.
O método init anterior aceita uma longa lista de parâmetros, em que a
maioria tem valores default. Eles de nem as versões de instância das
propriedades con guráveis que acabamos de discutir. Em nossos
exemplos, _population será inicializado com um conjunto aleatório de
cromossomos usando o método de classe random_instance() da classe
Chromosome. Em outras palavras, a primeira geração de cromossomos será
composta simplesmente de indivíduos aleatórios. Esse é um ponto de
possível otimização em um algoritmo genético mais so sticado. Em vez de
começar com indivíduos puramente aleatórios, a primeira geração
poderia conter indivíduos que estejam mais próximos da solução, o que
poderá ser feito com algum tipo de conhecimento acerca do problema.
Isso é conhecido como seeding (semeadura).
_fitness_key é uma referência ao método que usaremos em todo a classe
GeneticAlgorithm para calcular a aptidão de um cromossomo. Lembre-se de
que essa classe deve funcionar para qualquer subclasse de Chromosome.
Desse modo, _fitness_key será diferente conforme a subclasse. Para acessálo, usamos type() para referenciar a subclasse especí ca de Chromosome da
qual estamos determinando a aptidão.
Analisaremos agora os dois métodos de seleção aceitos por nossa classe.
Listagem 5.4 – Continuação de genetic_algorithm.py
# Usa a roleta de distribuição de probabilidades para escolher dois pais
# Nota: não trabalharemos com resultados negativos de aptidão
def _pick_roulette(self, wheel: List[float]) -> Tuple[C, C]:
return tuple(choices(self._population, weights=wheel, k=2))
A seleção por roleta é baseada na proporção da aptidão de cada
cromossomo em relação à soma de todas as aptidões em uma geração. Os
cromossomos com as maiores aptidões terão mais chance de serem
escolhidos. Os valores que representam a aptidão de cada cromossomo
são fornecidos no parâmetro wheel. A escolha propriamente dita é feita de
modo conveniente pela função choices() do módulo random da bibliotecapadrão de Python. Essa função aceita uma lista de itens entre os quais
queremos fazer uma escolha, uma lista de mesmo tamanho contendo
pesos para cada item da primeira lista e a quantidade de itens que
queremos escolher.
Se fôssemos implementar isso por conta própria, poderíamos calcular
porcentagens da aptidão total para cada item (aptidões proporcionais)
representadas por valores de ponto utuante entre 0 e 1. Um número
aleatório (pick) entre 0 e 1 poderia ser usado para determinar qual
cromossomo deve ser selecionado. O algoritmo funcionaria
decrementando pick do valor de aptidão proporcional de cada
cromossomo, sequencialmente. Quando pick chegar a 0, esse é o
cromossomo a ser selecionado.
Faz sentido para você o motivo pelo qual esse processo resulta em cada
cromossomo ser escolhido de acordo com a sua proporção? Se não zer,
pense nisso usando lápis e papel. Considere desenhar uma roleta com
proporções, como mostra a Figura 5.2.
Figura 5.2 – Exemplo de uma seleção por roleta em ação.
A forma básica de seleção por torneio é mais simples que a seleção por
roleta. Em vez de determinar as proporções, basta escolher k
cromossomos dentre toda a população de forma aleatória. Os dois
cromossomos com as melhores aptidões entre o grupo aleatoriamente
selecionado vencem.
Listagem 5.5 – Continuação de genetic_algorithm.py
# Escolhe num_participants aleatoriamente e seleciona os 2 melhores
def _pick_tournament(self, num_participants: int) -> Tuple[C, C]:
participants: List[C] = choices(self._population, k=num_participants)
return tuple(nlargest(2, participants, key=self._fitness_key))
O código de _pick_tournament() inicialmente utiliza choices() para escolher
aleatoriamente num_participants de _population. Em seguida, a função
nlargest() do módulo heapq é usada para encontrar os dois maiores
indivíduos de acordo com _fitness_key. Qual é o número correto para
num_participants? Como ocorre com muitos parâmetros em um algoritmo
genético, o método de tentativa e erro talvez seja a melhor maneira de
determiná-lo. Um ponto a ser lembrado é que um número maior de
participantes no torneio resulta em menos diversidade na população, pois
é mais provável que os cromossomos com menos aptidão sejam
eliminados nas disputas.1 Formas mais so sticadas de seleção por torneio
podem escolher indivíduos que não são os melhores, mas os segundo ou
terceiro melhores, com base em algum tipo de modelo de probabilidade
decrescente.
Esses dois métodos, _pick_roulette() e _pick_tournament(), são usados para
seleção, que ocorre durante a reprodução. A reprodução está
implementada em _reproduce_ and_replace(), e ela também cuida de garantir
que uma nova população com o mesmo número de cromossomos
substitua os cromossomos da última geração.
Listagem 5.6 – Continuação de genetic_algorithm.py
# Substitui a população por uma nova geração de indivíduos
def _reproduce_and_replace(self) -> None:
new_population: List[C] = []
# continua até ter completada a nova geração
while len(new_population) < len(self._population):
# escolhe os 2 pais
if self._selection_type == GeneticAlgorithm.SelectionType.ROULETTE:
parents: Tuple[C, C] = self._pick_roulette([x.fitness() for x in
self._population])
else:
parents = self._pick_tournament(len(self._population) // 2)
# faz um possível crossover dos dois pais
if random() < self._crossover_chance:
new_population.extend(parents[0].crossover(parents[1]))
else:
new_population.extend(parents)
# se tivemos um número ímpar, teremos 1 extra, portanto ele será removido
if len(new_population) > len(self._population):
new_population.pop()
self._population = new_population # substitui a referência
Em _reproduce_and_replace(), os passos a seguir, de modo geral, são
executados:
1. Dois cromossomos, chamados parents, são selecionados para
reprodução usando um dos dois métodos de seleção. Na seleção por
torneio, sempre executamos o torneio na metade da população total,
mas essa também poderia ser uma opção con gurável.
2. Há uma chance de _crossover_chance de os dois pais serem combinados
para gerar dois novos cromossomos, caso em que esses serão
adicionados em new_population. Se não houver lhos, os dois pais serão
simplesmente adicionados em new_population.
3. Se new_population tiver tantos cromossomos quanto em _population, ela
substituirá esse último. Caso contrário, retorne ao passo 1.
O método que implementa a mutação, _mutate(), é bem simples, e os
detalhes de como efetuar uma mutação são deixados a cargo dos
cromossomos individuais.
Listagem 5.7 – Continuação de genetic_algorithm.py
# Com uma probabilidade de _mutation_chance faz uma mutação em cada indivíduo
def _mutate(self) -> None:
for individual in self._population:
if random() < self._mutation_chance:
individual.mutate()
Temos agora todos os blocos de construção necessários para executar o
algoritmo genético. run() coordena os passos de avaliação, reprodução
(que inclui a seleção) e mutação, que levam a população de uma geração a
outra. O método também mantém o controle do melhor cromossomo (o
mais apto) encontrado, em qualquer ponto da busca.
Listagem 5.8 – Continuação de genetic_algorithm.py
# Executa o algoritmo genético para max_generations iterações
# e devolve o melhor indivíduo encontrado
def run(self) -> C:
best: C = max(self._population, key=self._fitness_key)
for generation in range(self._max_generations):
# sai antes, se o limiar for atingido
if best.fitness() >= self._threshold:
return best
print(f"Generation {generation} Best {best.fitness()} Avg {
mean(map(self._fitness_key, self._population))}")
self._reproduce_and_replace()
self._mutate()
highest: C = max(self._population, key=self._fitness_key)
if highest.fitness() > best.fitness():
best = highest # encontrado um novo cromossomo melhor
return best # o melhor encontrado em _max_generations
best registra o melhor cromossomo encontrado até então. O laço principal
executa um número de vezes igual a _max_generations. Se algum
cromossomo exceder threshold em aptidão, ele será devolvido, e o método
terminará. Caso contrário, _reproduce_and_replace() será chamado, bem
como _mutate(), a m de criar a próxima geração e executar o laço
novamente. Se _max_generations for alcançado, o melhor cromossomo
encontrado até então será devolvido.
5.3 Teste simples
O algoritmo genético genérico GeneticAlgorithm funcionará com qualquer
tipo que implemente Chromosome. Para testar, começaremos implementando
um código para um problema simples, que pode ser facilmente
solucionado com métodos tradicionais. Tentaremos maximizar a equação
6x – x2 + 4y – y2. Em outras palavras, quais são os valores de x e de y
nessa equação que resultarão no maior número?
Os valores que maximizam a equação podem ser encontrados fazendo
uso de cálculo, utilizando derivadas parciais e de nindo cada uma com
zero. O resultado é x = 3 e y = 2. Nosso algoritmo genético será capaz de
chegar ao mesmo resultado sem usar cálculo? Vamos pôr mãos à obra.
Listagem 5.9 – simple_equation.py
from __future__ import annotations
from typing import Tuple, List
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import randrange, random
from copy import deepcopy
class SimpleEquation(Chromosome):
def __init__(self, x: int, y: int) -> None:
self.x: int = x
self.y: int = y
def fitness(self) -> float: # 6x - x^2 + 4y - y^2
return 6 * self.x - self.x * self.x + 4 * self.y - self.y * self.y
@classmethod
def random_instance(cls) -> SimpleEquation:
return SimpleEquation(randrange(100), randrange(100))
def crossover(self, other: SimpleEquation) -> Tuple[SimpleEquation,
SimpleEquation]:
child1: SimpleEquation = deepcopy(self)
child2: SimpleEquation = deepcopy(other)
child1.y = other.y
child2.y = self.y
return child1, child2
def mutate(self) -> None:
if random() > 0.5: # faz a mutação de x
if random() > 0.5:
self.x += 1
else:
self.x -= 1
else: # caso contrário, faz a mutação de y
if random() > 0.5:
self.y += 1
else:
self.y -= 1
def __str__(self) -> str:
return f"X: {self.x} Y: {self.y} Fitness: {self.fitness()}"
SimpleEquation está em consonância com Chromosome e, de modo
el ao seu
nome, funciona do modo mais simples possível. Podemos pensar nos
genes de um cromossomo SimpleEquation como x e y. O método fitness()
avalia x e y usando a equação 6x – x2 + 4y – y2. Quanto maior o valor,
mais apropriado será o cromossomo individual, de acordo com
GeneticAlgorithm. No caso de uma instância aleatória, x e y são de nidos
inicialmente com inteiros aleatórios entre 0 e 100, portanto,
random_instance() não precisa fazer nada além de instanciar uma nova
SimpleEquation com esses valores. Para combinar uma SimpleEquation com
outra em crossover(), os valores de y das duas instâncias são simplesmente
trocados para criar os dois lhos. mutate() incrementa ou decrementa
aleatoriamente x ou y. E é basicamente isso.
Como SimpleEquation está em consonância com Chromosome, já podemos
utilizá-lo em GeneticAlgorithm.
Listagem 5.10 – Continuação de simple_equation.py
if __name__ == "__main__":
initial_population: List[SimpleEquation] =
[SimpleEquation.random_instance() for _ in range(20)]
ga: GeneticAlgorithm[SimpleEquation] =
GeneticAlgorithm(initial_population=initial_population, threshold=13.0,
max_generations = 100, mutation_chance = 0.1, crossover_chance = 0.7)
result: SimpleEquation = ga.run()
print(result)
Os parâmetros usados nesse caso foram determinados por meio de palpite
e veri cação. Você pode experimentar outros valores. threshold foi de nido
com 13.0 porque já sabíamos a resposta correta. Quando x = 3 e y = 2, a
equação é avaliada com 13.
Se você não soubesse a resposta com antecedência, talvez quisesse ver o
melhor resultado possível de ser encontrado em determinado número de
gerações. Nesse caso, poderíamos de nir threshold com algum valor
arbitrariamente alto. Lembre-se de que, como os algoritmos genéticos são
estocásticos, cada execução será diferente.
Eis um exemplo de saída de uma execução na qual o algoritmo genético
solucionou a equação em nove gerações:
Generation 0 Best -349 Avg -6112.3
Generation 1 Best 4 Avg -1306.7
Generation 2 Best 9 Avg -288.25
Generation 3 Best 9 Avg -7.35
Generation 4 Best 12 Avg 7.25
Generation 5 Best 12 Avg 8.5
Generation 6 Best 12 Avg 9.65
Generation 7 Best 12 Avg 11.7
Generation 8 Best 12 Avg 11.6
X: 3 Y: 2 Fitness: 13
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.
5.4 Revendo SEND+MORE=MONEY
No Capítulo 3, resolvemos o problema clássico de criptoaritmética
SEND+MORE=MONEY usando um framework de satisfação de
restrições. (Para relembrar o que é o problema, reveja a sua descrição no
Capítulo 3.) O problema também pode ser resolvido em tempo razoável
usando um algoritmo genético.
Uma das principais di culdades em formular um problema para que
seja resolvido com um algoritmo genético é determinar o modo de
representá-lo. Uma representação conveniente para problemas de
criptoaritmética é utilizar índices de lista como dígitos.2 Desse modo, para
representar os dez dígitos possíveis (0, 1, 2, 3, 4, 5, 6, 7, 8, 9), uma lista de
dez elementos é necessária. Os caracteres a serem procurados no
problema podem ser então deslocados de um lugar para outro. Por
exemplo, se suspeitarmos que a solução para um problema inclui o
caractere “E” representando o dígito 4, então list[4] = "E".
SEND+MORE=MONEY tem oito letras distintas (S, E, N, D, M, O, R, Y),
deixando duas posições vazias no array. Essas posições podem ser
preenchidas com espaços, sinalizando a ausência de letras.
Um cromossomo que representa o problema SEND+MORE=MONEY
está descrito em SendMoreMoney2. Observe como o método fitness() é
bastante parecido com satisfied() de SendMoreMoneyConstraint, que vimos no
Capítulo 3.
Listagem 5.11 – send_more_money2.py
from __future__ import annotations
from typing import Tuple, List
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import shuffle, sample
from copy import deepcopy
class SendMoreMoney2(Chromosome):
def __init__(self, letters: List[str]) -> None:
self.letters: List[str] = letters
def fitness(self) -> float:
s: int = self.letters.index("S")
e: int = self.letters.index("E")
n: int = self.letters.index("N")
d: int = self.letters.index("D")
m: int = self.letters.index("M")
o: int = self.letters.index("O")
r: int = self.letters.index("R")
y: int = self.letters.index("Y")
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
difference: int = abs(money - (send + more))
return 1 / (difference + 1)
@classmethod
def random_instance(cls) -> SendMoreMoney2:
letters = ["S", "E", "N", "D", "M", "O", "R", "Y", " ", " "]
shuffle(letters)
return SendMoreMoney2(letters)
def crossover(self, other: SendMoreMoney2) -> Tuple[SendMoreMoney2,
SendMoreMoney2]:
child1: SendMoreMoney2 = deepcopy(self)
child2: SendMoreMoney2 = deepcopy(other)
idx1, idx2 = sample(range(len(self.letters)), k=2)
l1, l2 = child1.letters[idx1], child2.letters[idx2]
child1.letters[child1.letters.index(l2)], child1.letters[idx2] =
child1.letters[idx2], l2
child2.letters[child2.letters.index(l1)], child2.letters[idx1] =
child2.letters[idx1], l1
return child1, child2
def mutate(self) -> None: # troca as posições de duas letras
idx1, idx2 = sample(range(len(self.letters)), k=2)
self.letters[idx1], self.letters[idx2] = self.letters[idx2],
self.letters[idx1]
def __str__(self) -> str:
s: int = self.letters.index("S")
e: int = self.letters.index("E")
n: int = self.letters.index("N")
d: int = self.letters.index("D")
m: int = self.letters.index("M")
o: int = self.letters.index("O")
r: int = self.letters.index("R")
y: int = self.letters.index("Y")
send: int = s * 1000 + e * 100 + n * 10 + d
more: int = m * 1000 + o * 100 + r * 10 + e
money: int = m * 10000 + o * 1000 + n * 100 + e * 10 + y
difference: int = abs(money - (send + more))
return f"{send} + {more} = {money} Difference: {difference}"
Contudo, há uma diferença importante entre o método satisfied() do
Capítulo 3 e o método fitness() desta seção. Em nosso método,
devolvemos 1 / (difference + 1). difference é o valor absoluto da diferença
entre MONEY e SEND+MORE. Esse valor representa quão distante está
o cromossomo de resolver o problema. Se estivéssemos tentando
minimizar fitness(), poderíamos devolver apenas difference. Porém, como
GeneticAlgorithm tenta maximizar o valor de fitness(), o valor deve ser
invertido (de modo que valores menores pareçam maiores), e é por isso
que 1 é dividido por difference. O valor 1 é somado antes em difference;
desse modo, uma difference igual a 0 não resultará em um fitness() igual a
0, mas 1. A Tabela 5.1 mostra como isso funciona.
Tabela 5.1 – Como a equação 1 / (di erence + 1) resulta em valores de
aptidão ( tnesses) para maximização
difference difference + 1 fitness (1/(difference + 1))
0
1
2
3
1
2
3
4
1
0,5
0,25
0,125
Lembre-se de que diferenças menores são melhores, e valores maiores de
aptidão ( tnesses) são melhores. Como essa fórmula faz com que esses
dois fatos se alinhem, ela funciona bem. Dividir 1 por um valor de
aptidão é uma forma simples de converter um problema de minimização
em um problema de maximização. Contudo, isso introduz algumas
distorções, portanto não é um método infalível.3
random_instance() faz uso da função shuffle() do módulo random. crossover()
seleciona dois índices aleatórios nas listas letters dos dois cromossomos e
troca as letras, de modo que acabamos com uma letra do primeiro
cromossomo no mesmo lugar no segundo cromossomo, e vice-versa.
Essas trocas são feitas nos lhos, de modo que o posicionamento das
letras nos dois lhos é, no nal, uma combinação dos pais. mutate() troca
duas posições aleatórias na lista letters.
Podemos associar SendMoreMoney2 a GeneticAlgorithm de modo tão simples
como o zemos com SimpleEquation. No entanto, considere-se avisado: este
é um problema razoavelmente difícil, e demorará bastante para o código
executar caso os parâmetros não estejam bem ajustados. Além disso,
continua havendo certa aleatoriedade, mesmo que esses parâmetros
estejam corretos! O problema poderá ser resolvido em alguns segundos
ou em alguns minutos. Infelizmente, é da natureza dos algoritmos
genéticos.
Listagem 5.12 – Continuação de send_more_money2.py
if __name__ == "__main__":
initial_population: List[SendMoreMoney2] =
[SendMoreMoney2.random_instance() for _ in range(1000)]
ga: GeneticAlgorithm[SendMoreMoney2] =
GeneticAlgorithm(initial_population=initial_population, threshold=1.0,
max_generations = 1000, mutation_chance = 0.2, crossover_chance = 0.7,
selection_type=GeneticAlgorithm.SelectionType.ROULETTE)
result: SendMoreMoney2 = ga.run()
print(result)
A saída a seguir é de uma execução que solucionou o problema em três
gerações usando mil indivíduos em cada geração (conforme criado no
código anterior). Veja se você consegue brincar com os parâmetros
con guráveis de GeneticAlgorithm e obter um resultado semelhante com
menos indivíduos. O algoritmo parece funcionar melhor com a seleção
por roleta do que com a seleção por torneio?
Generation 0 Best 0.0040650406504065045 Avg 8.854014252391551e-05
Generation 1 Best 0.16666666666666666 Avg 0.001277329479413134
Generation 2 Best 0.5 Avg 0.014920889170684687
8324 + 913 = 9237 Difference: 0
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.
5.5 Otimizando a compactação de listas
Suponha que temos algumas informações que queremos compactar.
Imagine que seja uma lista de itens, e que não nos importamos com a
ordem dos itens, desde que todos continuem intactos. Qual é a ordem dos
itens que maximizará a taxa de compactação? Você ao menos sabia que a
ordem dos itens afeta a taxa de compactação na maioria dos algoritmos
de compactação?
A resposta dependerá do algoritmo de compactação usado. Nesse
exemplo, usaremos a função compress() do módulo zlib com suas
con gurações padrões. A solução será mostrada a seguir por completo,
para uma lista de 12 nomes. Se não executarmos o algoritmo genético e
executarmos apenas compress() nos 12 nomes na ordem em que foram
originalmente apresentados, os dados compactados resultantes terão 165
bytes.
Listagem 5.13 – list_compression.py
from __future__ import annotations
from typing import Tuple, List, Any
from chromosome import Chromosome
from genetic_algorithm import GeneticAlgorithm
from random import shuffle, sample
from copy import deepcopy
from zlib import compress
from sys import getsizeof
from pickle import dumps
# 165 bytes compactados
PEOPLE: List[str] = ["Michael", "Sarah", "Joshua", "Narine", "David", "Sajid",
"Melanie", "Daniel", "Wei", "Dean", "Brian", "Murat", "Lisa"]
class ListCompression(Chromosome):
def __init__(self, lst: List[Any]) -> None:
self.lst: List[Any] = lst
@property
def bytes_compressed(self) -> int:
return getsizeof(compress(dumps(self.lst)))
def fitness(self) -> float:
return 1 / self.bytes_compressed
@classmethod
def random_instance(cls) -> ListCompression:
mylst: List[str] = deepcopy(PEOPLE)
shuffle(mylst)
return ListCompression(mylst)
def crossover(self, other: ListCompression) -> Tuple[ListCompression,
ListCompression]:
child1: ListCompression = deepcopy(self)
child2: ListCompression = deepcopy(other)
idx1, idx2 = sample(range(len(self.lst)), k=2)
l1, l2 = child1.lst[idx1], child2.lst[idx2]
child1.lst[child1.lst.index(l2)], child1.lst[idx2] = child1.lst[idx2], l2
child2.lst[child2.lst.index(l1)], child2.lst[idx1] = child2.lst[idx1], l1
return child1, child2
def mutate(self) -> None: # troca duas posições
idx1, idx2 = sample(range(len(self.lst)), k=2)
self.lst[idx1], self.lst[idx2] = self.lst[idx2], self.lst[idx1]
def __str__(self) -> str:
return f"Order: {self.lst} Bytes: {self.bytes_compressed}"
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)
Observe a semelhança dessa implementação com a implementação de
SEND+ MORE=MONEY da Seção 5.4. As funções crossover() e mutate()
são basicamente iguais. Nas soluções dos dois problemas, tomamos uma
lista de itens e as reorganizamos continuamente, testando essas listas
reorganizadas. Poderíamos escrever uma superclasse genérica para as
soluções de ambos os problemas, que funcionaria para uma grande
variedade de problemas. Qualquer problema que possa ser representado
como uma lista de itens para a qual uma ordem ótima tenha de ser
encontrada poderia ser solucionado do mesmo modo. O único ponto de
personalização para as subclasses seriam suas respectivas funções de
aptidão ( tness functions).
Se executássemos list_compression.py, ela poderia demorar bastante
para terminar. Isso ocorre porque não sabemos previamente o que
constitui a resposta “certa”, de modo diferente dos dois problemas
anteriores, portanto, não temos um verdadeiro limiar em direção ao qual
trabalharemos. Em vez disso, de nimos o número de gerações e o número
de indivíduos em cada geração com um número arbitrariamente alto, e
esperamos pelo melhor. Qual é o número mínimo de bytes que resultará
da compactação com a reorganização dos 12 nomes? Honestamente, não
sabemos a resposta para isso. Em minha melhor execução, utilizando a
con guração da solução anterior, após 546 gerações, o algoritmo genético
encontrou uma ordem para os 12 nomes que resultou em 159 bytes
compactados.
É uma economia de apenas 6 bytes em relação à ordem original – uma
economia de ~4%. Poderíamos dizer que 4% é irrelevante, mas, se essa
fosse uma lista muito maior, a ser transmitida várias vezes por uma rede, a
economia total poderia ser alta. Suponha que essa lista tivesse 1 MB e
que, em algum momento, fosse transmitida pela internet 10 milhões de
vezes. Se o algoritmo genético conseguisse otimizar a ordem da lista para
compactá-la de modo a economizar 4%, isso representaria uma economia
de ~40 kilobytes por transferência e, em última análise, 400 GB de largura
de banda para todas as transferências. Não é uma quantidade enorme,
mas, talvez, pudesse ser su cientemente signi cativa, a ponto de valer a
pena executar o algoritmo uma vez a m de encontrar uma ordem que
estivesse próxima da ordem ideal para a compactação.
Considere, porém, o seguinte: não sabemos realmente se encontramos a
ordem ótima para os 12 nomes, muito menos para a lista hipotética de 1
MB. Como saberíamos se tivéssemos encontrado? A menos que tenhamos
uma compreensão profunda do algoritmo de compactação, teríamos de
tentar compactar todas as possíveis ordens da lista. Para uma lista com
apenas 12 itens, seriam 479.001.600 ordens possíveis, o que seria
praticamente inviável (12!, em que ! signi ca fatorial). Usar um algoritmo
genético que apenas tente encontrar uma solução ótima talvez seja mais
viável, mesmo se não soubermos se a solução nal é realmente ótima.
5.6 Desa os para os algoritmos genéticos
Os algoritmos genéticos não são uma panaceia. Na verdade, eles não são
apropriados para a maioria dos problemas. Para qualquer problema para
o qual haja um algoritmo determinístico rápido, uma abordagem com um
algoritmo genético não fará sentido. Sua natureza inerentemente
estocástica faz com que seus tempos de execução sejam imprevisíveis. Para
resolver esse problema, eles podem ser interrompidos após determinado
número de gerações. Porém, não estará claro se uma solução realmente
ótima foi encontrada.
Steven Skiena, autor de um dos textos mais conhecidos sobre
algoritmos, foi até mais longe e escreveu o seguinte:
Jamais encontrei um problema para o qual os algoritmos genéticos me
parecessem o modo correto de atacá-lo. Além disso, nunca vi nenhum
resultado de processamento fornecido por algoritmos genéticos que tenha
me impressionado favoravelmente.4
A visão de Skiena é um pouco radical, mas é um indício do fato de que os
algoritmos genéticos só deverão ser escolhidos quando você tiver uma
certeza razoável de que não há uma solução melhor. Outro problema com
os algoritmos genéticos é determinar como representar uma possível
solução para um problema na forma de um cromossomo. A prática
tradicional consiste em representar a maioria dos problemas como cadeias
binárias (sequências de 1s e 0s, isto é, bits brutos). Com frequência, isso é
ideal no que diz respeito ao uso de espaço, e implica funções simples de
crossover. Contudo, os problemas mais complexos não são facilmente
representados como cadeias de bits divisíveis.
Outro problema, mais especí co, que vale a pena ser mencionado são os
desa os relacionados ao método de seleção por roleta descrito neste
capítulo.
A seleção por roleta, às vezes chamada de seleção proporcional à
aptidão, pode levar a uma falta de diversidade em uma população em
virtude da dominância de indivíduos relativamente aptos, sempre que a
seleção é efetuada. Por outro lado, se os valores de aptidão estiverem
próximos, a seleção por roleta pode resultar em uma falta de pressão para
a seleção.5 Além do mais, a seleção por roleta, conforme implementada
neste capítulo, não funciona para problemas nos quais a aptidão pode ser
mensurada com valores negativos, como em nosso exemplo simples de
equação da seção 5.3.
Em suma, para a maioria dos problemas su cientemente grandes que
justi que usá-los, os algoritmos genéticos não são capazes de garantir a
descoberta de uma solução ótima em um intervalo de tempo previsível.
Por esse motivo, eles serão mais bem utilizados em situações que não
exijam uma solução ótima, mas uma solução que seja “boa o su ciente”.
Esses algoritmos são relativamente fáceis de implementar, mas ajustar
seus parâmetros con guráveis pode exigir muitas tentativas e erros.
5.7 Aplicações no mundo real
Apesar do que Skiena escreveu, os algoritmos genéticos são aplicados de
modo frequente e e caz em diversos domínios de problemas. São muitas
vezes usados em problemas difíceis, que não exigem soluções
perfeitamente ótimas, como problemas de satisfação de restrições que
sejam grandes demais para serem resolvidos com métodos tradicionais.
Um exemplo são os problemas complexos de agendamento.
Muitas aplicações para os algoritmos genéticos têm sido encontradas em
biologia computacional. Esses algoritmos têm sido usados com sucesso
para docking de proteína-ligante, que é a busca da con guração de uma
pequena molécula quando ela é ligada a um receptor. Isso é usado em
pesquisa farmacêutica e para compreender melhor os mecanismos da
natureza.
O Problema do Caixeiro-Viajante, que veremos novamente no Capítulo
9, é um dos problemas mais famosos em ciência da computação. Um
caixeiro-viajante quer encontrar o caminho mais curto em um mapa, por
meio do qual visite todas as cidades exatamente uma vez e que o leve de
volta ao seu ponto de partida. Essa situação pode lembrar as árvores
geradoras mínimas (minimum spanning trees) do Capítulo 4, mas é
diferente. No problema do Caixeiro-Viajante, a solução é um ciclo
gigantesco que minimiza o custo para percorrê-lo, enquanto uma árvore
geradora mínima minimiza o custo de conectar todas as cidades. Uma
pessoa que percorra uma árvore geradora mínima de cidades talvez tenha
de visitar a mesma cidade duas vezes para alcançar todas as cidades.
Apesar de soarem semelhantes, não há um algoritmo que execute em
tempo razoável para encontrar uma solução para o problema do CaixeiroViajante, para um número arbitrário de cidades. Os algoritmos genéticos
mostram que podem encontrar soluções que não são ótimas, mas são
muito boas, em pouco tempo. O problema é amplamente aplicável na
distribuição e ciente de mercadorias. Por exemplo, os responsáveis pelo
despacho de caminhões da FedEx e da UPS usam softwares para resolver
o problema do Caixeiro-Viajante diariamente. Algoritmos que ajudam a
resolver o problema podem gerar economias em diversos mercados.
Na arte gerada por computador, os algoritmos genéticos às vezes são
usados para imitar fotogra as usando métodos estocásticos. Pense em 50
polígonos posicionados aleatoriamente em uma tela e gradualmente
distorcidos, girados, movidos, redimensionados e modi cados quanto à
cor, até que correspondam o máximo possível a uma fotogra a. O
resultado parecerá o trabalho de um artista abstrato ou, se formatos mais
angulares forem utilizados, se assemelhará a um vitral.
Os algoritmos genéticos fazem parte de uma área mais ampla chamada
computação evolucionária. Uma área da computação evolucionária,
intimamente relacionada aos algoritmos genéticos, é a programação
genética, na qual os programas usam as operações de seleção, crossover e
mutação para modi carem a si mesmos a m de encontrar soluções não
óbvias para problemas de programação. A programação genética não é
uma técnica amplamente usada, mas pense em um futuro em que os
programas escrevam a si mesmos.
Uma vantagem dos algoritmos genéticos é que eles permitem uma fácil
paralelização. No formato mais óbvio, cada população poderia ser
simulada em um processador distinto. Em uma forma mais granular, cada
indivíduo poderia sofrer mutação e crossover, e ter sua aptidão calculada
em uma thread separada. Há também várias possibilidades
intermediárias.
5.8 Exercícios
1. Acrescente suporte em GeneticAlgorithm para uma forma mais
so sticada de seleção por torneio que possa ocasionalmente escolher o
segundo ou o terceiro melhor cromossomo, com base em uma
probabilidade decrescente.
2. Acrescente uma nova função no framework de satisfação de restrições
do Capítulo 3, que resolva qualquer CSP arbitrário usando um
algoritmo genético. Uma possível medida de aptidão é o número de
restrições resolvidas por um cromossomo.
3. Crie uma classe BitString que implemente Chromosome. Lembre-se do
que é uma cadeia de bits revendo o Capítulo 1. Em seguida, use sua
nova classe para resolver o problema da equação simples da seção 5.3.
De que modo o problema pode ser codi cado como uma cadeia de
bits?
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
A humanidade nunca teve tantos dados sobre diferentes aspectos da
sociedade como tem atualmente. Os computadores são ótimos para
armazenar conjuntos de dados, mas esses conjuntos só terão algum valor
para a sociedade se forem analisados por seres humanos. Técnicas de
computação podem orientar as pessoas no processo de compreender um
conjunto de dados de modo que faça sentido.
O clustering (agrupamento) é uma técnica de computação que divide os
pontos de um conjunto de dados em grupos. Um clustering bemsucedido resulta em grupos que contêm pontos relacionados entre si. O
fato de esses relacionamentos serem signi cativos, em geral, exige uma
veri cação feita por seres humanos.
No clustering, o grupo (ou seja, o cluster) ao qual um ponto de dado
pertence não está predeterminado, mas é decidido durante a execução do
algoritmo de clustering. De fato, o algoritmo não é orientado a colocar
nenhum ponto de dado em particular em um cluster especí co usando
informações preconcebidas. Por esse motivo, o clustering é considerado
um método não supervisionado no domínio do aprendizado de máquina
(machine learning). Podemos pensar em não supervisionado como não
orientado por conhecimento prévio.
O clustering é uma técnica útil quando queremos saber sobre a
estrutura de um conjunto de dados, mas não conhecemos suas partes
constituintes previamente. Por exemplo, suponha que você seja o
proprietário de um mercado e colete dados sobre os clientes e suas
transações. Você quer fazer anúncios em dispositivos móveis sobre ofertas
em horários relevantes da semana a m de atrair clientes para o seu
estabelecimento. Você poderia tentar fazer um clustering de seus dados de
acordo com o dia da semana e com informações demográ cas. Talvez você
encontre um cluster que indique que compradores mais jovens pre ram
fazer compras às terças-feiras, e poderia utilizar essa informação para fazer
um anúncio visando especi camente a essa clientela nesse dia.
6.1 Informações preliminares
Nosso algoritmo de clustering exigirá algumas primitivas de estatística
(média, desvio-padrão e assim por diante). A partir da versão 3.4, a
biblioteca-padrão de Python disponibiliza várias primitivas úteis de
estatística no módulo statistics. Note que, apesar de nos atermos à
biblioteca-padrão neste livro, há outras bibliotecas de terceiros com
desempenho melhor para manipulações numéricas, como o NumPy, que
deverão ser utilizadas em aplicações nas quais o desempenho seja crítico
– especialmente aquelas que lidam com big data.
Para simpli car, os conjuntos de dados com os quais trabalharemos
neste capítulo serão todos representados com o tipo float, portanto haverá
muitas operações em listas e tuplas de floats. As primitivas de estatística
sum(), mean() e pstdev() estão de nidas na biblioteca-padrão. Suas de nições
re etem diretamente as fórmulas que você veria em um livro didático
sobre estatística. Além disso, precisaremos de uma função para calcular
escores z (z-scores).
Listagem 6.1 – kmeans.py
from __future__ import annotations
from typing import TypeVar, Generic, List, Sequence
from copy import deepcopy
from functools import partial
from random import uniform
from statistics import mean, pstdev
from dataclasses import dataclass
from data_point import DataPoint
def zscores(original: Sequence[float]) -> List[float]:
avg: float = mean(original)
std: float = pstdev(original)
if std == 0: # devolve tudo igual a zero se não houver variação
return [0] * len(original)
return [(x - avg) / std for x in original]
DICA pstdev() calcula o desvio-padrão de uma população, enquanto stdev(), que não
estamos usando, calcula o desvio-padrão de uma amostra.
zscores() converte uma sequência de números de ponto
utuante ( oats)
em uma lista de números de ponto utuante com os respectivos escores z
dos números originais relativos a todos os números da sequência original.
Falaremos mais sobre os escores z posteriormente neste capítulo.
NOTA Ensinar estatística elementar está fora do escopo deste livro, mas você não precisará de
nada além de uma compreensão rudimentar sobre média e desvio-padrão para acompanhar o
resto do capítulo. Se você já aprendeu esses conceitos há muito tempo e precisa recordá-los, ou
se nunca os viu antes, talvez valha a pena consultar algum recurso contendo informações
sobre estatística que explique esses dois conceitos fundamentais.
Todos os algoritmos de clustering trabalham com pontos de dados, e
nossa implementação de k-means não será uma exceção. De niremos
uma interface comum chamada DataPoint. Para deixar o código mais
limpo, de niremos essa interface em um arquivo próprio.
Listagem 6.2 – data_point.py
from __future__ import annotations
from typing import Iterator, Tuple, List, Iterable
from math import sqrt
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)
def distance(self, other: DataPoint) -> float:
combined: Iterator[Tuple[float, float]] = zip(self.dimensions,
other.dimensions)
differences: List[float] = [(x - y) ** 2 for x, y in combined]
return sqrt(sum(differences))
def __eq__(self, other: object) -> bool:
if not isinstance(other, DataPoint):
return NotImplemented
return self.dimensions == other.dimensions
def __repr__(self) -> str:
return self._originals.__repr__()
Todo ponto de dado deve ser comparável com outros pontos de dados do
mesmo tipo para saber se são iguais (__eq__()), e devem ser legíveis para
depuração (__repr__()). Um ponto de dado de qualquer tipo tem
determinado número de dimensões (num_dimensions). A tupla dimensions
armazena os valores propriamente ditos de cada uma dessas dimensões na
forma de floats. O método __init__() aceita um iterável de valores para as
dimensões necessárias. Essas dimensões poderão ser substituídas mais
tarde por escores z pelo k-means, portanto manteremos também uma
cópia dos dados iniciais em _originals para exibir posteriormente.
Uma última informação preliminar de que precisamos antes de explorar
o k-means é um modo de calcular a distância entre dois pontos de dados
quaisquer do mesmo tipo. Há várias maneiras de calcular a distância,
porém a mais comum utilizada com o k-means é a distância euclidiana.
Essa é a fórmula de distância que a maioria das pessoas conhece no curso
de geometria do ensino médio, e que pode ser derivada do teorema de
Pitágoras. Na verdade, já discutimos a fórmula e criamos uma versão dela
para espaços bidimensionais no Capítulo 2, quando a utilizamos para
calcular a distância entre duas posições quaisquer em um labirinto. Nosso
DataPoint exige uma versão mais so sticada, pois um DataPoint pode
envolver qualquer quantidade de dimensões.
Essa versão de distance() é particularmente compacta, e funcionará com
tipos DataPoint com qualquer quantidade de dimensões. A chamada a zip()
cria tuplas preenchidas com pares de cada dimensão dos dois pontos,
combinados em uma sequência. A list comprehension calcula a diferença
entre cada ponto de cada dimensão e eleva esse valor ao quadrado. sum()
soma todos esses valores, e o valor nal devolvido por distance() é a raiz
quadrada dessa soma.
6.2 Algoritmo de clustering k-means
O k-means é um algoritmo de clustering que tenta agrupar pontos de
dados em um certo número prede nido de clusters com base na distância
relativa de cada ponto até o centro do cluster. Em cada rodada do kmeans, a distância entre cada ponto de dado e o centro de cada cluster
(um ponto conhecido como centroide) é calculada. Os pontos são
atribuídos ao cluster de cujo centroide eles estiverem mais próximos. Em
seguida, o algoritmo recalcula todos os centroides, encontrando a média
dos pontos atribuídos a cada cluster e substituindo o antigo centroide
pela nova média. O processo de atribuir pontos e recalcular centroides
continua até que os centroides parem de se mover ou determinado
número de iterações ocorra.
Cada dimensão dos pontos iniciais fornecidos ao k-means deve ser
comparável em magnitude. Se não for, o k-means apresentará uma
distorção e fará um clustering com base em dimensões com as maiores
diferenças. O processo de deixar diferentes tipos de dados (em nosso caso,
diferentes dimensões) comparáveis é conhecido como normalização. Um
modo comum de normalizar dados é avaliar cada valor com base em seu
escore z (também conhecido como escore padrão) relativo aos demais
valores do mesmo tipo. Calcula-se um escore z tomando um valor,
subtraindo a média de todos os valores e dividindo esse resultado pelo
desvio-padrão de todos os valores. A função zscores() apresentada
próximo ao início da seção anterior faz exatamente isso para cada valor
em um iterável de floats.
A principal di culdade com o k-means é decidir como atribuir os
centroides iniciais. Na forma básica do algoritmo, que é a que
implementaremos, os centroides iniciais serão posicionados de modo
aleatório dentro da faixa dos dados. Outra di culdade é decidir em
quantos clusters os dados devem ser divididos (o “k” do k-means). No
algoritmo clássico, esse número é determinado pelo usuário, que pode ou
não saber qual é o número correto, e isso exigirá alguns experimentos.
Deixaremos que o usuário de na “k”.
Reunindo todos esses passos e considerações, eis o nosso algoritmo de
clustering k-means:
1. Inicialize todos os pontos de dados e “k” clusters vazios.
2. Normalize todos os pontos de dados.
3. Crie centroides aleatórios associados a cada cluster.
4. Atribua cada ponto de dado ao cluster de cujo centroide ele estiver
mais próximo.
5. Recalcule cada centroide de modo que esteja no centro (média) do
cluster ao qual ele está associado.
6. Repita os passos 4 e 5 até que um número máximo de iterações tenha
sido alcançado ou os centroides parem de se mover (haja uma
convergência).
Conceitualmente, o k-means é bem simples: a cada iteração, cada ponto
de dado é associado ao cluster de cujo centro ele está mais próximo. Esse
centro se move à medida que novos pontos são associados ao cluster. A
Figura 6.1 mostra isso.
Figura 6.1 – Um exemplo do k-means executando por três gerações em um
conjunto de dados arbitrário. As estrelas representam os centroides. As cores
e as formas representam os membros do cluster no momento (com
mudanças).
Implementaremos uma classe para manter os estados e executar o
algoritmo, de modo semelhante ao GeneticAlgorithm do Capítulo 5. Vamos
agora voltar para o arquivo kmeans.py.
Listagem 6.3 – Continuação de kmeans.py
Point = TypeVar('Point', bound=DataPoint)
class KMeans(Generic[Point]):
@dataclass
class Cluster:
points: List[Point]
centroid: DataPoint
KMeans é uma classe genérica. Ela funciona com DataPoint ou com qualquer
subclasse de DataPoint, conforme de nido pelo bound do tipo Point. Ela tem
uma classe interna, Cluster, que mantém o controle dos clusters
individuais na operação. Cada Cluster tem pontos de dados e um centroide
associado.
Prosseguiremos agora com o método __init__() da classe externa.
Listagem 6.4 – Continuação de kmeans.py
def __init__(self, k: int, points: List[Point]) -> None:
if k < 1: # k-means não trabalha com clusters negativos ou iguais a zero
raise ValueError("k must be >= 1")
self._points: List[Point] = points
self._zscore_normalize()
# inicializa clusters vazios com centroides aleatórios
self._clusters: List[KMeans.Cluster] = []
for _ in range(k):
rand_point: DataPoint = self._random_point()
cluster: KMeans.Cluster = KMeans.Cluster([], rand_point)
self._clusters.append(cluster)
@property
def _centroids(self) -> List[DataPoint]:
return [x.centroid for x in self._clusters]
KMeans tem um array _points associado. Esse array representa todos os
pontos do conjunto de dados. Os pontos são posteriormente divididos
entre os clusters, que são armazenados na variável devidamente nomeada
como _clusters. Quando KMeans é instanciada, ela precisa saber quantos
clusters deverá criar (k). Todo cluster tem inicialmente um centroide
aleatório. Todos os pontos de dados que serão usados no algoritmo são
normalizados pelo escore z. A propriedade _centroids calculada devolve
todos os centroides associados aos clusters no algoritmo.
Listagem 6.5 – Continuação de kmeans.py
def _dimension_slice(self, dimension: int) -> List[float]:
return [x.dimensions[dimension] for x in self._points]
_dimension_slice() é um método auxiliar que podemos imaginar como um
método que devolve uma coluna de dados. Ele devolverá uma lista
composta de todos os valores de um índice em particular, de cada ponto
de dado. Por exemplo, se os pontos de dados forem do tipo DataPoint,
_dimension_ slice(0) devolveria uma lista do valor da primeira dimensão de
cada ponto de dado. Isso será útil no método de normalização a seguir.
Listagem 6.6 – Continuação de kmeans.py
def _zscore_normalize(self) -> None:
zscored: List[List[float]] = [[] for _ in range(len(self._points))]
for dimension in range(self._points[0].num_dimensions):
dimension_slice: List[float] = self._dimension_slice(dimension)
for index, zscore in enumerate(zscores(dimension_slice)):
zscored[index].append(zscore)
for i in range(len(self._points)):
self._points[i].dimensions = tuple(zscored[i])
_zscore_normalize() substitui os valores na tupla dimensions de cada ponto de
dado pelo seu escore z equivalente. Esse método utiliza a função zscores()
que de nimos antes para sequências de float. Embora os valores na tupla
dimensions sejam substituídos, o mesmo não ocorre na tupla _originals de
DataPoint. Isso é conveniente; o usuário do algoritmo ainda poderá
recuperar os valores originais das dimensões, anteriores à normalização,
após a execução do algoritmo, se esses estiverem armazenados nos dois
lugares.
Listagem 6.7 – Continuação de kmeans.py
def _random_point(self) -> DataPoint:
rand_dimensions: List[float] = []
for dimension in range(self._points[0].num_dimensions):
values: List[float] = self._dimension_slice(dimension)
rand_value: float = uniform(min(values), max(values))
rand_dimensions.append(rand_value)
return DataPoint(rand_dimensions)
O método _random_point() anterior é usado no método __init__() para criar
os centroides iniciais aleatórios para cada cluster. Ele limita os valores
aleatórios de cada ponto de modo que estejam no intervalo de valores dos
pontos de dados existentes. O método utiliza o construtor que
especi camos antes em DataPoint para criar um ponto a partir de um
iterável de valores.
Veremos agora o nosso método para encontrar o cluster apropriado ao
qual um ponto de dado pertence.
Listagem 6.8 – Continuação de kmeans.py
# Encontra o centroide de cluster mais próximo de cada ponto
# e atribui o ponto a esse cluster
def _assign_clusters(self) -> None:
for point in self._points:
closest: DataPoint = min(self._centroids, key=partial(DataPoint.distance,
point))
idx: int = self._centroids.index(closest)
cluster: KMeans.Cluster = self._clusters[idx]
cluster.points.append(point)
Ao longo do livro, criamos diversas funções que encontram o mínimo ou
o máximo em uma lista. Essa função não é diferente. Nesse caso, estamos
procurando o centroide do cluster que tenha a distância mínima para
cada ponto individual. O ponto é então atribuído a esse cluster. O único
detalhe intrincado é o uso de uma função partial() para a key de min().
partial() aceita uma função e lhe fornece alguns de seus parâmetros antes
que essa função seja aplicada. Nesse caso, fornecemos o método
DataPoint.distance() com o ponto a partir do qual estamos fazendo o
cálculo, como seu parâmetro other. Isso resultará no cálculo da distância
de cada centroide até o ponto, e o centroide com a menor distância será
devolvido por min().
Listagem 6.9 – Continuação de kmeans.py
# Encontra o centro de cada cluster e desloca o centroide para esse ponto
def _generate_centroids(self) -> None:
for cluster in self._clusters:
if len(cluster.points) == 0: # mantém o mesmo centroide se não houver
pontos
continue
means: List[float] = []
for dimension in range(cluster.points[0].num_dimensions):
dimension_slice: List[float] = [p.dimensions[dimension] for p in
cluster.points]
means.append(mean(dimension_slice))
cluster.centroid = DataPoint(means)
Depois que cada ponto é atribuído a um cluster, os novos centroides são
calculados. Isso envolve calcular a média de cada dimensão de todos os
pontos do cluster. As médias de cada dimensão são então combinadas a
m de determinar o “ponto médio” do cluster, que passa a ser o novo
centroide. Observe que não podemos usar _dimension_slice() nesse local,
pois os pontos em questão são um subconjunto de todos os pontos
(apenas aqueles que pertencem a um cluster em particular). De que modo
_dimension_slice() poderia ser reescrito para que seja mais genérico?
Vamos observar agora o método que executará realmente o algoritmo.
Listagem 6.10 – Continuação de kmeans.py
def run(self, max_iterations: int = 100) -> List[KMeans.Cluster]:
for iteration in range(max_iterations):
for cluster in self._clusters: # limpa todos os clusters
cluster.points.clear()
self._assign_clusters() # encontra o cluster do qual cada ponto está mais
próximo
old_centroids: List[DataPoint] = deepcopy(self._centroids) # registra
self._generate_centroids() # encontra os novos centroides
if old_centroids == self._centroids: # os centroides se moveram?
print(f"Converged after {iteration} iterations")
return self._clusters
return self._clusters
run() é a expressão mais pura do algoritmo original. A única mudança no
algoritmo que você poderia achar inesperada é a remoção de todos os
pontos no início de cada iteração. Se isso não ocorresse, o método
_assign_clusters(), do modo como foi escrito, acabaria colocando pontos
duplicados em cada cluster.
Execute um teste rápido usando DataPoints de teste e k de nido com 2.
Listagem 6.11 – Continuação de kmeans.py
if __name__ == "__main__":
point1: DataPoint = DataPoint([2.0, 1.0, 1.0])
point2: DataPoint = DataPoint([2.0, 2.0, 5.0])
point3: DataPoint = DataPoint([3.0, 1.5, 2.5])
kmeans_test: KMeans[DataPoint] = KMeans(2, [point1, point2, point3])
test_clusters: List[KMeans.Cluster] = kmeans_test.run()
for index, cluster in enumerate(test_clusters):
print(f"Cluster {index}: {cluster.points}")
Como há aleatoriedade envolvida, seus resultados poderão variar. O
resultado esperado será algo na linha exibida a seguir:
Converged after 1 iterations
Cluster 0: [(2.0, 1.0, 1.0), (3.0, 1.5, 2.5)]
Cluster 1: [(2.0, 2.0, 5.0)]
6.3 Clustering de governadores por idade e longitude
Todo estado norte-americano tem um governador. Em junho de 2017,
esses governadores tinham idades que variavam de 42 a 79 anos. Se
considerarmos os Estados Unidos de leste para oeste e observarmos cada
estado de acordo com sua longitude, talvez possamos encontrar clusters
de estados com longitudes semelhantes e governadores com idades
semelhantes. A Figura 6.2 exibe um grá co de dispersão com todos os 50
governadores. O eixo x representa a longitude dos estados, e o eixo y
contém a idade dos governadores.
Há clusters evidentes na Figura 6.2? Nessa gura, os eixos não estão
normalizados. Estamos observando dados brutos. Se os clusters fossem
sempre óbvios, não haveria necessidade de algoritmos de clustering.
Figura 6.2 – Governadores dos estados, em junho de 2017, representados de
acordo com a longitude dos estados e a idade dos governadores.
Vamos experimentar submeter esse conjunto de dados ao k-means. Em
primeiro lugar, precisaremos de um modo de representar um ponto de
dado individual.
Listagem 6.12 – governors.py
from __future__ import annotations
from typing import List
from data_point import DataPoint
from kmeans import KMeans
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})"
Um Governor tem duas dimensões nomeadas e armazenadas: longitude e age.
Afora isso, Governor não faz nenhuma modi cação no funcionamento de
sua superclasse, DataPoint, exceto sobrescrever __repr__() para uma exibição
elegante. Seria pouco razoável inserir os dados a seguir manualmente,
portanto, faça um checkout do repositório do código-fonte que
acompanha este livro.
Listagem 6.13 – Continuação de governors.py
if __name__ == "__main__":
governors: List[Governor] =
[Governor(-86.79113, 72, "Alabama"), Governor(-152.404419, 66, "Alaska"),
Governor(-111.431221, 53, "Arizona"), Governor(-92.373123, 66, "Arkansas"),
Governor(-119.681564, 79, "California"), Governor(-105.311104, 65,
"Colorado"),
Governor(-72.755371, 61, "Connecticut"), Governor(-75.507141, 61,
"Delaware"),
Governor(-81.686783, 64, "Florida"), Governor(-83.643074, 74, "Georgia"),
Governor(-157.498337, 60, "Hawaii"), Governor(-114.478828, 75, "Idaho"),
Governor(-88.986137, 60, "Illinois"), Governor(-86.258278, 49, "Indiana"),
Governor(-93.210526, 57, "Iowa"), Governor(-96.726486, 60, "Kansas"),
Governor(-84.670067, 50, "Kentucky"), Governor(-91.867805, 50, "Louisiana"),
Governor(-69.381927, 68, "Maine"), Governor(-76.802101, 61, "Maryland"),
Governor(-71.530106, 60, "Massachusetts"), Governor(-84.536095, 58,
"Michigan"),
Governor(-93.900192, 70, "Minnesota"), Governor(-89.678696, 62,
"Mississippi"),
Governor(-92.288368, 43, "Missouri"), Governor(-110.454353, 51, "Montana"),
Governor(-98.268082, 52, "Nebraska"), Governor(-117.055374, 53, "Nevada"),
Governor(-71.563896, 42, "New Hampshire"), Governor(-74.521011, 54, "New
Jersey"),
Governor(-106.248482, 57, "New Mexico"), Governor(-74.948051, 59, "New
York"),
Governor(-79.806419, 60, "North Carolina"), Governor(-99.784012, 60, "North
Dakota"),
Governor(-82.764915, 65, "Ohio"), Governor(-96.928917, 62, "Oklahoma"),
Governor(-122.070938, 56, "Oregon"), Governor(-77.209755, 68,
"Pennsylvania"),
Governor(-71.51178, 46, "Rhode Island"), Governor(-80.945007, 70, "South
Carolina"),
Governor(-99.438828, 64, "South Dakota"), Governor(-86.692345, 58,
"Tennessee"),
Governor(-97.563461, 59, "Texas"), Governor(-111.862434, 70, "Utah"),
Governor(-72.710686, 58, "Vermont"), Governor(-78.169968, 60, "Virginia"),
Governor(-121.490494, 66, "Washington"), Governor(-80.954453, 66, "West
Virginia"),
Governor(-89.616508, 49, "Wisconsin"), Governor(-107.30249, 55, "Wyoming")]
Executaremos o k-means com k de nido com 2.
Listagem 6.14 – Continuação de governors.py
kmeans: KMeans[Governor] = KMeans(2, governors)
gov_clusters: List[KMeans.Cluster] = kmeans.run()
for index, cluster in enumerate(gov_clusters):
print(f"Cluster {index}: {cluster.points}\n")
Como o algoritmo começa com centroides aleatórios, cada execução de
KMeans poderá devolver clusters distintos. É necessário um pouco de análise
por parte de um ser humano para ver se os clusters são realmente
relevantes. O resultado a seguir é de uma execução que apresentou
clusters interessantes:
Converged after 5 iterations
Cluster 0: [Alabama: (longitude: -86.79113, age: 72), Arizona: (longitude:
-111.431221, age: 53), Arkansas: (longitude: -92.373123, age: 66), Colorado:
(longitude: -105.311104, age: 65), Connecticut: (longitude: -72.755371, age:
61), Delaware: (longitude: -75.507141, age: 61), Florida: (longitude:
-81.686783, age: 64), Georgia: (longitude: -83.643074, age: 74), Illinois:
(longitude: -88.986137, age: 60), Indiana: (longitude: -86.258278, age: 49),
Iowa: (longitude: -93.210526, age: 57), Kansas: (longitude: -96.726486, age:
60), Kentucky: (longitude: -84.670067, age: 50), Louisiana: (longitude:
-91.867805, age: 50), Maine: (longitude: -69.381927, age: 68), Maryland:
(longitude: -76.802101, age: 61), Massachusetts: (longitude: -71.530106, age:
60), Michigan: (longitude: -84.536095, age: 58), Minnesota: (longitude:
-93.900192, age: 70), Mississippi: (longitude: -89.678696, age: 62),
Missouri: (longitude: -92.288368, age: 43), Montana: (longitude: -110.454353,
age: 51), Nebraska: (longitude: -98.268082, age: 52), Nevada: (longitude:
-117.055374, age: 53), New Hampshire: (longitude: -71.563896, age: 42), New
Jersey: (longitude: -74.521011, age: 54), New Mexico: (longitude:
-106.248482, age: 57), New York: (longitude: -74.948051, age: 59), North
Carolina: (longitude: -79.806419, age: 60), North Dakota: (longitude:
-99.784012, age: 60), Ohio: (longitude: -82.764915, age: 65), Oklahoma:
(longitude: -96.928917, age: 62), Pennsylvania: (longitude: -77.209755, age:
68), Rhode Island: (longitude: -71.51178, age: 46), South Carolina:
(longitude: -80.945007, age: 70), South Dakota: (longitude: -99.438828, age:
64), Tennessee: (longitude: -86.692345, age: 58), Texas: (longitude:
-97.563461, age: 59), Vermont: (longitude: -72.710686, age: 58), Virginia:
(longitude: -78.169968, age: 60), West Virginia: (longitude: -80.954453, age:
66), Wisconsin: (longitude: -89.616508, age: 49), Wyoming: (longitude:
-107.30249, age: 55)]
Cluster 1: [Alaska: (longitude: -152.404419, age: 66), California: (longitude:
-119.681564, age: 79), Hawaii: (longitude: -157.498337, age: 60), Idaho:
(longitude: -114.478828, age: 75), Oregon: (longitude: -122.070938, age: 56),
Utah: (longitude: -111.862434, age: 70), Washington: (longitude: -121.490494,
age: 66)]
O Cluster 1 representa os estados mais a oeste, todos geogra camente
próximos (se você considerar que o Alasca e o Havaí estão próximos dos
estados da costa do Pací co). Todos eles têm governadores relativamente
mais velhos e, desse modo, formaram um cluster interessante. As pessoas
da costa do Pací co gostam de governadores mais velhos? Não é possível
determinar nada conclusivo a partir desses clusters, além de uma
correlação. A Figura 6.3 exibe o resultado. Os quadrados são do cluster 1,
e os círculos, do cluster 0.
Figura 6.3 – Pontos de dados no cluster 0 estão representados por círculos,
enquanto pontos de dados do cluster 1 estão representados por quadrados.
DICA Nunca é demais enfatizar que seus resultados com o k-means usando inicialização
aleatória de centroides vai variar. Não se esqueça de executar o k-means várias vezes com
qualquer conjunto de dados.
6.4 Clustering de álbuns do Michael Jackson por tamanho
Michael Jackson lançou dez álbuns solo gravados em estúdio. No
exemplo a seguir, faremos o clustering desses álbuns observando duas
dimensões: duração do álbum (em minutos) e o número de faixas
musicais. Esse exemplo apresenta um contraste interessante com o
exemplo anterior dos governadores porque é fácil ver os clusters no
conjunto de dados originais, sem sequer executar o k-means. Um exemplo
como esse pode ser uma boa maneira de depurar uma implementação de
um algoritmo de clustering.
NOTA Os dois exemplos deste capítulo fazem uso de pontos de dados bidimensionais, mas o
k-means é capaz de trabalhar com pontos de dados com qualquer quantidade de dimensões.
O exemplo será apresentado por completo nesta seção, como uma única
listagem de código. Se você observar os dados sobre os álbuns na listagem
de código a seguir, mesmo antes de executar o exemplo, estará claro que
Michael Jackson gravou álbuns mais longos em direção ao nal de sua
carreira. Assim, os dois clusters de álbuns provavelmente serão divididos
entre os primeiros e os últimos álbuns. HIStory: Past, Present, and Future,
Book I é um dado discrepante, e poderá, sem dúvida, acabar em seu
próprio cluster único. Um dado discrepante é um ponto de dado que está
fora dos limites usuais de um conjunto de dados.
Listagem 6.15 – mj.py
from __future__ import annotations
from typing import List
from data_point import DataPoint
from kmeans import KMeans
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
def __repr__(self) -> str:
return f"{self.name}, {self.year}"
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]
As médias geradas nos clusters são interessantes. Observe que as médias
são escores z. Os três álbuns do Cluster 1, isto é, os três últimos álbuns de
Michael Jackson, foram aproximadamente um desvio-padrão maiores do
que a média de todos os seus dez álbuns solo.
6.5 Problemas e extensões do clustering k-means
Quando o clustering k-means é implementado com pontos de início
aleatórios, ele pode deixar totalmente de perceber pontos de divisão
importantes nos dados. Com frequência, isso resulta em muitas tentativas
e erros para o operador. Descobrir o valor correto de “k” (o número de
clusters) também é difícil e suscetível a erros caso o operador não tenha
um bom insight acerca da quantidade de grupos de dados que deve haver.
Há versões mais so sticadas do k-means, capazes de dar palpites bem
fundamentados ou efetuar tentativas e erros automaticamente no que
concerne a essas variáveis problemáticas. Uma variante conhecida é o kmeans++, que tenta resolver o problema da inicialização escolhendo
centroides com base em uma distribuição de probabilidades para a
distância até cada ponto, em vez de usar apenas a aleatoriedade. Uma
opção melhor ainda para muitas aplicações é escolher boas regiões de
partida para cada um dos centroides, com base em informações sobre os
dados que sejam conhecidas previamente – em outras palavras, uma
versão do k-means em que o usuário do algoritmo escolhe os centroides
iniciais.
O tempo de execução do clustering k-means é proporcional ao número
de pontos de dados, de clusters e de dimensões dos pontos de dados. Ele
poderá se tornar inviável em sua forma básica se houver um número
elevado de pontos com muitas dimensões. Há extensões que tentam não
fazer tantos cálculos entre cada ponto e cada centro, avaliando se um
ponto de fato tem o potencial de se mover para outro cluster, antes de
fazer o cálculo. Outra opção para conjuntos de dados com muitos pontos
ou muitas dimensões é submeter apenas uma amostragem dos pontos de
dados ao k-means. Isso gerará uma aproximação dos clusters que o
algoritmo k-means completo encontraria.
Dados discrepantes em um conjunto de dados podem gerar resultados
inusitados com o k-means. Se um centroide inicial estiver, por acaso,
próximo de um valor discrepante, um cluster de um só elemento poderia
ser formado (como poderia possivelmente acontecer com o álbum
HIStory no exemplo com Michael Jackson). O k-means talvez tenha uma
melhor execução se os dados discrepantes forem removidos.
Por m, a média nem sempre é considerada uma boa medida do centro.
Os k-medians (k-medianas) observam a mediana de cada dimensão, e os
k-medoids utilizam um ponto do conjunto de dados como o centro de
cada cluster. Há explicações estatísticas que estão além do escopo deste
livro para escolher cada um desses métodos de centralização, mas o bom
senso diz que, para um problema complicado, pode valer a pena testar
cada um deles e obter uma amostragem do resultado. As implementações
de cada um desses métodos não são muito diferentes.
6.6 Aplicações no mundo real
O clustering, em geral, está no domínio dos cientistas de dados e dos
analistas de estatística. É amplamente usado como uma forma de
interpretar dados em diversos campos. O clustering k-means, em
particular, é uma técnica útil quando sabemos pouco sobre a estrutura do
conjunto de dados.
Em análise de dados, o clustering é uma técnica essencial. Pense em um
departamento de polícia que queira saber em que lugar deve colocar os
policiais em patrulhamento. Pense em uma franquia de fast food que
queira descobrir onde estão seus melhores clientes, para lhes enviar
promoções. Pense em um locador de barcos que queira minimizar os
acidentes analisando quando estes ocorrem e quem os causa. Agora
imagine como eles poderiam resolver seus problemas usando clustering.
O clustering ajuda no reconhecimento de padrões. Um algoritmo de
clustering é capaz de detectar um padrão que não é percebido pelo olhar
humano. Por exemplo, o clustering às vezes é usado em biologia para
identi car grupos de células incongruentes.
Em reconhecimento de imagens, o clustering ajuda a identi car traços
não óbvios. Pixels individuais podem ser tratados como pontos de dados,
com os relacionamentos entre si de nidos pela distância e pela diferença
de cores.
Em ciências políticas, o clustering é ocasionalmente utilizado para
encontrar eleitores visados. Um partido político poderia encontrar
eleitores sem partido concentrados em um único distrito no qual ele
deveria concentrar o dinheiro de sua campanha? Em quais problemas
eleitores semelhantes teriam mais chances de estar interessados?
6.7 Exercícios
1. Crie uma função capaz de importar dados de um arquivo CSV para
DataPoints.
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
Redes neurais relativamente simples
Quando ouvimos falar dos avanços da inteligência arti cial atualmente –
no nal dos anos 2010 –, em geral, eles dizem respeito a uma subárea
especí ca conhecida como aprendizado de máquina (machine learning,
isto é, computadores que aprendem algumas informações novas sem que
lhes tenham sido ditas explicitamente). Com muita frequência, esses
avanços se devem a uma técnica especí ca de aprendizado de máquina,
conhecida como redes neurais (neural networks). Apesar de ter sido
inventada há décadas, as redes neurais têm passado por uma espécie de
renascimento, uma vez que hardwares melhores e técnicas recémdescobertas de software, resultantes de pesquisas, possibilitam a
existência de um novo paradigma conhecido como aprendizagem
profunda (deep learning).
A aprendizagem profunda passou a ser uma técnica amplamente
aplicável. Ela tem se mostrado útil em tudo, de algoritmos para fundos
hedge a bioinformática. Duas aplicações da aprendizagem profunda com
as quais os usuários passaram a ter familiaridade são o reconhecimento
de imagens e o reconhecimento de fala. Se você já perguntou como está o
clima ao seu assistente digital ou já fez um programa de fotos reconhecer
o seu rosto, é provável que alguma dose de aprendizagem profunda
estivesse envolvida.
Técnicas de aprendizagem profunda fazem uso dos mesmos blocos de
construção utilizados pelas redes neurais mais simples. Neste capítulo,
exploraremos esses blocos construindo uma rede neural simples. Não será
o estado da arte, mas você verá os fundamentos para entender a
aprendizagem profunda (que é baseada em redes neurais mais complexas
do que a rede que construiremos). A maioria dos pro ssionais que
trabalham com aprendizado de máquina não constrói redes neurais do
zero. Em vez disso, eles utilizam frameworks populares prontos,
extremamente otimizados, que fazem o trabalho pesado. Embora este
capítulo não o ensine a usar nenhum framework especí co, e a rede que
construiremos não vá ser útil para nenhuma aplicação real, ele ajudará
você a entender como esses frameworks funcionam no nível básico.
7.1 Base biológica?
O cérebro humano é o dispositivo computacional mais incrível que existe.
Ele não é capaz de processar números de modo tão rápido quanto um
microprocessador, mas sua capacidade de se adaptar a novas situações,
adquirir novas habilidades e ser criativo ainda não foi superada por
nenhuma máquina conhecida. Desde o surgimento dos computadores, os
cientistas têm se interessado em modelar o sistema de funcionamento do
cérebro. Cada célula nervosa do cérebro é conhecida como neurônio. Os
neurônios do cérebro estão interligados em rede, por meio de conexões
conhecidas como sinapses. A eletricidade passa pelas sinapses
alimentando essas redes de neurônios – também conhecidas como redes
neurais.
NOTA A descrição anterior dos neurônios biológicos é uma simpli cação grosseira visando a
uma analogia. Na verdade, os neurônios biológicos têm outras partes, como axônios,
dendritos e núcleos, que você deve se lembrar de ter visto na disciplina de biologia do ensino
médio. Na verdade, as sinapses são lacunas entre os neurônios, onde neurotransmissores se
formam para permitir que esses sinais elétricos sejam transmitidos.
Embora os cientistas tenham identi cado as partes e as funções dos
neurônios, os detalhes de como as redes neurais biológicas formam
padrões de pensamento complexos ainda não foram bem compreendidos.
Como elas processam informações? Como compõem pensamentos
originais? A maior parte de nosso conhecimento sobre o funcionamento
do cérebro resulta da observação em nível macro. Imagens de ressonância
magnética funcional do cérebro mostram os pontos em que o sangue ui
quando um ser humano está fazendo alguma atividade em particular ou
está pensando em algo especí co (exibido na Figura 7.1). Essa e outras
técnicas no nível macro podem levar a inferências sobre como as diversas
partes estão conectadas, mas não explicam os mistérios de como os
neurônios individuais contribuem para o desenvolvimento de novos
pensamentos.
Equipes de cientistas em todo o mundo se apressam para desvendar os
segredos do cérebro; todavia, considere o seguinte: o cérebro humano tem
aproximadamente 100 bilhões de neurônios, e cada um deles pode ter
conexões com até dezenas de milhares de outros neurônios. Até mesmo
para um computador com bilhões de portas lógicas e terabytes de
memória, seria impossível modelar um único cérebro humano com a
tecnologia atual. Os seres humanos provavelmente continuarão sendo as
entidades de aprendizagem de propósito geral mais so sticadas no futuro
próximo.
Figura 7.1 – Um pesquisador estudando imagens de ressonância magnética
funcional do cérebro. Essas imagens não nos dizem muito sobre o
funcionamento de neurônios individuais ou como as redes neurais estão
organizadas. (Domínio público, U.S. National Institute for Mental Health
[Instituto Nacional de Saúde Mental dos Estados Unidos]).
NOTA Uma máquina de aprendizagem de propósito geral equivalente aos seres humanos em
capacidade é o objetivo da chamada IA forte (strong AI) – também conhecida como
inteligência arti cial genérica (arti cial general intelligence). Neste ponto da história, ainda é
assunto de cção cientí ca. A IA fraca (weak IA) é o tipo de IA que vemos no cotidiano:
computadores resolvendo tarefas especí cas para as quais foram pré-con gurados, de modo
inteligente.
Se as redes neurais biológicas não foram totalmente compreendidas, por
que as modelar é uma técnica computacional e caz? Apesar de as redes
neurais digitais, conhecidas como redes neurais arti ciais, se inspirarem
nas redes neurais biológicas, é na inspiração que as semelhanças
terminam. As redes neurais arti ciais modernas não alegam que
funcionam como suas contrapartidas biológicas. De fato, isso seria
impossível, pois, para começar, não compreendemos totalmente de que
modo as redes neurais biológicas funcionam.
7.2 Redes neurais arti ciais
Nesta seção, veremos o que é, sem dúvida, o tipo mais comum de rede
neural arti cial: uma rede feedforward com retropropagação
(backpropagation) – o mesmo tipo de rede que implementaremos mais
adiante. Feedforward signi ca que o sinal, de modo geral, se move em
uma única direção na rede. Retropropagação signi ca que
determinaremos os erros no nal do percurso de cada sinal pela rede e
tentaremos distribuir correções para esses erros de volta na rede, afetando
particularmente os neurônios que foram mais responsáveis por eles. Há
vários outros tipos de redes neurais arti ciais, e este capítulo talvez faça
você se interessar em explorá-los melhor.
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.
Figura 7.4 – Processo pelo qual o delta de um neurônio de saída é calculado
durante a fase de retropropagação do treinamento.
A Figura 7.5 mostra o cálculo propriamente dito dos deltas para os
neurônios das camadas ocultas. Em uma rede com várias camadas
ocultas, os neurônios O1, O2 e O3 poderiam ser os neurônios da próxima
camada oculta, e não da camada de saída.
Figura 7.5 – Como um delta é calculado para um neurônio em uma camada
oculta.
Por m, o mais importante é que os pesos de todos os neurônios da
rede devem ser atualizados multiplicando-se a última entrada do peso de
cada indivíduo pelo delta do neurônio e por algo chamado taxa de
aprendizagem, e somando-se esse valor ao peso atual. Esse método de
modi car o peso de um neurônio é conhecido como gradiente
descendente. É como descer uma colina, representando a função de erro
do neurônio em direção a um ponto de erro mínimo. O delta representa a
direção que queremos seguir, e a taxa de aprendizagem afeta a velocidade
com que seguimos. É difícil determinar o que seria uma boa taxa de
aprendizagem para um problema desconhecido se não houver tentativas e
erros. A Figura 7.6 mostra como cada peso da camada oculta e da camada
de saída é atualizado.
Figura 7.6 – Os pesos de cada neurônio da camada oculta e da camada de
saída são atualizados usando os deltas calculados nos passos anteriores, os
pesos anteriores, as entradas anteriores e uma taxa de aprendizagem de nida
pelo usuário.
Depois que os pesos forem atualizados, a rede neural estará pronta para
novo treinamento, com outra entrada e outra saída esperada. Esse
processo se repetirá até que a rede seja considerada bem treinada pelo
usuário da rede neural. Isso pode ser determinado testando a rede com
entradas cujas saídas corretas sejam conhecidas.
A retropropagação é complicada. Não se preocupe caso ainda não tenha
compreendido todos os detalhes. A explicação que está nesta seção talvez
não seja su ciente. A implementação da retropropagação deverá levar a
sua compreensão ao próximo patamar. Durante a implementação de
nossa rede neural e da retropropagação, tenha em mente a ideia geral a
seguir: a retropropagação é uma forma de ajustar o peso de cada
indivíduo da rede de acordo com a sua responsabilidade por uma saída
incorreta.
7.2.4 Visão geral
Abordamos vários assuntos nesta seção. Mesmo que os detalhes ainda
não façam sentido, é importante ter em mente as principais ideias de uma
rede feedforward com retropropagação:
• Os sinais (números de ponto utuante) se movem pelos neurônios
organizados em camadas em uma só direção. Todo neurônio de
qualquer camada está conectado a todos os neurônios da próxima
camada.
• Todo neurônio (exceto na camada de entrada) processa os sinais que
recebe combinando-os com os pesos (que também são números de
ponto utuante) e aplicando uma função de ativação.
• Durante um processo chamado treinamento, as saídas da rede são
comparadas com as saídas esperadas a m de calcular erros.
• Os erros são propagados para trás na rede (de volta, em direção ao
ponto de partida) para que os pesos sejam modi cados e,
consequentemente, haja mais chances de saídas corretas serem
geradas.
Há outros métodos para treinamento de redes neurais além daquele
explicado neste capítulo. Há também várias maneiras diferentes pelas
quais os sinais podem se mover pelas redes neurais. O método explicado
neste capítulo, e que será implementado, é apenas uma forma
particularmente comum, que servirá como uma introdução razoável. O
Apêndice B lista outros recursos para conhecer melhor as redes neurais
(incluindo outros tipos) e a matemática envolvida.
7.3 Informações preliminares
As redes neurais utilizam recursos matemáticos que exigem muitas
operações com números de ponto utuante. Antes de desenvolver as
estruturas para a nossa rede neural simples, precisaremos de algumas
primitivas de matemática. Essas primitivas simples serão intensamente
utilizadas no código a seguir, portanto, se você encontrar meios de agilizálas, sua rede neural terá realmente um ganho de desempenho.
AVISO A complexidade do código deste capítulo, sem dúvida, é maior do que de qualquer
outro capítulo do livro. Há muitas construções no código, e os resultados propriamente ditos
serão vistos apenas no nal. Vários textos sobre redes neurais podem ajudar você a construir
uma rede com poucas linhas de código; contudo, este exemplo tem como objetivo explorar o
funcionamento de uma rede e como os diferentes componentes atuam juntos, de uma forma
legível e expansível. Essa é a nossa meta, ainda que o código acabe cando um pouco mais
extenso e expressivo.
7.3.1 Produto escalar
Como você deve se lembrar, os produtos escalares são necessários tanto
na fase de feedforward como de retropropagação. Felizmente, um produto
escalar é fácil de implementar usando as funções embutidas de Python
zip() e sum(). Manteremos nossas funções preliminares em um arquivo
util.py.
Listagem 7.1 – util.py
from typing import List
from math import exp
# produto escalar de dois vetores
def dot_product(xs: List[float], ys: List[float]) -> float:
return sum(x * y for x, y in zip(xs, ys))
7.3.2 Função de ativação
Lembre-se de que a função de ativação transforma a saída de um
neurônio antes que o sinal seja passado para a próxima camada (veja a
Figura 7.2). A função de ativação tem duas nalidades: permite que a rede
neural represente soluções que não sejam apenas transformações lineares
(desde que a própria função de ativação não seja apenas uma
transformação linear), e consegue manter a saída de cada neurônio dentro
de determinada faixa. Uma função de ativação deve ter uma derivada
calculável para que essa seja usada na retropropagação.
Funções sigmoides são um conjunto popular de funções de ativação. A
Figura 7.7 mostra uma função sigmoide particularmente conhecida
(muitas vezes chamada de “a função sigmoide”) – referenciada na gura
como S(x) –, junto com a sua equação e a derivada (S’(x)). O resultado da
função sigmoide será sempre um valor entre 0 e 1. Ter o valor
consistentemente entre 0 e 1 é conveniente para a rede, conforme veremos.
Em breve, você verá as fórmulas da gura traduzidas no código.
Figura 7.7 – A função de ativação sigmoide (S(x)) sempre devolverá um valor
entre 0 e 1. Observe que é fácil calcular também a sua derivada (S’(x)).
Há outras funções de ativação, mas utilizaremos a função sigmoide. Eis
uma conversão direta das fórmulas da Figura 7.7 para o código:
Listagem 7.2 – Continuação de util.py
# a clássica função de ativação sigmoide
def sigmoid(x: float) -> float:
return 1.0 / (1.0 + exp(-x))
def derivative_sigmoid(x: float) -> float:
sig: float = sigmoid(x)
return sig * (1 - sig)
7.4 Construindo a rede
Criaremos classes para modelar todas as três unidades organizacionais da
rede: neurônios, camadas e a própria rede. Para simpli car, começaremos
pela menor unidade (os neurônios), passaremos para o componente
central da organização (as camadas) e implementaremos a unidade maior
(a rede toda). À medida que avançarmos do componente menor para o
maior, encapsularemos o nível anterior. Os neurônios só conhecem a si
mesmos. As camadas conhecem os neurônios que elas contêm e outras
camadas. E a rede conhece todas as camadas.
NOTA Há várias linhas de código longas neste capítulo, que quase não cabem nos limites das
colunas de um livro impresso. Recomendo que você faça o download do código-fonte deste
capítulo a partir do repositório de códigos-fontes do livro e acompanhe na tela de seu
computador
à
medida
que
o
ler:
https://github.com/
davecom/ClassicComputerScienceProblemsInPython.
7.4.1 Implementando os neurônios
Vamos começar com um neurônio. Um neurônio individual armazenará
várias informações de estado, incluindo seus pesos, seu delta, sua taxa de
aprendizagem, um cache de sua última saída e sua função de ativação,
além da derivada dessa função. Alguns desses elementos poderiam ser
armazenados de modo mais e ciente um nível acima (na futura classe
Layer), mas foram incluídos na classe Neuron para demonstração.
Listagem 7.3 – neuron.py
from typing import List, Callable
from util import dot_product
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)
A maior parte desses parâmetros é inicializada no método __init__().
Como delta e output_cache não são conhecidos quando um Neuron é criado,
eles são simplesmente inicializados com 0. Todas as variáveis do neurônio
são mutáveis. Na vida de um neurônio (de acordo com o modo como o
usaremos), seus valores talvez jamais mudem, mas há um motivo para
deixá-los mutáveis: a exibilidade. Se essa classe Neuron for usada com
outros tipos de redes neurais, é possível que alguns desses valores sejam
alterados durante a execução. Há redes neurais que alteram a taxa de
aprendizagem como parte das abordagens para a solução, e que tentam
diferentes funções de ativação automaticamente. Em nosso caso,
procuraremos manter a classe Neuron exível ao máximo para outras
aplicações de redes neurais.
O único método além de __init__() é output(). output() aceita os sinais de
entrada (inputs) que chegam até o neurônio e aplica a fórmula discutida
antes neste capítulo (veja a Figura 7.2). Os sinais de entrada são
combinados com os pesos por meio de um produto escalar, e esse
resultado é armazenado em output_cache. Lembre-se de que, conforme
vimos na seção sobre retropropagação, esse valor, obtido antes de a função
de ativação ter sido aplicada, é usado para calcular o delta. Por m, antes
de enviar o sinal para a próxima camada (o valor é devolvido por output()),
a função de ativação é aplicada.
É isso! Um neurônio individual nessa rede é razoavelmente simples. Ele
não faz muita coisa, além de aceitar um sinal de entrada, transformá-lo e
enviá-lo para ser processado adiante. O neurônio mantém vários
elementos para armazenar estados, que serão usados por outras classes.
7.4.2 Implementando as camadas
Uma camada de nossa rede deverá manter três informações de estado:
seus neurônios, a camada que a antecede e um cache de saída. O cache de
saída é semelhante ao cache de um neurônio, porém um nível acima. Ele
armazena as saídas (após as funções de ativação terem sido aplicadas) de
cada neurônio da camada.
No momento da criação, a principal responsabilidade de uma camada é
inicializar seus neurônios. O método __init__() de nossa classe Layer, desse
modo, precisa saber quantos neurônios devem ser inicializados, quais são
as funções de ativação e quais são as taxas de aprendizagem. Nessa rede
simples, todos os neurônios de uma camada têm a mesma função de
ativação e a mesma taxa de aprendizagem.
Listagem 7.4 – layer.py
from __future__ import annotations
from typing import List, Callable, Optional
from random import random
from neuron import Neuron
from util import dot_product
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)]
À medida que os sinais avançarem pela rede, Layer deverá processá-los por
intermédio de cada neurônio. (Lembre-se de que cada neurônio em uma
camada recebe os sinais de todos os neurônios da camada anterior.)
outputs() faz exatamente isso. O método também devolve o resultado do
processamento dos neurônios (a ser passado pela rede para a próxima
camada) e armazena a saída em cache. Se não houver uma camada
anterior, é sinal de que a camada é uma camada de entrada, e ela apenas
passará os sinais para a frente, para a próxima camada.
Listagem 7.5 – Continuação de layer.py
def outputs(self, inputs: List[float]) -> List[float]:
if self.previous_layer is None:
self.output_cache = inputs
else:
self.output_cache = [n.output(inputs) for n in self.neurons]
return self.output_cache
Há dois tipos distintos de delta para calcular na retropropagação: deltas
para neurônios da camada de saída e deltas para neurônios das camadas
ocultas. As fórmulas estão descritas nas guras 7.4 e 7.5, e os dois métodos
a seguir são traduções diretas dessas fórmulas. Mais tarde, esses métodos
serão chamados pela rede durante a retropropagação.
Listagem 7.6 – Continuação de layer.py
# deve ser chamado somente na camada de saída
def calculate_deltas_for_output_layer(self, expected: List[float]) -> None:
for n in range(len(self.neurons)):
self.neurons[n].delta = self.neurons[n].derivative_activation_function(
self.neurons[n].output_cache) * (expected[n] - self.output_cache[n])
# não deve ser chamado na camada de saída
def calculate_deltas_for_hidden_layer(self, next_layer: Layer) -> None:
for index, neuron in enumerate(self.neurons):
next_weights: List[float] = [n.weights[index] for n in
next_layer.neurons]
next_deltas: List[float] = [n.delta for n in next_layer.neurons]
sum_weights_and_deltas: float = dot_product(next_weights, next_deltas)
neuron.delta = neuron.derivative_activation_function(neuron.output_cache)
* sum_weights_and_deltas
7.4.3 Implementando a rede
A rede em si tem apenas uma informação de estado: as camadas que ela
administra. A classe Network é responsável por inicializar as camadas que a
compõem.
O método __init__() aceita uma lista de ints que descreve a estrutura da
rede. Por exemplo, a lista [2, 4, 3] descreve uma rede com 2 neurônios em
sua camada de entrada, 4 neurônios em sua camada oculta e 3 neurônios
em sua camada de saída. Nessa rede simples, partiremos do pressuposto
de que todas as camadas da rede farão uso da mesma função de ativação
para seus neurônios e terão a mesma taxa de aprendizagem.
Listagem 7.7 – network.py
from __future__ import annotations
from typing import List, Callable, TypeVar, Tuple
from functools import reduce
from layer import Layer
from util import sigmoid, derivative_sigmoid
T = TypeVar('T') # tipo da saída para interpretação da rede neural
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)
O método backpropagate() é responsável por calcular deltas para todos os
neurônios da rede. Ele utiliza os métodos calculate_deltas_for_output_layer()
e calculate_deltas_for_hidden_layer() de Layer, em sequência. (Lembre-se de
que, na retropropagação, os deltas são calculados na ordem inversa.) Os
valores esperados de saída para um dado conjunto de entrada são
passados para calculate_deltas_for_output_layer(). Esse método utiliza os
valores esperados para calcular o erro usado no cálculo dos deltas.
Listagem 7.9 – Continuação de network.py
# Calcula as mudanças em cada neurônio com base nos erros da saída
# em comparação com a saída esperada
def backpropagate(self, expected: List[float]) -> None:
# calcula delta para os neurônios da camada de saída
last_layer: int = len(self.layers) - 1
self.layers[last_layer].calculate_deltas_for_output_layer(expected)
# calcula delta para as camadas ocultas na ordem inversa
for l in range(last_layer - 1, 0, -1):
self.layers[l].calculate_deltas_for_hidden_layer(self.layers[l + 1])
backpropagate() é responsável pelo cálculo de todos os deltas, mas, na
verdade, ele não modi ca nenhum dos pesos da rede. update_weights() deve
ser chamado após backpropagate() porque a modi cação dos pesos
dependerá dos deltas. Esse método é diretamente derivado da fórmula
que está na Figura 7.6.
Listagem 7.10 – Continuação de network.py
# backpropagate() não modifica realmente nenhum peso;
# esta função utiliza os deltas calculados em backpropagate() para
# fazer as modificações nos pesos
def update_weights(self) -> None:
for layer in self.layers[1:]: # ignora a camada de entrada
for neuron in layer.neurons:
for w in range(len(neuron.weights)):
neuron.weights[w] = neuron.weights[w] + (neuron.learning_rate
* (layer.previous_layer.output_cache[w]) * neuron.delta)
Os pesos dos neurônios são modi cados no nal de cada rodada do
treinamento. Os conjuntos para treinamento (as entradas, junto com as
saídas esperadas) devem ser fornecidos à rede. O método train() aceita
uma lista de listas de entrada e uma lista de listas de saídas esperadas.
Cada entrada é submetida à rede e, então, os pesos são atualizados
chamando backpropagate() com a saída esperada (e chamando
update_weights() depois). Experimente acrescentar código para exibir a taxa
de erros enquanto a rede processa um conjunto de treinamento a m de
ver como ela reduz gradualmente sua taxa de erros à medida que desce
em gradiente decrescente.
Listagem 7.11 – Continuação de network.py
# train() usa os resultados de outputs(), obtidos a partir de várias entradas e
# comparados com expecteds, para fornecer a backpropagate() e a update_weights()
def train(self, inputs: List[List[float]], expecteds: List[List[float]]) -> None:
for location, xs in enumerate(inputs):
ys: List[float] = expecteds[location]
outs: List[float] = self.outputs(xs)
self.backpropagate(ys)
self.update_weights()
Por
m, depois do treinamento de uma rede, é necessário testá-la.
validate() aceita entradas e saídas esperadas (não é diferente de train()),
mas usa esses dados para calcular uma porcentagem exata, em vez de
efetuar um treinamento. Supõe-se que a rede já esteja treinada. validate()
também aceita uma função, interpret_output(), que é usada para interpretar
a saída da rede neural e compará-la com a saída esperada. Talvez a saída
esperada seja uma string como “anfíbio” em vez de um conjunto de
números de ponto utuante.) interpret_output() deve usar os números de
ponto utuante obtidos como saída da rede e convertê-los em algo que
seja comparável com as saídas esperadas. Será uma função personalizada,
especí ca para um conjunto de dados. validate() devolve o número de
classi cações corretas, o número total de amostras testadas e a
porcentagem de classi cações corretas.
Listagem 7.12 – Continuação de network.py
# para resultados genéricos que exijam classificação,
# esta função devolverá o número de tentativas corretas
# e a porcentagem delas em relação ao total
def validate(self, inputs: List[List[float]], expecteds: List[T],
interpret_output: Callable[[List[float]], T]) -> Tuple[int, int, float]:
correct: int = 0
for input, expected in zip(inputs, expecteds):
result: T = interpret_output(self.outputs(input))
if result == expected:
correct += 1
percentage: float = correct / len(inputs)
return correct, len(inputs), percentage
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.
7.5 Problemas de classi cação
No Capítulo 6, classi camos um conjunto de dados com o clustering kmeans, sem usar nenhuma noção preconcebida sobre o grupo ao qual
cada dado pertenceria. No clustering, sabemos que queremos descobrir
categorias de dados, mas não sabemos com antecedência quais são essas
categorias. Em um problema de classi cação, também tentamos classi car
um conjunto de dados, mas há categorias prede nidas. Por exemplo, se
estivéssemos tentando classi car um conjunto de imagens de animais,
poderíamos decidir previamente quais são as categorias, por exemplo,
mamíferos, répteis, anfíbios, peixes e aves.
Há várias técnicas de aprendizado de máquina que podem ser usadas
para resolver problemas de classi cação. Talvez você já tenha ouvido falar
de máquinas de suporte de vetores (support vector machines), árvores de
decisão ou classi cadores Naive Bayes. (Há outros também.)
Recentemente, as redes neurais têm sido amplamente utilizadas na área de
classi cação. Elas exigem mais processamento em comparação com
outros algoritmos de classi cação, mas sua capacidade de classi car tipos
aparentemente arbitrários de dados faz delas uma técnica e caz. Os
classi cadores baseados em redes neurais são responsáveis por muitas das
classi cações de imagens interessantes, usadas por softwares modernos de
fotogra a.
Por que há um interesse renovado no uso de redes neurais para
problemas de classi cação? O hardware tem se tornado su cientemente
rápido, a ponto de fazer com que o processamento extra envolvido, em
comparação com outros algoritmos, faça com que as vantagens
compensem.
7.5.1 Normalizando dados
Os conjuntos de dados com os quais queremos trabalhar, em geral,
exigem alguma “limpeza” antes de servirem como entrada para os nossos
algoritmos. A limpeza pode envolver remoção de caracteres irrelevantes,
eliminação de duplicatas, correção de erros e outras tarefas menores. A
tarefa relacionada à limpeza que teremos de executar nos dois conjuntos
de dados com os quais trabalharemos é a normalização. No Capítulo 6,
zemos isso com o método zscore_normalize() da classe KMeans. A
normalização diz respeito a converter atributos registrados em diferentes
escalas em uma escala comum.
Todo neurônio em nossa rede gera valores entre 0 e 1 como resultado da
função de ativação sigmoide. Parece lógico que uma escala entre 0 e 1
faria sentido para os atributos de nosso conjunto de dados de entrada
também. Converter uma escala em determinado intervalo para um
intervalo entre 0 e 1 não chega a ser um desa o. Para qualquer valor V em
um intervalo especí co de atributos, com um valor máximo igual a max e
um valor mínimo igual a min, basta usar a fórmula newV = (oldV - min) / (max min). Essa operação é conhecida como feature scaling (normalização de
características). Eis uma implementação Python que deve ser
acrescentada em util.py.
Listagem 7.13 – Continuação de util.py
# supõe que todas as linhas têm o mesmo tamanho
# e faz o feature scaling de cada coluna para que esteja no intervalo de 0 a 1
def normalize_by_feature_scaling(dataset: List[List[float]]) -> None:
for col_num in range(len(dataset[0])):
column: List[float] = [row[col_num] for row in dataset]
maximum = max(column)
minimum = min(column)
for row_num in range(len(dataset)):
dataset[row_num][col_num] = (dataset[row_num][col_num]
- minimum) / (maximum - minimum)
Observe o parâmetro dataset. É uma referência a uma lista de listas que
será modi cada in-place. Em outras palavras, normalize_by_feature_scaling()
não recebe uma cópia do conjunto de dados, mas uma referência ao
conjunto de dados original. Essa é uma situação na qual queremos fazer
alterações em um valor, em vez de receber de volta uma cópia
transformada.
Observe também que nosso programa pressupõe que os conjuntos de
dados são listas bidimensionais de floats.
7.5.2 Conjunto clássico de dados de amostras de íris
Assim como há problemas clássicos em ciência da computação, há
conjuntos de dados clássicos em aprendizado de máquina. Esses
conjuntos de dados são usados para validar novas técnicas e compará-las
com as técnicas existentes. Também servem como bons pontos de partida
para pessoas que estão conhecendo o que é aprendizado de máquina.
Talvez o conjunto mais famoso seja aquele que contém dados da planta
íris. Originalmente coletado nos anos 1930, o conjunto de dados é
composto de 150 amostras de uma planta chamada íris (são ores
bonitas), separadas em três espécies diferentes (50 de cada). Cada planta é
avaliada segundo quatro atributos distintos: comprimento da sépala,
largura da sépala, comprimento da pétala e largura da pétala.
Vale a pena mencionar que uma rede neural não se preocupa com o que
os vários atributos representam. Seu modelo de treinamento não faz
nenhuma distinção entre o comprimento da sépala e o comprimento da
pétala no que concerne à importância. Se uma distinção como essa fosse
necessária, caberia ao usuário da rede neural fazer os ajustes apropriados.
O repositório de código-fonte que acompanha este livro contém um
arquivo separado por vírgulas (CSV) com o conjunto de dados de
amostras de íris.2 O conjunto de dados de íris é do UCI Machine
Learning Repository da Universidade da Califórnia: M. Lichman, UCI
Machine Learning Repository (Irvine, CA: University of California,
School
of
Information
and
Computer
Science,
2013),
http://archive.ics.uci.edu/ml. Um arquivo CSV é simplesmente um arquivo-
texto com valores separados por vírgulas. É um formato de intercâmbio
comum para dados de tabela, incluindo planilhas.
Eis algumas linhas de iris.csv:
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
Cada linha representa um ponto de dado. Os quatro números
representam os quatro atributos (comprimento da sépala, largura da
sépala, comprimento da pétala, largura da pétala), os quais, novamente,
são arbitrários para nós no que diz respeito ao que de fato representam. O
nome no nal de cada linha representa a espécie de íris em particular.
Todas as cinco linhas são da mesma espécie porque essa amostra foi
extraída do início do arquivo, e as três espécies estão agrupadas, com 50
linhas para cada uma.
Para ler o arquivo CSV de disco, usaremos algumas funções da
biblioteca-padrão de Python. O módulo csv nos ajudará a ler os dados de
forma estruturada. A função embutida open() cria um objeto arquivo que
é passado para csv.reader(). Afora essas poucas linhas, o resto da listagem
de código a seguir apenas reorganiza os dados do arquivo CSV a m de
prepará-los para serem consumidos pela nossa rede, para treinamento e
validação.
Listagem 7.14 – iris_test.py
import csv
from typing import List
from util import normalize_by_feature_scaling
from network import Network
from random import shuffle
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)
iris_parameters representa a coleção de quatro atributos por amostra, que
estamos usando para classi car cada amostra de íris. iris_classifications é
a classi cação propriamente dita de cada amostra. Nossa rede neural terá
três neurônios de saída, cada um representando uma espécie possível. Por
exemplo, um conjunto nal de saída igual a [0.9, 0.3, 0.1] representará
uma classi cação como iris-setosa, pois o primeiro neurônio representa
essa espécie e contém o maior número.
Para treinamento, já sabemos as respostas corretas, portanto, cada
amostra de íris tem uma resposta prede nida. Para uma or que deva ser
iris-setosa, a entrada em iris_classifications será [1.0, 0.0, 0.0]. Esses
valores serão usados para calcular o erro após cada passo do treinamento.
iris_species corresponde diretamente à classi cação de cada or em forma
textual. Uma iris-setosa estará marcada como “Iris-setosa” no conjunto de
dados.
AVISO A ausência de código para veri cação de erros deixa esse código bastante perigoso. Ele
não é apropriado para um ambiente de produção do modo como está, porém é apropriado
para testes.
Vamos de nir a rede neural.
Listagem 7.15 – Continuação de iris_test.py
iris_network: Network = Network([4, 6, 3], 0.3)
O argumento layer_structure especi ca uma rede com três camadas (uma
camada de entrada, uma camada oculta e uma camada de saída) com [4,
6, 3]. A camada de entrada tem quatro neurônios, a camada oculta tem
seis neurônios e a camada de saída tem três neurônios. Os quatro
neurônios da camada de entrada são diretamente mapeados aos quatro
parâmetros usados para classi car cada espécime. Os três neurônios da
camada de saída mapeiam-se diretamente às três espécies diferentes de
acordo com as quais estamos tentando classi car cada entrada. Os seis
neurônios da camada oculta resultam mais de tentativa e erro do que de
alguma fórmula. Isso vale também para learning_rate. Podemos fazer
experimentos com esses dois valores (o número de neurônios da camada
oculta e a taxa de aprendizagem) caso a precisão da rede esteja abaixo de
um nível ideal.
Listagem 7.16 – Continuação de iris_test.py
def iris_interpret_output(output: List[float]) -> str:
if max(output) == output[0]:
return "Iris-setosa"
elif max(output) == output[1]:
return "Iris-versicolor"
else:
return "Iris-virginica"
iris_interpret_output() é uma função utilitária que será passada para o
método validate() da rede a m de ajudar a identi car as classi cações
corretas.
A rede nalmente está pronta para o treinamento.
Listagem 7.17 – Continuação de iris_test.py
# faz o treinamento com os 140 primeiros dados de amostras de íris do conjunto,
50 vezes
iris_trainers: List[List[float]] = iris_parameters[0:140]
iris_trainers_corrects: List[List[float]] = iris_classifications[0:140]
for _ in range(50):
iris_network.train(iris_trainers, iris_trainers_corrects)
Fizemos o treinamento com os primeiros 140 dados de amostras de íris,
dos 150 do conjunto. Lembre-se de que as linhas lidas do arquivo CSV
foram embaralhadas. Isso garante que, sempre que o programa for
executado, faremos o treinamento em um subconjunto diferente do
conjunto de dados. Observe que zemos o treinamento em 140 dados de
amostras de íris, 50 vezes. Modi car esse valor terá um efeito signi cativo
no tempo que demora para fazer o treinamento da rede neural. Em geral,
quanto mais treinamento, maior será a precisão no funcionamento da
rede. O último teste será conferir se a classi cação dos 10 últimos dados
de amostras de íris do conjunto está correta.
Listagem 7.18 – Continuação de iris_test.py
# teste nos últimos 10 dados de amostras de íris do conjunto
iris_testers: List[List[float]] = iris_parameters[140:150]
iris_testers_corrects: List[str] = iris_species[140:150]
iris_results = iris_network.validate(iris_testers, iris_testers_corrects,
iris_interpret_output)
print(f"{iris_results[0]} correct of {iris_results[1]} = {iris_results[2] *
100}%")
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%
7.5.3 Classi cando vinhos
Testaremos nossa rede neural com outro conjunto de dados: um conjunto
baseado na análise química de vinhos de cultivares da Itália.3 Há 178
amostras no conjunto de dados. O modo de trabalhar com esse conjunto
será muito parecido com a forma como trabalhamos com o conjunto de
dados da planta de íris, mas o layout do arquivo CSV é um pouco
diferente. Eis uma amostra desse arquivo:
1,14.23,1.71,2.43,15.6,127,2.8,3.06,.28,2.29,5.64,1.04,3.92,1065
1,13.2,1.78,2.14,11.2,100,2.65,2.76,.26,1.28,4.38,1.05,3.4,1050
1,13.16,2.36,2.67,18.6,101,2.8,3.24,.3,2.81,5.68,1.03,3.17,1185
1,14.37,1.95,2.5,16.8,113,3.85,3.49,.24,2.18,7.8,.86,3.45,1480
1,13.24,2.59,2.87,21,118,2.8,2.69,.39,1.82,4.32,1.04,2.93,735
O primeiro valor em cada linha será sempre um inteiro entre 1 e 3,
representando um dos três cultivares de cujo tipo a amostra pode ser.
Observe, porém, quantos parâmetros adicionais existem para a
classi cação. No conjunto de dados de amostras de íris, havia apenas
quatro. Nesse conjunto de dados de vinho, há treze.
Nosso modelo de rede neural escalará apropriadamente. Bastará
aumentar o número de neurônios de entrada. wine_test.py é análogo a
iris_test.py, mas há algumas modi cações pequenas que deverão ser
levadas em consideração por causa dos diferentes layouts dos respectivos
arquivos.
Listagem 7.19 – wine_test.py
import csv
from typing import List
from util import normalize_by_feature_scaling
from network import Network
from random import shuffle
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)
A con guração de camadas para a rede de classi cação de vinhos precisa
de 13 neurônios de entrada, conforme já havíamos mencionado (um para
cada parâmetro). Também são necessários três neurônios de saída. (Há
três cultivares de uvas para vinho, assim como havia três espécies de íris.)
O aspecto interessante é que a rede funciona bem com menos neurônios
na camada oculta do que na camada de entrada. Uma possível explicação
intuitiva é que alguns dos parâmetros de entrada não são de fato úteis
para a classi cação, e é conveniente eliminá-los durante o processamento.
Na verdade, essa não é exatamente a explicação para o fato de menos
neurônios na camada oculta funcionar, mas é uma ideia intuitiva
interessante.
Listagem 7.20 – Continuação de wine_test.py
wine_network: Network = Network([13, 7, 3], 0.9)
Novamente, pode ser interessante fazer experimentos com um número
diferente de neurônios na camada oculta ou com uma taxa de
aprendizagem distinta.
Listagem 7.21 – Continuação de wine_test.py
def wine_interpret_output(output: List[float]) -> int:
if max(output) == output[0]:
return 1
elif max(output) == output[1]:
return 2
else:
return 3
wine_interpret_output() é análogo a iris_interpret_output(). Como não temos
nomes para os cultivares de uvas para vinho, estamos trabalhando apenas
com a atribuição de inteiros presente no conjunto original de dados.
Listagem 7.22 – Continuação de wine_test.py
# faz o treinamento nos 150 primeiros vinhos, 10 vezes
wine_trainers: List[List[float]] = wine_parameters[0:150]
wine_trainers_corrects: List[List[float]] = wine_classifications[0:150]
for _ in range(10):
wine_network.train(wine_trainers, wine_trainers_corrects)
Faremos o treinamento nas 150 primeiras amostras do conjunto de dados,
deixando as últimas 28 para validação. Faremos o treinamento 10 vezes
nas amostras – signi cativamente menos que as 50 vezes no conjunto de
dados de amostras de íris. Por qualquer que seja o motivo (talvez pelas
qualidades inatas do conjunto de dados ou por causa do ajuste de
parâmetros, como a taxa de aprendizagem e o número de neurônios
ocultos), esse conjunto de dados exige menos treinamento para alcançar
uma precisão signi cativa, em comparação com o conjunto de dados de
amostras de íris.
Listagem 7.23 – Continuação de wine_test.py
# teste nos últimos 28 vinhos do conjunto de dados
wine_testers: List[List[float]] = wine_parameters[150:178]
wine_testers_corrects: List[int] = wine_species[150:178]
wine_results = wine_network.validate(wine_testers, wine_testers_corrects,
wine_interpret_output)
print(f"{wine_results[0]} correct of {wine_results[1]} = {wine_results[2] *
100}%")
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%
7.6 Agilizando as redes neurais
As redes neurais exigem muitas operações matemáticas com
vetores/matrizes. Essencialmente, isso signi ca tomar uma lista de
números e efetuar uma operação em todos eles ao mesmo tempo.
Bibliotecas com bom desempenho para operações matemáticas
otimizadas em vetores/matrizes estão se tornando cada vez mais
importantes à medida que o aprendizado de máquina continua a permear
a nossa sociedade. Muitas dessas bibliotecas tiram proveito das GPUs
porque elas estão otimizadas para desempenhar essa função.
(Vetores/matrizes estão no coração das imagens de computador.) Uma
especi cação de biblioteca mais antiga da qual talvez você já tenha ouvido
falar é o BLAS (Basic Linear Algebra Subprograms ou Subprogramas
Básicos de Álgebra Linear). Uma implementação do BLAS está na base da
conhecida biblioteca numérica NumPy de Python.
Além da GPU, as CPUs têm extensões que podem agilizar o
processamento de vetores/matrizes. A NumPy inclui funções que fazem
uso de instruções SIMD (single instruction, multiple data, ou uma
instrução. vários dados). As instruções SIMD são instruções especiais do
microprocessador, as quais permitem que várias porções de dados sejam
processadas ao mesmo tempo. Às vezes, são chamadas de instruções de
vetor.
Diferentes microprocessadores incluem diferentes instruções SIMD. Por
exemplo, a extensão SIMD para o G4 (um processador de arquitetura
PowerPC presente nos Macs no início dos anos 2000) era conhecida como
AltiVec. Microprocessadores ARM, como aqueles que se encontram nos
iPhones, têm uma extensão conhecida como NEON. E
microprocessadores Intel modernos incluem extensões SIMD conhecidas
como MMX, SSE, SSE2 e SSE3. Felizmente, você não precisa saber quais
são as diferenças. Uma biblioteca como a NumPy selecionará
automaticamente as instruções corretas para um processamento e ciente
na arquitetura subjacente, no local em que seu programa estiver
executando.
Não é nenhuma surpresa, então, que bibliotecas de redes neurais do
mundo real (de modo diferente de nossa biblioteca simplória deste
capítulo) utilizem arrays NumPy como a estrutura de dados básica, em
vez de listas da biblioteca-padrão de Python. Contudo, elas vão mais
além. Bibliotecas Python populares para redes neurais como TensorFlow e
PyTorch não só fazem uso de instruções SIMD como também utilizam
intensamente o processamento da GPU. Como as GPUs foram
explicitamente projetadas para processamentos rápidos de vetores, isso
agiliza as redes neurais em uma ordem de magnitude, em comparação
com a execução apenas na CPU.
Vamos deixar claro o seguinte: você jamais deverá implementar
ingenuamente uma rede neural para um ambiente de produção usando
apenas a biblioteca-padrão de Python, como zemos neste capítulo. Você
deve usar uma biblioteca bem otimizada, que utilize SIMD e GPU, como
o TensorFlow. As únicas exceções seriam uma biblioteca de rede neural
projetada para ns didáticos, ou uma que tivesse de executar em um
dispositivo embarcado, sem instruções SIMD ou GPU.
7.7 Problemas e extensões das redes neurais
As redes neurais estão em efervescência atualmente, graças aos avanços
em aprendizagem profunda (deep learning), mas elas têm algumas
de ciências signi cativas. O maior problema é que uma solução com rede
neural para um problema é uma espécie de caixa-preta. Mesmo quando
funcionam bem, as redes neurais não fornecem muitos insights ao
usuário acerca de como elas resolveram o problema. Por exemplo, o
classi cador do conjunto de dados de amostras de íris com o qual
trabalhamos neste capítulo não mostra claramente quanto cada um dos
quatro parâmetros de entrada afeta a saída. O comprimento da sépala foi
mais importante que a largura para classi car cada amostra?
É possível que uma análise cuidadosa dos pesos nais da rede após o
treinamento pudesse oferecer alguns insights, mas uma análise desse tipo
não é trivial e não fornece o tipo de insight que, por exemplo, uma
regressão linear forneceria no que concerne ao signi cado de cada variável
na função sendo modelada. Em outras palavras, uma rede neural pode
resolver um problema, mas não explica como o problema é resolvido.
Outro problema com as redes neurais é que, para serem precisas, em
geral, elas exigem conjuntos de dados bem grandes. Pense em um
classi cador de imagens para paisagens. Talvez ele tivesse de classi car
milhares de diferentes tipos de imagens ( orestas, vales, montanhas,
riachos, estepes e assim por diante). É possível que a rede exigisse milhões
de imagens para treinamento. Conjuntos de dados grandes como esses
não só são difíceis de encontrar como também, para algumas aplicações,
podem ser totalmente inexistentes. A tendência é que grandes
corporações e governos é que tenham instalações técnicas e de datawarehousing (armazém de dados) para coletar e armazenar conjuntos de
dados gigantescos como esses.
Por m, as redes neurais são custosas do ponto de vista do
processamento. Como você provavelmente deve ter percebido, apenas o
treinamento com o conjunto de dados de amostras de íris pode deixar seu
interpretador Python de joelhos. Python puro não é um ambiente com
um bom desempenho quanto ao processamento (sem bibliotecas com
suporte em C, como a NumPy, pelo menos); entretanto, em qualquer
plataforma de processamento em que as redes neurais são usadas, acima
de tudo, é o enorme número de cálculos que devem ser efetuados para o
treinamento da rede que exige tanto tempo. Há truques em abundância
para fazer com que as redes neurais tenham um melhor desempenho
(como usar instruções SIMD ou GPUs), mas, em última instância, fazer o
treinamento de uma rede neural exige muitas operações com números de
ponto utuante.
Uma ressalva interessante é que, do ponto de vista do processamento, o
treinamento é muito mais custoso do que o próprio uso da rede. Algumas
aplicações não exigem um treinamento contínuo. Nesses casos, uma rede
treinada pode simplesmente ser utilizada em uma aplicação para
solucionar um problema. Por exemplo, a primeira versão do framework
Core ML da Apple nem sequer aceita treinamento. Ela apenas oferece
suporte para ajudar os desenvolvedores de aplicativos a executar modelos
de redes neurais previamente treinadas em seus aplicativos. Um
desenvolvedor de aplicativo que esteja criando um aplicativo para fotos
pode fazer download de um modelo para classi cação de imagens com
licença gratuita, colocá-lo no Core ML e começar a utilizar prontamente
um código de aprendizado de máquina com bom desempenho em um
aplicativo.
Neste capítulo, trabalhamos apenas com um único tipo de rede neural:
uma rede feedforward com retropropagação. Conforme mencionamos
antes, existem vários outros tipos de redes neurais. Redes neurais
convolucionais (convolutional neural networks) também são feedforward,
mas têm vários tipos diferentes de camadas ocultas, diferentes formas de
distribuir pesos e outras propriedades interessantes que as tornam
particularmente bem projetadas para classi cação de imagens. Nas redes
neurais recorrentes (recurrent neural networks), os sinais não trafegam em
uma só direção. Elas permitem feedback cíclicos e têm se mostrado úteis
para aplicações com entradas contínuas, como reconhecimento de escrita
cursiva e reconhecimento de fala.
Uma extensão simples para a nossa rede neural que a deixaria com um
desempenho melhor seria a inclusão de neurônios com bias
(tendenciosos). Um neurônio com bias é como um neurônio dummy em
uma camada, permitindo que a saída da próxima camada represente mais
funções fornecendo uma entrada constante (ainda modi cada por um
peso). Mesmo as redes neurais simples usadas em problemas do mundo
real em geral contêm neurônios com bias. Se neurônios com bias forem
acrescentados em nossa rede, é provável que você perceba que ela exigirá
menos treinamento para alcançar um nível de precisão similar.
7.8 Aplicações no mundo real
Embora tivessem sido inicialmente imaginadas em meados do século XX,
as redes neurais arti ciais não eram comuns até a última década. Sua
aplicação ampla foi di cultada pela falta de um hardware que tivesse um
desempenho su cientemente bom. Atualmente, as redes neurais arti ciais
passaram a ser a área de crescimento mais explosivo em aprendizado de
máquina porque elas funcionam!
Redes neurais arti ciais têm possibilitado a existência de algumas das
aplicações de computação mais empolgantes, voltadas a usuários, em
décadas. Essas aplicações incluem reconhecimento prático de fala (prático
no que diz respeito a ter precisão su ciente), reconhecimento de imagens
e reconhecimento de escrita cursiva. O reconhecimento de fala está
presente em sistemas de auxílio à digitação, como o Dragon Naturally
Speaking, e em assistentes digitais, como Siri, Alexa e Cortana. Um
exemplo especí co de reconhecimento de imagens está na atribuição de
identi cação automática do Facebook às pessoas em fotos usando
reconhecimento facial. Em versões recentes do iOS, você pode procurar
tarefas em suas notas, mesmo que elas tenham sido escritas à mão,
empregando o reconhecimento de escrita cursiva.
Uma tecnologia mais antiga de reconhecimento que pode funcionar
com base em redes neurais é o OCR (Optical Character Recognition, ou
Reconhecimento Óptico de Caracteres), usado sempre que você faz o
scanning de um documento e ele é devolvido na forma de um texto
selecionável, e não como uma imagem. O OCR permite que postos de
pedágio leiam placas dos automóveis e que envelopes sejam rapidamente
organizados pelo serviço postal.
Neste capítulo, vimos as redes neurais serem usadas com sucesso em
problemas de classi cação. Aplicações semelhantes nas quais as redes
neurais funcionam bem são os sistemas de recomendação. Pense no
Net ix sugerindo um lme a que talvez você gostasse de assistir, ou na
Amazon sugerindo um livro que você talvez quisesse ler. Há também
outras técnicas de aprendizado de máquina que funcionam bem em
sistemas de recomendações (Amazon e Net ix não usam necessariamente
as redes neurais com essas nalidades; os detalhes de seus sistemas
provavelmente são proprietários), portanto, as redes neurais só deverão
ser selecionadas depois que todas as demais opções tiverem sido
exploradas.
As redes neurais podem ser usadas em qualquer situação em que uma
função desconhecida precise de uma aproximação. Isso as torna úteis para
fazer previsões. As redes neurais podem ser empregadas para prever o
resultado de um evento esportivo, uma eleição ou o mercado de ações (e
elas são). É claro que sua precisão é resultado de quão bem treinadas elas
são, e isso está relacionado com o tamanho do conjunto de dados
disponível, que seja relevante para o evento cuja saída é desconhecida,
com quão bem ajustados estão os parâmetros da rede neural e quantas
iterações de treinamento são executadas. No que concerne a previsões,
como a maioria das aplicações de redes neurais, uma das partes mais
difíceis é decidir como será a estrutura da própria rede, a qual, em última
análise, é muitas vezes determinada por tentativa e erro.
7.9 Exercícios
1. Use o framework de rede neural desenvolvido neste capítulo para
classi car itens de outro conjunto de dados.
2. Crie uma função genérica parse_CSV(), com parâmetros
su cientemente exíveis, a ponto de ser possível fazer uma
substituição nos dois exemplos de parsing de CSV deste capítulo.
3. Experimente executar os exemplos com uma função de ativação
diferente. (Lembre-se de calcular também a sua derivada.) De que
modo a mudança na função de ativação afeta a precisão da rede? Ela
exige mais ou menos treinamento?
4. Com base nos problemas deste capítulo, recrie suas soluções usando
um framework conhecido para redes neurais, como o TensorFlow ou o
PyTorch.
5. Reescreva as classes Network, Layer e Neuron usando a NumPy para
agilizar a execução da rede neural desenvolvida neste capítulo.
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
Um jogo de informação perfeita com soma zero para dois jogadores é um
jogo no qual os dois adversários têm todas as informações sobre o estado
do jogo à disposição, e qualquer ganho de vantagem para um implica
uma perda de vantagem para o outro. Jogos como esses incluem jogo da
velha, Connect Four, jogo de damas e xadrez. Neste capítulo, veremos
como criar um adversário arti cial que jogue esses tipos de jogos com
bastante habilidade. De fato, as técnicas discutidas neste capítulo, junto
com a moderna capacidade de processamento, permitem criar adversários
arti ciais que joguem perfeitamente esses tipos de jogos simples, além de
jogos complexos que estão além das habilidades de qualquer adversário
humano.
8.1 Componentes básicos de jogos de tabuleiro
Assim como para a maioria dos problemas mais complexos neste livro,
tentaremos deixar nossa solução o mais genérico possível. No caso da
busca competitiva (adversarial search), isso signi ca fazer com que nossos
algoritmos de busca não sejam especí cos para um jogo. Vamos começar
de nindo algumas classes-base simples que especi cam todos os estados
de que nossos algoritmos de busca precisarão. Mais tarde, podemos criar
subclasses dessas classes-base para os jogos especí cos que
implementaremos (jogo da velha e Connect Four), e passar as subclasses
para os algoritmos de busca a m de fazê-los “jogar”. Eis as classes-base:
Listagem 8.1 – board.py
from __future__ import annotations
from typing import NewType, List
from abc import ABC, abstractmethod
Move = NewType('Move', int)
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.
8.2 Jogo da velha
O jogo da velha é um jogo simples, mas pode ser usado para ilustrar o
mesmo algoritmo minimax que pode ser aplicado em jogos de estratégia
so sticados, como Connect Four, jogo de damas e xadrez. Construiremos
uma IA capaz de jogar perfeitamente o jogo da velha usando o minimax.
NOTA Nesta seção, partimos do pressuposto de que você tenha familiaridade com o jogo da
velha e suas regras padrões. Caso não tenha, uma pesquisa rápida na internet deve permitir
que você o conheça.
8.2.1 Administrando os estados do jogo da velha
Vamos desenvolver algumas estruturas para manter o controle do estado
de um jogo da velha à medida que ele se desenrola.
Inicialmente, precisamos de um modo de representar cada quadrado do
tabuleiro do jogo da velha. Usaremos um enum chamado TTTPiece, que é
uma subclasse de Piece. Uma peça do jogo da velha pode ser um X, um O
ou vazio (representado por E no enum).
Listagem 8.2 – tictactoe.py
from __future__ import annotations
from typing import List
from enum import Enum
from board import Piece, Board, Move
class TTTPiece(Piece, Enum):
X = "X"
O = "O"
E = " " # para representação de vazio
@property
def opposite(self) -> TTTPiece:
if self == TTTPiece.X:
return TTTPiece.O
elif self == TTTPiece.O:
return TTTPiece.X
else:
return TTTPiece.E
def __str__(self) -> str:
return self.value
A classe TTTPiece tem uma propriedade opposite, que devolve outro TTTPiece.
Ela será conveniente para alternar o turno de um jogador para outro após
um movimento no jogo da velha. Para representar os movimentos,
usaremos apenas um inteiro, que corresponde a um quadrado do
tabuleiro no qual uma peça é colocada. Como você deve se lembrar, Move
foi de nido como um inteiro em board.py.
Um tabuleiro de jogo da velha tem nove posições organizadas em três
linhas e três colunas. Para simpli car, essas nove posições podem ser
representadas com uma lista unidimensional. A atribuição dos quadrados
às designações numéricas (isto é, ao índice do array) é arbitrária, mas
seguiremos o esquema representado na Figura 8.1.
Figura 8.1 – Os índices da lista unidimensional que correspondem a cada
quadrado no tabuleiro do jogo da velha.
A classe principal, responsável por manter o estado do jogo, é a classe
TTTBoard. TTTBoard controla dois estados diferentes: a posição (representada
pela lista unidimensional mencionada antes) e o jogador a quem o turno
pertence.
Listagem 8.3 – Continuação de tictactoe.py
class TTTBoard(Board):
def __init__(self, position: List[TTTPiece] = [TTTPiece.E] * 9,
turn: TTTPiece = TTTPiece.X) -> None:
self.position: List[TTTPiece] = position
self._turn: TTTPiece = turn
@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; TTTBoards 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)
Um movimento permitido no jogo da velha é feito em qualquer quadrado
vazio. A propriedade a seguir, legal_moves, usa uma list comprehension a
m de gerar possíveis movimentos para uma dada posição.
Listagem 8.5 – Continuação de tictactoe.py
@property
def legal_moves(self) -> List[Move]:
return [Move(l) for l in range(len(self.position)) if self.position[l] ==
TTTPiece.E]
Os índices nos quais a list comprehension atua são índices int da lista de
posições. De modo conveniente (e proposital), um Move também é de nido
com um tipo int, permitindo que essa de nição de legal_moves seja sucinta.
Há várias maneiras de analisar as linhas, as colunas e as diagonais de
um tabuleiro de jogo da velha a m de veri car se houve uma vitória. A
implementação a seguir da propriedade is_win faz isso com uma
combinação aparentemente interminável de and, or e ==, com código xo.
Não é dos códigos mais elegantes, mas ele faz seu trabalho de modo
direto.
Listagem 8.6 – Continuação de tictactoe.py
@property
def is_win(self) -> bool:
# verificações para três linhas, três colunas e então para duas diagonais
return self.position[0] == self.position[1] and self.position[0]
== self.position[2] and self.position[0] != TTTPiece.E or \
self.position[3] == self.position[4] and self.position[3] \
== self.position[5] and self.position[3] != TTTPiece.E or \
self.position[6] == self.position[7] and self.position[6] \
== self.position[8] and self.position[6] != TTTPiece.E or \
self.position[0] == self.position[3] and self.position[0] \
== self.position[6] and self.position[0] != TTTPiece.E or \
self.position[1] == self.position[4] and self.position[1] \
== self.position[7] and self.position[1] != TTTPiece.E or \
self.position[2] == self.position[5] and self.position[2] \
== self.position[8] and self.position[2] != TTTPiece.E or \
self.position[0] == self.position[4] and self.position[0] \
== self.position[8] and self.position[0] != TTTPiece.E or \
self.position[2] == self.position[4] and self.position[2] \
== self.position[6] and self.position[2] != TTTPiece.E
Se todos os quadrados de uma linha, de uma coluna ou de uma diagonal
não estiverem vazios e contiverem a mesma peça, o jogo terá sido vencido.
Um jogo estará empatado se não for vencido e não restarem mais
movimentos permitidos; essa propriedade já foi descrita na classe-base
abstrata Board. Por m, precisamos de uma maneira de avaliar uma
posição especí ca e fazer uma exibição elegante do tabuleiro.
Listagem 8.7 – Continuação de tictactoe.py
def evaluate(self, player: Piece) -> float:
if self.is_win and self.turn == player:
return -1
elif self.is_win and self.turn != player:
return 1
else:
return 0
def __repr__(self) -> str:
return f"""{self.position[0]}|{self.position[1]}|{self.position[2]}
----{self.position[3]}|{self.position[4]}|{self.position[5]}
----{self.position[6]}|{self.position[7]}|{self.position[8]}"""
Na maioria dos jogos, a avaliação de uma posição terá de ser uma
aproximação, pois não é possível pesquisar o jogo até o m para
descobrir, com certeza, quem vai ganhar ou quem perder, dependendo
dos movimentos efetuados. Contudo, o jogo da velha tem um espaço de
busca pequeno, que pode ser pesquisado a partir de qualquer posição até
o nal. Assim, o método evaluate() pode simplesmente devolver um
número se o jogador vencer, um número pior para um empate e um
número pior ainda para uma derrota.
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
# Encontra o melhor resultado possível para o jogador inicial
def minimax(board: Board, maximizing: bool, original_player: Piece, max_depth:
int = 8) -> float:
# Caso de base – posição final ou profundidade máxima alcançada
if board.is_win or board.is_draw or max_depth == 0:
return board.evaluate(original_player)
# Caso recursivo - maximiza seus ganhos ou minimiza os ganhos do adversário
if maximizing:
best_eval: float = float("-inf") # ponto de partida arbitrariamente baixo
for move in board.legal_moves:
result: float = minimax(board.move(move), False, original_player,
max_depth - 1)
best_eval = max(result, best_eval)
return best_eval
else: # minimizando
worst_eval: float = float("inf")
for move in board.legal_moves:
result = minimax(board.move(move), True, original_player, max_depth 1)
worst_eval = min(result, worst_eval)
return worst_eval
Em cada chamada recursiva, devemos manter o controle da situação do
tabuleiro, se estamos maximizando ou minimizando e para quem
estamos tentando avaliar a posição (original_player). As primeiras linhas
de minimax() lidam com o caso de base: um nó terminal (uma vitória, uma
derrota ou um empate) ou a profundidade máxima foi alcançada. O resto
da função cuida dos casos recursivos.
Um caso recursivo é a maximização. Nessa situação, estamos
procurando um movimento que resulte na melhor avaliação possível. O
outro caso recursivo é a minimização, na qual procuramos o movimento
que resultará na pior avaliação possível. Qualquer que seja a situação, os
dois casos se alternarão até alcançarmos um estado nal ou a
profundidade máxima (caso de base).
Infelizmente, não podemos usar nossa implementação de minimax() do
modo como está para encontrar o melhor movimento para uma dada
posição. Ela devolve uma avaliação (um valor float). A função não nos
informa qual é o primeiro melhor movimento que resultou nessa
avaliação.
Criaremos uma função auxiliar, find_best_move(), que fará chamadas a
minimax() para cada movimento permitido em uma posição, a m de
encontrar o movimento cuja avaliação tenha o maior valor. Podemos
pensar em find_best_move() como a primeira chamada de maximização
para minimax(), mas na qual mantemos o controle dos movimentos iniciais.
Listagem 8.9 – Continuação de minimax.py
# Encontra o melhor movimento possível na posição atual
# observando max_depth
def find_best_move(board: Board, max_depth: int = 8) -> Move:
best_eval: float = float("-inf")
best_move: Move = Move(-1)
for move in board.legal_moves:
result: float = minimax(board.move(move), False, board.turn, max_depth)
if result > best_eval:
best_eval = result
best_move = move
return best_move
Temos tudo pronto agora para encontrar o melhor movimento possível
em qualquer situação no jogo da velha.
8.2.3 Testando o minimax com o jogo da velha
O jogo da velha é um jogo tão simples que para nós, seres humanos, é
fácil determinar o movimento correto de nitivo em uma dada posição.
Isso faz com que seja possível desenvolver testes de unidade (unit tests)
facilmente. No trecho de código a seguir, desa aremos o nosso algoritmo
minimax a encontrar o próximo movimento correto em três situações
diferentes do jogo da velha. O primeiro é fácil, e exige apenas que o
algoritmo pense no próximo movimento para uma vitória. O segundo
exige um bloqueio; a IA deve impedir que seu adversário pontue e
obtenha uma vitória. O último é um pouco mais desa ador e exige que a
IA pense em dois movimentos futuros.
Listagem 8.10 – tictactoe_tests.py
import unittest
from typing import List
from minimax import find_best_move
from tictactoe import TTTPiece, TTTBoard
from board import 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()
Todos os três
tictactoe_tests.py.
testes
deverão
passar
quando
você
executar
DICA Não é necessário muito código para implementar o minimax, e ele funcionará para
vários outros jogos além do jogo da velha. Se você planeja implementar o minimax para outro
jogo, é importante visar ao sucesso criando estruturas de dados que funcionem bem para o
modo como o minimax foi projetado, por exemplo, usando a classe Board. Um erro comum
cometido pelos alunos que estão aprendendo o minimax é usar uma estrutura de dados
modi cável que é alterada por uma chamada recursiva ao minimax, a qual não poderá ser
restaurada ao seu estado original para outras chamadas.
8.2.4 Desenvolvendo uma IA para o jogo da velha
Com todos esses ingredientes prontos, será trivial dar o próximo passo e
desenvolver um adversário totalmente arti cial, capaz de jogar o jogo da
velha por completo. Em vez de avaliar uma posição de teste, a IA
simplesmente avaliará a posição gerada por cada movimento do
adversário. No pequeno trecho de código a seguir, a IA do jogo da velha
jogará contra um adversário humano que fará o primeiro movimento:
Listagem 8.11 – tictactoe_ai.py
from minimax import find_best_move
from tictactoe import TTTBoard
from board import Move, Board
board: Board = TTTBoard()
def get_player_move() -> Move:
player_move: Move = Move(-1)
while player_move not in board.legal_moves:
play: int = int(input("Enter a legal square (0-8):"))
player_move = Move(play)
return player_move
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
Como o default de max_depth de find_best_move() é 8, essa IA de jogo da
velha sempre verá o nal do jogo. (O número máximo de movimentos no
jogo da velha é nove, e a IA joga em segundo lugar.) Assim, ela deverá
jogar todas as vezes de modo perfeito. Um jogo perfeito é aquele em que
os dois adversários fazem os melhores movimentos possíveis em cada
rodada. O resultado de um jogo da velha perfeito é um empate. Com isso
em mente, você jamais será capaz de vencer a IA do jogo da velha. Se você
jogar da melhor maneira possível, haverá um empate. Se cometer um erro,
a IA vencerá. Teste por conta própria. Você não conseguirá vencê-la.
8.3 Connect Four
No Connect Four,1 dois jogadores se alternam colocando peças de cores
distintas em um tabuleiro vertical de sete colunas e seis linhas. As peças
caem de cima para baixo no tabuleiro, até atingirem a parte inferior ou
alcançarem outra peça. Basicamente, a única decisão do jogador em cada
rodada é escolher em qual das sete colunas ele colocará uma peça. O
jogador não pode colocá-la em uma coluna cheia. O primeiro jogador que
tiver quatro peças de sua cor, uma ao lado da outra, em uma linha, coluna
ou diagonal, sem que haja lacunas, vencerá. Se nenhum jogador conseguir
isso e o tabuleiro estiver totalmente cheio, haverá um empate no jogo.
8.3.1 Peças do jogo Connect Four
Em vários aspectos, o Connect Four é parecido com o jogo da velha. Os
dois jogos usam um tabuleiro e exigem que o jogador alinhe peças para
vencer. No entanto, como o tabuleiro do Connect Four é maior e há
muito mais maneiras de vencer, a avaliação de cada posição é
signi cativamente mais complexa.
Parte do código a seguir parecerá bastante familiar, mas as estruturas de
dados e o método de avaliação são bem diferentes daqueles do jogo da
velha. Os dois jogos são implementados como subclasses da mesmas
classes-base Piece e Board que vimos no início do capítulo, possibilitando
que minimax() seja usado nos dois jogos.
Listagem 8.12 – connectfour.py
from __future__ import annotations
from typing import List, Optional, Tuple
from enum import Enum
from board import Piece, Board, Move
class C4Piece(Piece, Enum):
B = "B"
R = "R"
E = " " # para representação de vazio
@property
def opposite(self) -> C4Piece:
if self == C4Piece.B:
return C4Piece.R
elif self == C4Piece.R:
return C4Piece.B
else:
return C4Piece.E
def __str__(self) -> str:
return self.value
A classe C4Piece é quase idêntica à classe TTTPiece.
Em seguida, temos uma função para gerar todos os possíveis segmentos
vitoriosos em um tabuleiro de determinado tamanho do Connect Four.
Listagem 8.13 – Continuação de connectfour.py
def generate_segments(num_columns: int, num_rows: int, segment_length: int) ->
List[List[Tuple[int, int]]]:
segments: List[List[Tuple[int, int]]] = []
# gera os segmentos verticais
for c in range(num_columns):
for r in range(num_rows - segment_length + 1):
segment: List[Tuple[int, int]] = []
for t in range(segment_length):
segment.append((c, r + t))
segments.append(segment)
# gera os segmentos horizontais
for c in range(num_columns - segment_length + 1):
for r in range(num_rows):
segment = []
for t in range(segment_length):
segment.append((c + t, r))
segments.append(segment)
# gera os segmentos diagonais da parte inferior à esquerda para
# a parte superior à direita
for c in range(num_columns - segment_length + 1):
for r in range(num_rows - segment_length + 1):
segment = []
for t in range(segment_length):
segment.append((c + t, r + t))
segments.append(segment)
# gera os segmentos diagonais da parte superior à esquerda
# para a parte inferior à direita
for c in range(num_columns - segment_length + 1):
for r in range(segment_length - 1, num_rows):
segment = []
for t in range(segment_length):
segment.append((c + t, r - t))
segments.append(segment)
return segments
Essa função devolve uma lista de listas de posições do tabuleiro (tuplas
de combinações de colunas/linhas). Cada lista da lista contém quatro
posições do tabuleiro. Chamamos a cada uma dessas listas de quatro
posições do tabuleiro de segmento. Se algum segmento do tabuleiro tiver a
mesma cor, essa cor será a vencedora do jogo.
Ser capaz de pesquisar rapidamente todos os segmentos do tabuleiro é
conveniente tanto para veri car se um jogo terminou (alguém venceu)
como para avaliar uma posição. Assim, no trecho de código a seguir, você
perceberá que armazenamos os segmentos em cache para um dado
tamanho de tabuleiro como uma variável de classe chamada SEGMENTS na
classe C4Board.
Listagem 8.14 – Continuação de connectfour.py
class C4Board(Board):
NUM_ROWS: int = 6
NUM_COLUMNS: int = 7
SEGMENT_LENGTH: int = 4
SEGMENTS: List[List[Tuple[int, int]]] = generate_segments(NUM_COLUMNS,
NUM_ROWS, SEGMENT_LENGTH)
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
def push(self, item: C4Piece) -> None:
if self.full:
raise OverflowError("Trying to push piece to full column")
self._container.append(item)
def __getitem__(self, index: int) -> C4Piece:
if index > len(self._container) - 1:
return C4Piece.E
return self._container[index]
def __repr__(self) -> str:
return repr(self._container)
def copy(self) -> C4Board.Column:
temp: C4Board.Column = C4Board.Column()
temp._container = self._container.copy()
return temp
A classe Column é bem parecida com a classe Stack que usamos em capítulos
anteriores. Isso faz sentido, pois, do ponto de vista conceitual, durante o
jogo, uma coluna do Connect Four é uma pilha na qual podemos fazer
uma inserção, mas nunca uma remoção. De modo diferente das pilhas
anteriores, porém, uma coluna do Connect Four tem um limite absoluto
de seis itens. Também interessante é o método especial __getitem__(), que
possibilita que uma instância de Column seja acessada pelo índice. Isso
permite que uma lista de colunas seja tratada como uma lista
bidimensional. Observe que, mesmo que o _container subjacente não
contenha um item em uma linha em particular, __getitem__() devolverá
uma peça vazia.
Os próximos quatro métodos são relativamente parecidos com seus
equivalentes no jogo da velha.
Listagem 8.16 – Continuação de connectfour.py
def __init__(self, position: Optional[List[C4Board.Column]] = None,
turn: C4Piece = C4Piece.B) -> None:
if position is None:
self.position: List[C4Board.Column] = [
C4Board.Column() for _ in range(C4Board.NUM_COLUMNS)]
else:
self.position = position
self._turn: C4Piece = turn
@property
def turn(self) -> Piece:
return self._turn
def move(self, location: Move) -> Board:
temp_position: List[C4Board.Column] = self.position.copy()
for c in range(C4Board.NUM_COLUMNS):
temp_position[c] = self.position[c].copy()
temp_position[location].push(self._turn)
return C4Board(temp_position, self._turn.opposite)
@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
Assim como TTTBoard, C4Board pode usar a propriedade is_draw da classebase abstrata Board, sem modi cação.
Por m, para avaliar uma posição, avaliaremos todos os seus segmentos
representativos, um segmento de cada vez, e somaremos essas avaliações
para devolver um resultado. Um segmento que tenha peças tanto
vermelhas quanto pretas será considerado sem valor. Um segmento que
tenha duas peças da mesma cor e duas posições vazias terá uma
pontuação igual a 1 atribuída. Um segmento com três peças da mesma
cor receberá uma pontuação igual a 100. Por m, um segmento com
quatro peças da mesma cor (uma vitória) terá pontuação igual a 1.000.000.
Se o segmento for do adversário, a pontuação será negativa.
_evaluate_segment() é um método auxiliar que avalia um único segmento
utilizando a fórmula anterior. A pontuação conjunta de todos os
segmentos obtida com _evaluate_segment() será gerada por evaluate().
Listagem 8.18 – Continuação de connectfour.py
def _evaluate_segment(self, segment: List[Tuple[int, int]], player: Piece) ->
float:
black_count, red_count = self._count_segment(segment)
if red_count > 0 and black_count > 0:
return 0 # segmentos com cores misturadas são neutros
count: int = max(red_count, black_count)
score: float = 0
if count == 2:
score = 1
elif count == 3:
score = 100
elif count == 4:
score = 1000000
color: C4Piece = C4Piece.B
if red_count > black_count:
color = C4Piece.R
if color != player:
return -score
return score
def evaluate(self, player: Piece) -> float:
total: float = 0
for segment in C4Board.SEGMENTS:
total += self._evaluate_segment(segment, player)
return total
def __repr__(self) -> str:
display: str = ""
for r in reversed(range(C4Board.NUM_ROWS)):
display += "|"
for c in range(C4Board.NUM_COLUMNS):
display += f"{self.position[c][r]}" + "|"
display += "\n"
return display
8.3.2 Uma IA para o Connect Four
Por incrível que pareça, as mesmas funções minimax() e find_best_move() que
desenvolvemos para o jogo da velha poderão ser usadas sem alteração em
nossa implementação do Connect Four. No trecho de código a seguir, há
apenas duas modi cações em comparação com o código da IA para o
jogo da velha. A principal diferença é que max_depth agora está de nida
com 3. Isso permite que o tempo para o computador pensar em cada
movimento seja razoável. Em outras palavras, nossa IA para o Connect
Four analisa (avalia) posições para até três movimentos futuros.
Listagem 8.19 – connectfour_ai.py
from minimax import find_best_move
from connectfour import C4Board
from board import Move, Board
board: Board = C4Board()
def get_player_move() -> Move:
player_move: Move = Move(-1)
while player_move not in board.legal_moves:
play: int = int(input("Enter a legal column (0-6):"))
player_move = Move(play)
return player_move
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
Experimente jogar com a IA do Connect Four. Você perceberá que ela
demora alguns segundos para fazer cada movimento, de modo diferente
da IA do jogo da velha. Provavelmente, ela continuará vencendo você, a
menos que você pense com muito cuidado em seus movimentos. Pelo
menos, ela não cometerá nenhum erro óbvio. Podemos melhorar o modo
de a IA jogar aumentando a profundidade que ela pesquisa, mas cada
movimento do computador exigirá um tempo exponencialmente maior
para ser calculado.
DICA Você sabia que o Connect Four foi “resolvido” por cientistas da área de computação?
Resolver um jogo signi ca saber qual é o melhor movimento a ser feito em qualquer posição.
O primeiro melhor movimento no Connect Four consiste em colocar sua peça na coluna do
meio.
8.3.3 Aperfeiçoando o minimax com a poda alfa-beta
O minimax funciona bem, mas não temos uma busca com muita
profundidade no momento. Há uma pequena extensão para o minimax,
conhecida como poda alfa-beta (alpha-beta pruning), capaz de melhorar a
profundidade da busca excluindo posições que não resultarão em
melhorias em relação às posições já analisadas. Essa mágica é feita
mantendo-se o controle de dois valores entre chamadas recursivas do
minimax: alfa e beta. Alfa representa a avaliação do melhor movimento
maximizador encontrado até esse ponto na árvore de busca, enquanto
beta representa a avaliação do melhor movimento minimizador
encontrado até então para o adversário. Se beta for menor ou igual a alfa,
não valerá a pena explorar esse ramo da busca, pois um movimento
melhor ou equivalente já foi encontrado, em comparação ao que será
encontrado mais adiante nesse ramo. Essa heurística decrementa
signi cativamente o espaço de busca.
A seguir, apresentamos uma função alphabeta() conforme acabamos de
descrevê-la. Ela deve ser inserida em nosso arquivo minimax.py atual.
Listagem 8.20 – Continuação de minimax.py
def alphabeta(board: Board, maximizing: bool, original_player: Piece, max_depth:
int = 8, alpha: float = float("-inf"), beta: float = float("inf")) -> float:
# Caso de base – posição final ou profundidade máxima alcançada
if board.is_win or board.is_draw or max_depth == 0:
return board.evaluate(original_player)
# Caso recursivo - maximiza seus ganhos ou minimiza os ganhos do adversário
if maximizing:
for move in board.legal_moves:
result: float = alphabeta(board.move(move), False,
original_player, max_depth - 1, alpha, beta)
alpha = max(result, alpha)
if beta <= alpha:
break
return alpha
else: # minimizando
for move in board.legal_moves:
result = alphabeta(board.move(move), True, original_player,
max_depth - 1, alpha, beta)
beta = min(result, beta)
if beta <= alpha:
break
return beta
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.4 Melhorias no minimax além da poda alfa-beta
Os algoritmos apresentados neste capítulo foram intensamente estudados,
e várias melhorias foram identi cadas ao longo dos anos. Algumas dessas
melhorias são especí cas de um jogo, como as “bitboards” no xadrez, que
reduzem o tempo necessário para gerar movimentos permitidos; no
entanto, a maioria são técnicas genéricas, que podem ser utilizadas em
qualquer jogo.
Uma técnica comum é o aprofundamento iterativo. No aprofundamento
iterativo, inicialmente a função de busca é executada com uma
profundidade máxima de 1. Em seguida, é executado com uma
profundidade máxima de 2. Posteriormente, é executado com uma
profundidade máxima de 3, e assim sucessivamente. Quando um limite
de tempo especi cado for alcançado, a busca é interrompida. O resultado
da última profundidade concluída será devolvido.
Os exemplos deste capítulo usaram uma profundidade xa no código.
Isso é razoável quando o jogo não tem um relógio ou um limite de tempo,
ou se não nos importarmos com o tempo que o computador demora para
pensar. Um aprofundamento iterativo permite que uma IA demore um
tempo xo para calcular seu próximo movimento, em vez de ter um valor
de profundidade de busca xo com um tempo variável para completar a
busca.
Outra possível melhoria é a busca quiescente (quiescence search). Nessa
técnica, a árvore de busca do minimax será expandida por caminhos que
causam grandes mudanças de posição (capturas no xadrez, por exemplo),
em vez de caminhos que tenham posições relativamente “quietas”. Dessa
forma, não haverá desperdício de tempo de processamento com posições
inócuas na busca, que tenham poucas chances de dar uma vantagem
signi cativa ao jogador.
As duas melhores maneiras de melhorar a busca com o minimax é fazer
buscas em uma profundidade maior no tempo reservado, ou melhorar a
função de avaliação usada para veri car uma posição. Pesquisar mais
posições no mesmo intervalo de tempo exige gastar menos tempo com
cada posição. Isso pode resultar da escrita de um código mais e ciente ou
do uso de um hardware mais rápido, mas também pode ser uma
consequência da última técnica de aperfeiçoamento: melhorar a avaliação
de cada posição. Usar mais parâmetros ou dados heurísticos para avaliar
uma posição pode demorar mais, porém, em última análise, pode resultar
em uma engine melhor, que exija uma profundidade de busca menor
para identi car um bom movimento.
Algumas funções de avaliação usadas na busca minimax com a poda
alfa-beta no xadrez têm dezenas de heurísticas. Até mesmo algoritmos
genéticos têm sido usados para ajuste dessas heurísticas. Até que ponto a
captura de um cavalo compensa em um jogo de xadrez? Deveria valer
mais que um bispo? Essas heurísticas podem ser o ingrediente secreto que
distingue uma ótima engine de xadrez de outra que seja apenas boa.
8.5 Aplicações no mundo real
O minimax, em conjunto com outras extensões, como a poda alfa-beta, é
a base da maioria das engines modernas de xadrez. Ele tem sido aplicado
com bastante sucesso em uma ampla gama de jogos de estratégia. Com
efeito, a maioria dos adversários arti ciais nos jogos de tabuleiro que você
joga em seu computador provavelmente utiliza alguma forma de
minimax.
O minimax (com suas extensões, como a poda alfa-beta) tem sido tão
e ciente no xadrez, a ponto de ter levado à famosa derrota do campeão
mundial de xadrez, Gary Kasparov, pelo Deep Blue em 1997 – um
computador que jogava xadrez, criado pela IBM. Houve muita expectativa
para a disputa, e foi um evento que mudou o jogo. O xadrez era visto
como um domínio do mais elevado calibre intelectual. O fato de que um
computador pudesse superar a capacidade humana no xadrez, para
algumas pessoas, signi cou que a inteligência arti cial deveria ser levada
a sério.
Duas décadas mais tarde, a grande maioria das engines de xadrez ainda
tem o minimax como base. As engines de xadrez atuais, baseadas no
minimax, excedem de longe a capacidade dos melhores jogadores de
xadrez do mundo. Novas técnicas de aprendizado de máquina estão
começando a desa ar as engines de xadrez baseadas exclusivamente no
minimax (com extensões), mas elas ainda não comprovaram sua
superioridade no xadrez, de forma de nitiva.
Quanto maior o fator de rami cação de um jogo, menos e ciente será o
minimax. O fator de rami cação de um jogo é o número médio de
possíveis movimentos em uma posição. É por isso que avanços recentes
no jogo de Go por computador têm exigido explorações em outros
domínios, como na área de aprendizado de máquina. Uma IA para Go
baseada em aprendizado de máquina já derrotou o melhor jogador
humano de Go. O fator de rami cação (e, desse modo, o espaço de busca)
de Go é simplesmente absurdo para algoritmos que se baseiam no
minimax, os quais tentam gerar árvores contendo futuras posições.
Contudo Go é a exceção, e não a regra. Os jogos de tabuleiro mais
tradicionais (jogo de damas, xadrez, Connect Four, Scrabble e outros do
mesmo tipo) têm espaços de busca su cientemente pequenos, nos quais
as técnicas com base no minimax podem funcionar bem.
Se você estiver implementando um novo adversário arti cial de jogo de
tabuleiro, ou até mesmo uma IA para um jogo baseado em turnos,
totalmente orientado por computador, o minimax provavelmente será o
primeiro algoritmo do qual você deverá lançar mão. O minimax também
pode ser usado em simulações econômicas e políticas, assim como em
experimentos na teoria de jogos. A poda alfa-beta deve funcionar com
qualquer forma de minimax.
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
Neste livro, abordamos diversas técnicas de resolução de problemas
relevantes às tarefas de desenvolvimento de software moderno. Para
estudar cada técnica, exploramos problemas famosos de ciência da
computação. Contudo, nem todo problema famoso se encaixa nos moldes
dos capítulos anteriores. Este capítulo reúne problemas famosos que não
se enquadraram muito bem em nenhum outro capítulo. Pense nesses
problemas como um bônus: mais problemas interessantes, porém com
menos explicações detalhadas.
9.1 Problema da mochila
O problema da mochila (knapsack problem) é um problema de
otimização que parte de uma necessidade comum em computação –
encontrar o melhor uso de recursos limitados, dado um conjunto nito de
opções de uso – e a transforma em uma história divertida. Um ladrão
entra em uma casa com o intuito de roubá-la. Ele tem uma mochila e,
pela capacidade dela, está limitado ao quanto pode roubar. Como ele
faria para calcular o que pode ser colocado na mochila? A Figura 9.1
ilustra o problema.
Se o ladrão pudesse levar qualquer quantidade de qualquer item, ele
poderia apenas dividir o valor de cada item pelo seu peso a m de
descobrir quais são os itens mais valiosos para a capacidade disponível.
Contudo, para deixar o cenário mais realista, vamos supor que o ladrão
não possa levar a metade de um item (por exemplo, 2,5 televisões). Em vez
disso, pensaremos em uma forma de solucionar a variante 0/1 do
problema, assim chamada porque impõe outra regra: o ladrão só pode
levar um item inteiro, ou nenhum.
Figura 9.1 – O ladrão deve decidir quais itens roubará, pois a capacidade da
mochila é limitada.
Inicialmente, vamos de nir uma NamedTuple para armazenar nossos itens.
Listagem 9.1 – knapsack.py
from typing import NamedTuple, List
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
O laço interno na primeira parte dessa função executará N * C vezes, em
que N é o número de itens e C é a capacidade máxima da mochila. Desse
modo, o algoritmo executa em um tempo de ordem O(N * C), que é uma
melhoria signi cativa em comparação com a abordagem de força bruta
para um número grande de itens. Por exemplo, para os 11 itens seguintes,
um algoritmo que usasse de força bruta teria de analisar 2^11, ou seja,
2.048 combinações. A função anterior com programação dinâmica
executará 825 vezes, pois a capacidade máxima da mochila em questão é
de 75 unidades arbitrárias (11 * 75). Essa diferença aumentaria
exponencialmente com mais itens.
Vamos observar a solução em ação.
Listagem 9.3 – Continuação de knapsack.py
if __name__ == "__main__":
items: List[Item] = [Item("television", 50, 500),
Item("candlesticks", 2, 300),
Item("stereo", 35, 400),
Item("laptop", 3, 1000),
Item("food", 15, 50),
Item("clothing", 20, 800),
Item("jewelry", 1, 4000),
Item("books", 100, 300),
Item("printer", 18, 30),
Item("refrigerator", 200, 700),
Item("painting", 10, 1000)]
print(knapsack(items, 75))
Se você inspecionar os resultados exibidos no console, verá que os itens
ideais a serem levados são: quadro (painting), joias (jewelry), roupas
(clothing), laptop, aparelho de som (stereo) e castiçais (candlesticks). Eis
um exemplo de saída mostrando os itens mais valiosos para o ladrão
roubar, considerando a capacidade limitada da mochila:
[Item(name='painting', weight=10, value=1000), Item(name='jewelry', weight=1,
value=4000), Item(name='clothing', weight=20, value=800), Item(name='laptop',
weight=3, value=1000), Item(name='stereo', weight=35, value=400),
Item(name='candlesticks', weight=2, value=300)]
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):
Para cada número possível de itens, percorreremos todas as capacidades
em um laço, de forma linear, até a capacidade máxima da mochila.
Observe que eu disse “cada número possível de itens”, e não cada item.
Quando i é igual a 2, ele não representa apenas o item 2. Esse valor
representa as combinações possíveis dos dois primeiros itens para cada
capacidade explorada. item é o próximo item que estamos considerando
roubar:
previous_items_value: float = table[i][capacity]
if capacity >= item.weight: # o item cabe na mochila
é o valor da última combinação de itens para a
capacidade atual (capacity) sendo explorada. Para cada possível
combinação de itens, consideramos se adicionar o último “novo” item é,
no mínimo, possível.
Se o item pesar mais do que a capacidade da mochila que estamos
considerando, basta copiar o valor da última combinação de itens que
consideramos para a capacidade em questão:
previous_items_value
else: # o item não cabe na mochila
table[i + 1][capacity] = previous_items_value
Caso contrário, veri camos se adicionar o “novo” item resultará em um
valor maior do que a última combinação de itens para a capacidade
considerada. Fazemos isso somando o valor do item ao valor já calculado
na tabela para a combinação anterior de itens, em uma capacidade igual
ao peso do item subtraído da capacidade que estamos considerando no
momento. Se esse valor for maior do que a última combinação de itens
para a capacidade atual, nós o inserimos; caso contrário, inserimos o
último valor:
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)
Com isso, concluímos a construção da tabela. Para encontrar quais itens
correspondem à solução, porém, precisamos trabalhar na ordem inversa,
da capacidade mais alta e da última combinação de itens explorada:
for i in range(len(items), 0, -1): # trabalha na ordem inversa
# este item foi usado?
if table[i - 1][capacity] != table[i][capacity]:
Começamos pelo nal e percorremos nossa tabela da direita para a
esquerda, veri cando se houve uma mudança no valor inserido na tabela
em cada passo. Se houve, é sinal de que adicionamos o novo item que foi
considerado em uma combinação especí ca porque a combinação era
mais valiosa que a combinação anterior. Assim, adicionamos esse item na
solução. Além disso, subtraímos o peso do item da capacidade; podemos
pensar nisso como um movimento para cima na tabela:
solution.append(items[i - 1])
# se o item foi usado, decrementa o seu peso
capacity -= items[i - 1].weight
NOTA No processo de construção da tabela e da busca da solução, talvez você tenha notado
uma manipulação dos iteradores e do tamanho da tabela de 1. Isso é feito por questões de
conveniência, do ponto de vista da programação. Pense em como o problema é abordado de
baixo para cima. No início do problema, estamos lidando com uma mochila de capacidade
zero. Se você trabalhar de baixo para cima em uma tabela, cará claro o motivo de
precisarmos da linha e da coluna extras.
Você continua confuso? A Tabela 9.1 é a tabela construída pela função
knapsack(). Seria uma tabela bem grande para o problema anterior,
portanto, vamos observar uma tabela para uma mochila com capacidade
de 3 libras e três itens: fósforos (1 libra), lanterna (2 libras) e livro (1
libra). Suponha que esses itens estejam avaliados em 5, 10 e 15 dólares,
respectivamente.
Tabela 9.1 – Exemplo de um problema da mochila com três itens
0 libra 1 libra 2 libras 3 libras
Fósforos (1 libra, 5 dólares) 0
Lanterna (2 libras, 10 dólares) 0
Livro (1 libra, 15 dólares)
0
5
5
15
5
10
20
5
15
25
À medida que percorrer a tabela da esquerda para a direita, o peso
aumenta (quanto você está tentando carregar na mochila). À medida que
observar a tabela de cima para baixo, o número de itens que você está
tentando carregar aumenta. Na primeira linha, você está tentando
carregar apenas os fósforos. Na segunda linha, tenta carregar a
combinação mais valiosa de fósforos e lanterna, que possa ser carregada
na mochila. Na terceira linha, tenta carregar a combinação mais valiosa
de todos os três itens.
Como exercício para facilitar a sua compreensão, experimente preencher
uma versão em branco dessa tabela por conta própria, usando o
algoritmo descrito na função knapsack()com esses mesmos três itens. Em
seguida, utilize o algoritmo no nal da função para ler os itens corretos da
tabela. Essa tabela corresponde à variável table da função.
9.2 Problema do Caixeiro-Viajante
O Problema do Caixeiro-Viajante (Traveling Salesman Problem) é um dos
problemas mais clássicos e discutidos em toda a ciência da computação.
Um caixeiro-viajante deve visitar todas as cidades de um mapa
exatamente uma só vez, retornando à cidade da qual partiu no nal da
jornada. Há uma conexão direta entre cada cidade e todas as demais
cidades, e o caixeiro-viajante pode visitar as cidades em qualquer ordem.
Qual é o caminho mais curto para o ele?
Podemos pensar nesse problema como um problema de grafo (Capítulo
4), com as cidades sendo os vértices e as conexões entre elas, as arestas.
Seu instinto inicial poderia ser encontrar a árvore geradora mínima
(minimum spanning tree), conforme descrito no Capítulo 4. Infelizmente,
a solução para o Problema do Caixeiro-Viajante não é tão simples assim.
A árvore geradora mínima é o caminho mais curto para conectar todas as
cidades, mas não fornece o caminho mínimo de modo a visitar todas elas
exatamente uma só vez.
Apesar de o problema, conforme apresentado, parecer razoavelmente
simples, não há nenhum algoritmo capaz de resolvê-lo rapidamente para
um número arbitrário de cidades. O que eu quero dizer com
“rapidamente”? Quero dizer que esse é um problema conhecido como
NP-difícil (ou NP-complexo). Um problema NP-difícil (problema
polinomial difícil, não determinístico) é um problema para o qual não há
nenhum algoritmo com tempo polinomial. (O tempo que ele demora é
uma função polinomial do tamanho da entrada.) À medida que o número
de cidades que o caixeiro-viajante deve visitar aumenta, a di culdade
para resolver o problema aumentará excepcionalmente, de modo rápido.
É muito mais difícil resolver o problema para 20 cidades do que para 10. É
impossível (até onde sabemos atualmente) resolver o problema de modo
perfeito (ótimo) para milhões de cidades, em tempo razoável.
NOTA A abordagem ingênua para o Problema do Caixeiro Viajante tem complexidade O(n!).
O motivo para isso será discutido na Seção 9.2.2. Contudo, sugerimos que você leia a Seção
9.2.1 antes de ler a 9.2.2 porque a implementação de uma solução ingênua para o problema
deixará a sua complexidade evidente.
9.2.1 Abordagem ingênua
A abordagem ingênua para o problema é tentar simplesmente todas as
combinações possíveis de cidades. Uma tentativa de usar a abordagem
ingênua mostrará a di culdade do problema e a inadequação dessa
abordagem para tentativas usando força bruta em escalas maiores.
Dados para o nosso exemplo
Em nossa versão do Problema do Caixeiro-Viajante, o caixeiro-viajante
está interessado em visitar cinco das principais cidades do estado de
Vermont. Não especi caremos uma cidade inicial (e, portanto, nal). A
Figura 9.2 mostra as cinco cidades e as distâncias a serem percorridas
entre elas. Observe que há uma distância listada para a rota entre cada
par de cidades.
Figura 9.2 – Cinco cidades no estado de Vermont e as distâncias a serem
percorridas entre elas.
Talvez você já tenha visto distâncias de rotas em formato de tabela. Em
uma tabela com distâncias, podemos consultar facilmente a distância
entre duas cidades quaisquer. A Tabela 9.2 lista as distâncias das rotas
para as cinco cidades do problema.
Será necessário codi car tanto as cidades como as distâncias entre elas
em nosso problema. Para facilitar a consulta às distâncias entre as
cidades, usaremos um dicionário de dicionários, com o conjunto externo
de chaves representando o primeiro item de um par, e o conjunto interno
de chaves representando o segundo. Esse dicionário será do tipo Dict[str,
Dict[str, int]], e permitirá fazer consultas como vt_distances["Rutland"]
["Burlington"], que devolverá 67.
Tabela 9.2 – Distâncias das rotas entre as cidades no estado de Vermont
Rutland Burlington White River Junction Bennington Brattleboro
Rutland
0
67
46
55
75
Burlington
67
0
91
122
153
White River Junction 46
91
0
98
65
Bennington
55
122
98
0
40
Brattleboro
75
153
65
40
0
Listagem 9.4 – tsp.py
from typing import Dict, List, Iterable, Tuple
from itertools import permutations
vt_distances: Dict[str, Dict[str, int]] = {
"Rutland":
{"Burlington": 67,
"White River Junction": 46,
"Bennington": 55,
"Brattleboro": 75},
"Burlington":
{"Rutland": 67,
"White River Junction": 91,
"Bennington": 122,
"Brattleboro": 153},
"White River Junction":
{"Rutland": 46,
"Burlington": 91,
"Bennington": 98,
"Brattleboro": 65},
"Bennington":
{"Rutland": 55,
"Burlington": 122,
"White River Junction": 98,
"Brattleboro": 40},
"Brattleboro":
{"Rutland": 75,
"Burlington": 153,
"White River Junction": 65,
"Bennington": 40}
}
Encontrando todas as permutações
A abordagem ingênua para resolver o Problema do Caixeiro-Viajante
exige gerar todas as permutações possíveis das cidades. Há muitos
algoritmos para geração de permutações; eles são bem simples de
conceber, de modo que é quase certo que você poderia criar um por conta
própria.
Uma abordagem comum é usar o backtracking, o qual vimos
inicialmente no Capítulo 3, no contexto da resolução de um problema de
satisfação de restrições. Na resolução de problemas de satisfação de
restrições, o backtracking é usado depois que uma solução parcial é
encontrada, e que não satisfaça as restrições do problema. Nesse caso,
você deve voltar para um estado anterior e continuar a busca por um
caminho diferente daquele que o levou à solução parcial incorreta.
Para encontrar todas as permutações dos itens de uma lista (por
exemplo, de nossas cidades), o backtracking também poderia ser usado.
Depois de fazer uma troca (swap) entre elementos para percorrer um
caminho com outras permutações, você pode voltar ao estado anterior à
troca para que uma troca diferente seja feita a m de percorrer um
caminho diferente.
Felizmente, não há necessidade de reinventar a roda escrevendo um
algoritmo de geração de permutações, pois a biblioteca-padrão de Python
tem uma função permutations() em seu módulo itertools. No trecho de
código a seguir, geraremos todas as permutações das cidades de Vermont
que nosso caixeiro-viajante teria de visitar. Como há cinco cidades, isso
equivale e 5! (5 fatorial), ou seja, 120 permutações.
Listagem 9.5 – Continuação de tsp.py
vt_cities: Iterable[str] = vt_distances.keys()
city_permutations: Iterable[Tuple[str, ...]] = permutations(vt_cities)
Busca com o uso de força bruta
Podemos gerar agora todas as permutações da lista de cidades, mas elas
não serão exatamente o mesmo que o caminho para o Problema do
Caixeiro-Viajante. Lembre-se de que, no Problema do Caixeiro-Viajante,
no nal, o caixeiro-viajante deve retornar à mesma cidade na qual ele
iniciou seu percurso. Podemos facilmente acrescentar a primeira cidade
no nal de uma permutação usando uma list comprehension.
Listagem 9.6 – Continuação de tsp.py
tsp_paths: List[Tuple[str, ...]] = [c + (c[0],) for c in city_permutations]
Agora estamos prontos para testar os caminhos com as permutações.
Uma abordagem de busca com o uso de força bruta analisa pacientemente
cada caminho em uma lista de caminhos e utiliza a tabela para consultar
a distância entre duas cidades (vt_distances) a m de calcular a distância
total de cada caminho. Serão exibidos tanto o caminho mais curto como a
distância total desse caminho.
Listagem 9.7 – Continuação de tsp.py
if __name__ == "__main__":
best_path: Tuple[str, ...]
min_distance: int = 99999999999 # número arbitrariamente alto
for path in tsp_paths:
distance: int = 0
last: str = path[0]
for next in path[1:]:
distance += vt_distances[last][next]
last = next
if distance < min_distance:
min_distance = distance
best_path = path
print(f"The shortest path is {best_path} in {min_distance} miles.")
Por m, podemos usar de força bruta nas cidades de Vermont,
encontrando o caminho mais curto para alcançar todas as cinco cidades.
O resultado deverá ter uma aparência semelhante àquela mostrada a
seguir, e a Figura 9.3 apresenta o melhor caminho.
The shortest path is ('Rutland', 'Burlington', 'White River Junction',
'Brattleboro', 'Bennington', 'Rutland') in 318 miles.
9.2.2 Avançando para o próximo nível
Não há uma resposta fácil para o Problema do Caixeiro-Viajante. Nossa
abordagem ingênua torna-se rapidamente impraticável. O número de
permutações geradas é n fatorial (n!), em que n é o número de cidades do
problema. Se fôssemos incluir apenas uma cidade a mais (seis, em vez de
cinco), o número de caminhos avaliados aumentaria em um fator de seis.
Em seguida, seria sete vezes mais difícil resolver o problema com apenas
uma cidade a mais depois disso. Não é uma abordagem escalável!
Figura 9.3 – A gura mostra o caminho mais curto para o caixeiro-viajante
visitar todas as cinco cidades de Vermont.
No mundo real, a abordagem ingênua para o Problema do CaixeiroViajante raramente é usada. A maioria dos algoritmos para problemas
com um número maior de cidades gera aproximações. Eles tentam
resolver o problema fornecendo uma solução próxima da solução ótima.
Essa solução pode estar dentro de uma pequena faixa conhecida que
contém a solução perfeita. (Por exemplo, talvez não sejam mais do que 5%
menos e cientes.)
Duas técnicas que já apareceram neste livro têm sido usadas para tentar
resolver o Problema do Caixeiro-Viajante com conjuntos grandes de
dados. A programação dinâmica, que usamos antes no problema da
mochila neste capítulo, é uma dessas abordagens. A outra são os
algoritmos genéticos, conforme foram descritos no Capítulo 5. Muitos
artigos cientí cos foram publicados classi cando os algoritmos genéticos
como soluções próximas das soluções ótimas para o problema do
caixeiro-viajante para um número grande de cidades.
9.3 Dados mnemônicos para números de telefone
Antes da existência dos smartphones com agendas telefônicas embutidas,
os telefones incluíam letras em cada uma das teclas numéricas. O motivo
para essas letras era oferecer dados mnemônicos que facilitassem lembrar
os números de telefone. Nos Estados Unidos, em geral a tecla 1 não teria
letras, a tecla 2 teria ABC, 3 teria DEF, 4 teria GHI, 5 teria JKL, 6 teria
MNO, 7 teria PQRS, 8 teria TUV, 9 teria WXYZ e 0 não teria letras. Por
exemplo, 1-800-MY-APPLE corresponde ao número de telefone 1-800-6927753. Ocasionalmente, você ainda encontrará esses dados mnemônicos
em anúncios e, desse modo, os números no teclado conseguiram chegar
até os aplicativos modernos para smartphones, conforme evidencia a
Figura 9.4.
Como é possível criar um dado mnemônico para um número de
telefone? Nos anos 1990, havia sharewares populares para ajudar nessa
tarefa. Esse tipo de software gerava todas as permutações das letras de um
número de telefone, e então consultava um dicionário para encontrar
palavras que estivessem contidas nessas permutações. Em seguida, as
permutações eram exibidas ao usuário, com as palavras mais completas.
Resolveremos a primeira metade do problema. A consulta ao dicionário
será deixada como exercício.
Figura 9.4 – O aplicativo Phone no iOS preserva as letras que havia nas
teclas de seus telefones ancestrais.
No último problema, quando vimos a geração das permutações,
utilizamos a função permutations() para gerar os possíveis caminhos no
Problema do Caixeiro-Viajante. No entanto, conforme mencionamos, há
várias maneiras diferentes de gerar as permutações. Nesse problema em
particular, em vez de trocar duas posições em uma permutação existente a
m de gerar uma nova permutação, geraremos cada permutação do zero.
Faremos isso observando as possíveis letras que correspondam a cada
dígito do número de telefone e adicionaremos continuamente mais
opções no nal, à medida que veri camos cada dígito sucessivo. É uma
espécie de produto cartesiano e, mais uma vez, o módulo itertools da
biblioteca-padrão de Python oferece suporte para nós.
Inicialmente, de niremos um mapeamento entre os dígitos e as
possíveis letras.
Listagem 9.8 – Continuação de tsp.py
from typing import Dict, Tuple, Iterable, List
from itertools import product
phone_mapping: Dict[str, Tuple[str, ...]] = {"1": ("1",),
"2": ("a", "b", "c"),
"3": ("d", "e", "f"),
"4": ("g", "h", "i"),
"5": ("j", "k", "l"),
"6": ("m", "n", "o"),
"7": ("p", "q", "r", "s"),
"8": ("t", "u", "v"),
"9": ("w", "x", "y", "z"),
"0": ("0",)}
A próxima função combina todas as possibilidades para cada numeral e
gera uma lista de possíveis dados mnemônicos para um dado número de
telefone. Isso é feito por meio da criação de uma lista de tuplas com as
letras possíveis para cada dígito do número de telefone e, em seguida,
combinando-as com a função de produto cartesiano product() do módulo
itertools. Observe o uso do operador de desempacotamento (*) para usar
as tuplas de letter_tuples como argumentos para product().
Listagem 9.9 – Continuação de tsp.py
def possible_mnemonics(phone_number: str) -> Iterable[Tuple[str, ...]]:
letter_tuples: List[Tuple[str, ...]] = []
for digit in phone_number:
letter_tuples.append(phone_mapping.get(digit, (digit,)))
return product(*letter_tuples)
Agora podemos encontrar todos os possíveis dados mnemônicos para
um número de telefone.
Listagem 9.10 – Continuação de tsp.py
if __name__ == "__main__":
phone_number: str = input("Enter a phone number:")
print("Here are the potential mnemonics:")
for mnemonic in possible_mnemonics(phone_number):
print("".join(mnemonic))
O fato é que o número de telefone 1440787 também pode ser escrito como
1GH0STS. Essa informação é mais fácil de lembrar.
9.4 Aplicações no mundo real
A programação dinâmica, conforme empregada no problema da mochila,
é uma técnica amplamente aplicável, capaz de fazer com que problemas
aparentemente intratáveis se tornem solucionáveis, dividindo-os em
problemas constituintes menores e construindo uma solução a partir
dessas partes. O próprio problema da mochila está relacionado com
outros problemas de otimização, nos quais uma quantidade nita de
recursos (a capacidade da mochila) deve ser alocada entre um conjunto
nito, porém completo, de opções (os itens a serem roubados). Pense em
uma faculdade que precise distribuir seu orçamento destinado a esportes.
Ela não tem dinheiro su ciente para patrocinar todas as equipes, e há
certa expectativa acerca do valor das doações de ex-alunos que cada
equipe conseguirá obter. A faculdade poderia resolver um problema
semelhante ao da mochila para otimizar a alocação do orçamento.
Problemas como esse são comuns no mundo real.
O Problema do Caixeiro-Viajante é uma ocorrência diária em empresas
de transporte e distribuição, como UPS e FedEx. Empresas que entregam
encomendas querem que seus motoristas percorram as menores rotas
possíveis. Isso não só deixa os trabalhos dos motoristas mais agradáveis,
mas também permite economizar combustível e custos com manutenção.
Todos viajamos a trabalho ou por lazer, e encontrar rotas ótimas ao visitar
vários destinos pode fazer com que economizemos recursos. Contudo, o
Problema do Caixeiro-Viajante não serve apenas para rotas de viagem; ele
surge em quase todos os cenários de roteamento que exijam visitas únicas
aos nós. Embora uma árvore geradora mínima (Capítulo 4) possa
minimizar a quantidade de os necessária para interligar um bairro, ela
não nos informa qual é quantidade ótima de os, se cada casa tiver de
estar conectada a apenas uma outra casa adiante, como parte de um
circuito gigantesco que retorne à sua origem. O Problema do CaixeiroViajante faz isso.
As técnicas de geração de permutações, como aquela que usamos na
abordagem ingênua para o Problema do Caixeiro-Viajante e para o
problema dos dados mnemônicos para números de telefone, são
convenientes para testar todo tipo de algoritmos que fazem uso de força
bruta. Por exemplo, se você estivesse tentando quebrar uma senha
pequena, poderia gerar todas as permutações possíveis dos caracteres que
poderiam estar na senha. Para quem faz uso de tarefas que geram
permutação em larga escala como essas, usar um algoritmo
particularmente e ciente de geração de permutações, como o algoritmo
de Heap2, seria uma atitude inteligente.
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
Este apêndice de ne um conjunto de termos essenciais usados no livro.
acíclico Um grafo sem ciclos (Capítulo 4).
algoritmo guloso (greedy) Um algoritmo que sempre seleciona a melhor
opção imediata em qualquer ponto de decisão, na esperança de que essa
opção levará à solução global ótima (Capítulo 4).
aprendizagem não supervisionada Qualquer técnica de aprendizado de
máquina que não utiliza conhecimento prévio para chegar às suas
conclusões – em outras palavras, uma técnica que não é guiada, mas
executa por conta própria (Capítulo 6).
aprendizagem profunda (deep learning) Espécie de palavra da moda, a
aprendizagem profunda pode se referir a qualquer uma das diversas
técnicas que usam algoritmos so sticados de aprendizado de máquina
para análise de big data. O mais comum é a aprendizagem profunda se
referir ao uso de redes neurais arti ciais de várias camadas para
solucionar problemas usando conjuntos grandes de dados (Capítulo 7).
aprendizagem supervisionada Qualquer técnica de aprendizado de
máquina na qual o algoritmo, de algum modo, é guiado em direção aos
resultados corretos usando recursos externos (Capítulo 7).
aresta Uma conexão entre dois vértices (nós) em um grafo (Capítulo 4).
árvore Um grafo que tem um único caminho entre dois vértices quaisquer.
Uma árvore é acíclica (Capítulo 4).
árvore geradora (spanning tree) Uma árvore que conecta todos os vértices
em um grafo (Capítulo 4).
árvore geradora mínima (minimum spanning tree) Uma árvore geradora
que conecta todos os vértices usando o peso total mínimo das arestas
(Capítulo 4).
auto-memoization Uma versão da memoização implementada no nível da
linguagem, na qual os resultados de chamadas de função sem efeitos
colaterais são armazenados para consultas em caso de haver futuras
chamadas idênticas (Capítulo 1).
backtracking Retornar para um ponto de decisão anterior (a m de tomar
uma direção diferente daquela percorrida antes) depois de atingir um
obstáculo em um problema de busca (Capítulo 3).
cadeia de bits Uma estrutura de dados que armazena uma sequência de
1s e 0s representados por um único bit de memória para cada um. Às
vezes, são chamadas de vetor de bits ou array de bits (Capítulo 1).
camada de entrada A primeira camada de uma rede neural arti cial
feedforward, que recebe sua entrada de alguma espécie de entidade
externa (Capítulo 7).
camada de saída A última camada de uma rede neural arti cial
feedforward, usada para determinar o resultado da rede, para uma dada
entrada e um dado problema (Capítulo 7).
camada oculta Qualquer camada entre a camada de entrada e a camada de
saída em uma rede neural arti cial feedforward (Capítulo 7).
caminho Um conjunto de arestas que conectam dois vértices em um grafo
(Capítulo 4).
centroide O ponto central em um cluster. Em geral, cada dimensão desse
ponto é a média dos demais pontos dessa dimensão (Capítulo 6).
ciclo Um caminho em um grafo que visita o mesmo vértice duas vezes,
sem backtracking (Capítulo 4).
cluster Veja clustering (agrupamento) (Capítulo 6).
clustering (agrupamento) Uma técnica de aprendizado não supervisionado
que divide um conjunto de dados em grupos de pontos relacionados,
conhecidos como clusters (Capítulo 6).
códon Uma combinação de três nucleotídeos que compõem um
aminoácido (Capítulo 2).
compactação Codi cação de dados (mudando o seu formato) para que
menos espaço seja necessário (Capítulo 1).
conectado Uma propriedade de um grafo que indica que há um caminho
de qualquer vértice para qualquer outro vértice (Capítulo 4).
cromossomos Em um algoritmo genético, cada indivíduo da população é
chamado de cromossomo (Capítulo 5).
crossover Em um algoritmo genético, consiste em combinar indivíduos
da população a m de criar descendentes que sejam uma mistura dos
pais, e que farão parte da próxima geração (Capítulo 5).
CSV Um formato de intercâmbio de texto no qual linhas de conjuntos de
dados têm seus valores separados por vírgulas, e as próprias linhas, em
geral, são separadas por caracteres de mudança de linha. CSV signi ca
Comma-Separated Values (Valores Separados por Vírgula). É um formato
de exportação comum em planilhas e bancos de dados (Capítulo 7).
delta Um valor que é representativo de uma diferença entre o valor
esperado de um peso em uma rede neural e seu valor real. O valor
esperado é determinado por meio do uso de dados de treinamento e de
retropropagação (backpropagation) (Capítulo 7).
descompactação Inverter o processo da compactação, devolvendo os
dados ao seu formato original (Capítulo 1).
digrafo Veja grafo direcionado (Capítulo 4).
domínio Os possíveis valores de uma variável em um problema de
satisfação de restrições (Capítulo 3).
escore z O número de desvios-padrões que separa um ponto de dados da
média de um conjunto de dados (Capítulo 6).
feedforward Um tipo de rede neural em que os sinais se propagam em
uma única direção (Capítulo 7).
la Uma estrutura de dados abstrata que garante a ordem FIFO (First-InFirst-Out, ou o primeiro que entra é o primeiro que sai). Uma
implementação de la oferece no mínimo as operações de inserção e de
remoção para adicionar e remover elementos, respectivamente (Capítulo
2).
la de prioridades Uma estrutura de dados que remove itens com base na
ordem de “prioridades”. Por exemplo, uma la de prioridades pode ser
usada em um conjunto de chamadas de emergência para que chamadas
de mais alta prioridade sejam respondidas antes (Capítulo 2).
função de aptidão ( tness function) Uma função que avalia a e cácia de
uma possível solução para um problema (Capítulo 5).
função de ativação Uma função que transforma a saída de um neurônio
em uma rede neural arti cial, em geral para deixá-lo capaz de lidar com
transformações não lineares ou garantir que seu valor de saída esteja
limitado a algum intervalo (Capítulo 7).
função recursiva Uma função que chama a si mesma (Capítulo 1).
função sigmoide Uma função de um conjunto popular de funções de
ativação usadas em redes neurais arti ciais. A função sigmoide
homônima sempre devolve um valor entre 0 e 1. É conveniente também
para garantir que resultados que não sejam apenas transformações
lineares sejam representados pela rede (Capítulo 7).
geração Uma rodada na avaliação de um algoritmo genético; também
usado para se referir à população de indivíduos ativos em uma rodada
(Capítulo 5).
gradiente descendente O método para modi car os pesos de uma rede
neural arti cial usando os deltas calculados durante a retropropagação
(backpropagation) e a taxa de aprendizagem (Capítulo 7).
grafo Uma construção matemática abstrata usada para modelar um
problema do mundo real por meio do qual esse problema é dividido em
um conjunto de nós conectados. Os nós são conhecidos como vértices, e
as conexões são as arestas (Capítulo 4).
grafo direcionado Também conhecido como digrafo, um grafo
direcionado é um grafo no qual as arestas só podem ser percorridas em
uma direção (Capítulo 4).
heurística Uma intuição sobre o modo de resolver um problema, a qual
aponta para a direção correta (Capítulo 2).
heurística admissível Uma heurística para o algoritmo de busca A* que
jamais superestima o custo para alcançar o objetivo (Capítulo 2).
instruções SIMD Instruções de microprocessador otimizadas para fazer
cálculos usando vetores; às vezes, são chamadas também de instruções
de vetor. SIMD quer dizer single instruction, multiple data, isto é, uma
instrução, vários dados (Capítulo 7).
loop in nito Um laço que nunca termina (Capítulo 1).
memoização Uma técnica na qual os resultados de tarefas de
processamento são armazenados para que sejam posteriormente
recuperados da memória, economizando tempo adicional de
processamento para recriar os mesmos resultados (Capítulo 1).
mutação Em um algoritmo genético, modi car aleatoriamente alguma
propriedade de um indivíduo antes que ele seja incluído na próxima
geração (Capítulo 5).
neurônio Uma célula nervosa individual, como aquelas que existem no
cérebro humano (Capítulo 7).
normalização O processo de deixar diferentes tipos de dados comparáveis
(Capítulo 6).
NP-difícil Um problema que pertence a uma classe de problemas para os
quais não há nenhum algoritmo com tempo polinomial para resolvê-los
(Capítulo 9).
nucleotídeo Uma das quatro bases do DNA: adenina (A), citosina (C),
guanina (G) e timina (T) (Capítulo 2).
ou exclusivo Veja XOR (Capítulo 1).
pilha Uma estrutura de dados abstrata que garante a ordem Last-In-FirstOut (LIFO). Uma implementação de pilha oferece no mínimo as
operações de push e pop para inserção e remoção de elementos,
respectivamente (Capítulo 2).
ply (nível) Um turno (com frequência, pode ser pensado como um
movimento) em um jogo para dois jogadores (Capítulo 8).
população Em um algoritmo genético, a população é o conjunto de
indivíduos (cada um representando uma possível solução para um
problema) que competem para resolver o problema (Capítulo 5).
programação dinâmica Em vez de resolver um problema grande usando
uma abordagem de força bruta, na programação dinâmica, o problema
é dividido em subproblemas menores, mais fáceis de administrar
(Capítulo 9).
programação genética Programas que modi cam a si mesmos usando
operadores de seleção, crossover e mutação a m de encontrar soluções
não óbvias para problemas de programação (Capítulo 5).
recursão in nita Um conjunto de chamadas recursivas que não termina,
mas continua fazendo chamadas recursivas adicionais. É análoga a um
loop in nito. Em geral, é causada pela falta de um caso de base
(Capítulo 1).
rede neural Uma rede com vários neurônios que atuam de forma
coordenada para processar informações. Em geral, podemos pensar que
os neurônios estão organizados em camadas (Capítulo 7).
rede neural arti cial Uma simulação de uma rede neural biológica usando
ferramentas de computação para resolver problemas que não são
facilmente reduzíveis a formas mais propícias às abordagens
algorítmicas tradicionais. Observe que o funcionamento de uma rede
neural arti cial em geral se distancia signi cativamente de sua
contrapartida biológica (Capítulo 7).
restrição Um requisito que deve ser obedecido para que um problema de
satisfação de restrições seja resolvido (Capítulo 3).
retropropagação Uma técnica usada para treinamento de pesos de redes
neurais, com base em um conjunto de entradas cujas saídas corretas são
conhecidas. Derivadas parciais são usadas para calcular a
“responsabilidade” de cada peso pelo erro entre os resultados reais e os
resultados esperados. Esses deltas são utilizados para atualizar os pesos
em futuras execuções (Capítulo 7).
seleção Processo de selecionar indivíduos de uma geração para
reprodução e criação de indivíduos para a próxima geração , em um
algoritmo genético (Capítulo 5).
seleção natural O processo evolucionário pelo qual organismos bem
adaptados são bem-sucedidos e os organismos mal adaptados falham.
Dado um conjunto limitado de recursos no ambiente, os organismos
mais bem adaptados para tirar proveito desses recursos sobreviverão e se
propagarão. Ao longo de várias gerações, isso resultará na propagação de
características úteis entre uma população, que será, portanto,
naturalmente selecionada como consequência das limitações do
ambiente (Capítulo 5).
sinapses Lacunas entre neurônios, nas quais neurotransmissores são
liberados para permitir a condução de corrente elétrica. No linguajar
leigo, são as conexões entre os neurônios (Capítulo 7).
taxa de aprendizagem Um valor, em geral uma constante, usada para
ajustar a taxa com que os pesos são modi cados em uma rede neural
arti cial, com base em deltas calculados (Capítulo 7).
treinamento Uma fase na qual uma rede neural arti cial tem seus pesos
ajustados por meio da retropropagação (backpropagation), usando saídas
que se sabem ser corretas para determinadas entradas (Capítulo 7).
variável No contexto de um problema de satisfação de restrições, uma
variável é um parâmetro que deve ser resolvido como parte da solução
do problema. Os possíveis valores de uma variável compõem o seu
domínio. Os requisitos para uma solução correspondem a uma ou mais
restrições (Capítulo 3).
vértice Um único nó em um grafo (Capítulo 4).
XOR Uma operação lógica bit a bit que devolverá true se um de seus
operandos for verdadeiro, mas não quando ambos ou nenhum deles for
verdadeiro. A abreviatura quer dizer eXclusive OR, isto é, ou exclusivo.
Em Python, ^ é usado para representar o operador XOR (Capítulo 1).
APÊNDICE B
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.
B.2 Algoritmos e estruturas de dados
Para citar a a rmação que está na introdução deste livro, “Este não é um
livro didático sobre estruturas de dados e algoritmos”. Há pouco uso da
notação big-O no livro, e não há provas matemáticas. O livro está mais
para um tutorial prático para técnicas importantes de programação, mas é
importante ter um livro didático de verdade também. Ele não só oferecerá
uma explicação mais formal sobre o motivo pelo qual certas técnicas
funcionam como também servirá como uma obra de referência útil.
Conteúdos online são ótimos, mas, às vezes, é bom ter informações que
tenham sido meticulosamente analisadas por acadêmicos e editoras.
• Thomas Cormen, Charles Leiserson, Ronald Rivest e Cli ord Stein,
Introduction to Algorithms, 3ª edição (MIT Press, 2009)2,
https://mitpress.mit.edu/books/introduction-algorithms-third-edition.
• Este é um dos textos mais citados em ciência da computação – tão
consagrado que, com frequência, é referenciado apenas pelas iniciais
de seus autores: CLRS.
• É abrangente e rigoroso em sua abordagem.
• Seu estilo de ensino às vezes é visto como menos acessível em
comparação com outros textos, mas continua sendo uma excelente
referência.
• Pseudocódigos são disponibilizados para a maioria dos algoritmos.
• Uma quarta edição está em desenvolvimento, e, como esse livro é
caro, pode valer a pena veri car quando a quarta edição deverá ser
lançada.
• Robert Sedgewick e Kevin Wayne, Algorithms, 4ª edição (AddisonWesley Professional, 2011), http://algs4.cs.princeton.edu/home/.
• Uma introdução acessível, ainda que abrangente, para algoritmos e
estruturas de dados.
• Bem organizado, com exemplos completos de todos os algoritmos
em Java.
• Popular nos cursos de algoritmos nas universidades.
• Steven Skiena, The Algorithm Design Manual, 2ª edição (Springer,
2011), http://www.algorist.com.
• Diferente de outros livros dessa área quanto à sua abordagem.
• Apresenta menos código e mais discussões descritivas dos usos
apropriados para cada algoritmo.
• Fornece um guia do tipo “escolha a sua própria aventura” para uma
grande variedade de algoritmos.
• Aditya Bhargava, Grokking Algorithms (Manning, 2016)3,
https://www.manning.com/books/grokking-algorithms.
• Uma abordagem grá ca para apresentar algoritmos básicos, com
ilustrações simpáticas, como introdução.
• Não é um livro para referência, mas um guia para conhecer alguns
assuntos básicos selecionados.
B.3 Inteligência arti cial
A inteligência arti cial está mudando o nosso mundo. Neste livro, você
não só foi introduzido a algumas técnicas tradicionais de inteligência
arti cial, como o A* e o minimax, como também a técnicas de sua
subárea empolgante, o aprendizado de máquina, como k-means e redes
neurais. Conhecer melhor a inteligência arti cial não só é interessante
como também garantirá que você esteja preparado para a próxima onda
da computação.
• Stuart Russell e Peter Norvig, Arti cial Intelligence: A Modern
Approach, 3ª edição (Pearson, 2009)4, http://aima.cs.berkeley.edu
• O livro consagrado sobre IA, usado com frequência em cursos
universitários.
• Abrangente em sua abordagem.
• Repositórios de código-fonte excelentes (versões implementadas dos
pseudocódigos que estão no livro), disponíveis online.
• Stephen Lucci e Danny Kopec, Arti cial Intelligence in the 21st
Century, 2ª edição (Mercury Learning and Information, 2015),
http://mng.bz/1N46.
• Um livro acessível para aqueles que procuram um guia mais prático
e diversi cado que o livro de Russell e Norvig.
• Pequenas descrições interessantes sobre os pro ssionais da área e
muitas referências a aplicações no mundo real.
• Andrew Ng, curso de “Machine Learning” (Universidade de Stanford),
https://www.coursera.org/learn/machine-learning/.
• Um curso online gratuito que inclui vários dos algoritmos básicos
para aprendizado de máquina.
• Apresentado por um especialista mundialmente renomado.
• Com frequência, indicado pelos pro ssionais como um ótimo ponto
de partida para a área.
B.4 Programação funcional
Python pode ser programado em um estilo funcional, mas não foi
exatamente projetado para isso. Explorar o alcance da programação
funcional é possível com o próprio Python, mas também pode ser
conveniente trabalhar com uma linguagem puramente funcional, e então
trazer algumas das ideias aprendidas com essa experiência, de volta para
Python.
• Harold Abelson e Gerald Jay Sussman com Julie Sussman, Structure
and Interpretation of Computer Programs (MIT Press, 1996),
https://mitpress.mit.edu/ sicp/.
• Uma introdução clássica à programação funcional, muitas vezes
usada em disciplinas introdutórias de ciência da computação nas
universidades.
• Utiliza Scheme para ensinar – uma linguagem puramente funcional,
fácil de entender.
• Disponível online gratuitamente.
• Aslam Khan, Grokking Functional Programming (Manning, 2018),
https://www.manning.com/books/grokking-functional-programming.
• Uma introdução ilustrada e simpática à programação funcional.
• David Mertz, Functional Programming in Python (O’Reilly, 2015),
https://www.oreilly.com/programming/free/functional-programmingpython.csp.
• Apresenta uma introdução básica a alguns utilitários para
programação funcional da biblioteca-padrão de Python.
• É gratuito.
• Tem apenas 37 páginas – não é muito abrangente, mas é uma
introdução rápida.
B.5 Projetos de código aberto convenientes para aprendizado de
máquina
Há várias bibliotecas Python de terceiros, convenientes e otimizadas para
ter alto desempenho em aprendizado de máquina. Dois desses projetos
foram mencionados no Capítulo 7. Esses projetos oferecem mais recursos
e utilitários do que provavelmente você poderá desenvolver por conta
própria. Em aplicações sérias de aprendizado de máquina ou big data,
utilize essas bibliotecas (ou suas equivalentes).
• NumPy, http://www.numpy.org.
• A biblioteca numérica Python que é padrão de mercado.
• Implementada, em sua maior parte, em C, para ter melhor
desempenho.
• Está na base de muitas bibliotecas Python de aprendizado de
máquina, incluindo TensorFlow e scikit-learn.
• TensorFlow, https://www.tensor ow.org.
• Uma das bibliotecas Python mais populares para trabalhar com
redes neurais.
• pandas, https://pandas.pydata.org.
• Biblioteca popular para importar conjuntos de dados para Python e
manipulá-los.
• scikit-learn, http://scikit-learn.org/stable/.
• Versões bem testadas e completas de vários algoritmos de
aprendizado de máquina apresentados neste livro (e muito, muito
mais).
1 N.T.: Edição publicada no Brasil: Python Cookbook (Novatec, 2013).
2 N.T.: Edição publicada no Brasil: Algoritmos (Campus, 2012).
3 N.T.: Edição publicada no Brasil: Entendendo algoritmos (Novatec, 2017).
4 N.T.: Edição publicada no Brasil: Inteligência arti cial (Campus, 2013).
APÊNDICE C
Introdução rápida às dicas de tipo
Python introduziu as dicas de tipo (type hints), ou anotações de tipo,
como parte o cial da linguagem por meio da PEP 484 e da versão 3.5 de
Python. Desde então, as dicas de tipo têm se tornado mais comuns em
várias bases de código Python, e a linguagem tem acrescentado um
suporte mais robusto a elas. As dicas de tipo foram usadas em todas as
listagens de código deste livro. Neste breve apêndice, meu objetivo é
apresentar uma introdução às dicas de tipo, explicar por que elas são
úteis, apresentar alguns de seus problemas e fornecer referências para
conteúdos mais detalhados sobre elas.
AVISO Este apêndice não tem o intuito de ser completo. Na verdade, é uma introdução rápida.
Consulte
a
documentação
o cial
de
Python
https://docs.python.org/3/library/typing.html.
para
ver
os
detalhes:
C.1 O que são dicas de tipo?
Dicas de tipo são um modo de incluir anotações sobre os tipos esperados
de variáveis, parâmetros de função e tipos de retorno de funções em
Python. Em outras palavras, é um modo de um programador informar
qual é o tipo esperado em determinada parte de um programa Python. A
maioria dos programas Python é escrita sem dicas de tipo. Na verdade,
até ler este livro, mesmo sendo um programador Python de nível
intermediário, é bem possível que você jamais tenha visto um programa
Python com dicas de tipo.
Como Python não exige que o programador especi que o tipo de uma
variável, a única maneira de descobrir o tipo de uma variável sem dicas de
tipo é por meio de uma inspeção (literalmente, ler o código-fonte até o
ponto em questão ou executá-lo e exibir o tipo) ou uma consulta à
documentação. Isso é problemático, pois deixa o código Python mais
difícil de ler (embora algumas pessoas diriam o contrário, mas falaremos
disso mais adiante neste apêndice). Outro problema é que, por ser muito
exível, Python permite ao programador utilizar a mesma variável para
referenciar vários tipos de objetos, o que pode resultar em erros. As dicas
de tipo podem ajudar a evitar esse tipo de programação e reduzir esses
erros.
Agora que Python tem as dicas de tipo, podemos chamá-lo de
linguagem gradualmente tipada – isso signi ca que você pode usar
anotações de tipo quando quiser, mas elas não são obrigatórias. Nesta
rápida introdução, espero convencer você (apesar de sua possível
resistência pelo fato de elas mudarem essencialmente a aparência da
linguagem) de que ter dicas de tipo disponíveis é uma boa opção – uma
boa opção, da qual você deverá tirar proveito em seu código.
C.2 Como é a aparência das dicas de tipo?
As dicas de tipo são acrescentadas em uma linha de código na qual uma
variável ou função é declarada. Dois pontos (:) são usados para sinalizar o
início de uma dica de tipo para uma variável ou parâmetro de função, e
uma seta (->) sinaliza o início de uma dica de tipo para o tipo de retorno
de uma função. Por exemplo, considere a linha de código Python a seguir:
def repeat(item, times):
Sem ler a de nição da função, você é capaz de dizer o que essa função
deveria fazer? Ela deveria exibir uma string um certo número de vezes?
Deveria fazer outra coisa? É claro que poderíamos ler a de nição da
função para descobrir o que ela deve fazer, mas isso exigiria mais tempo.
O autor dessa função, infelizmente, também não forneceu nenhuma
documentação. Vamos tentar de novo, usando dicas de tipo:
def repeat(item: Any, times: int) -> List[Any]:
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.
C.4 Quais são as desvantagens das dicas de tipo?
Há três desvantagens em potencial para o uso das dicas de tipo:
• Códigos com dicas de tipo exigem mais tempo para ser escritos em
comparação com códigos sem dicas de tipo.
• As dicas de tipo, sem dúvida, deixam o código menos legível, em
alguns casos.
• As dicas de tipo ainda não estão totalmente amadurecidas, e
implementar algumas restrições de tipos com as implementações
atuais de Python pode ser confuso.
Um código com dicas de tipo exige mais tempo para ser escrito por dois
motivos: há simplesmente mais digitação (literalmente, mais teclas para
pressionar no teclado), e você terá de pensar mais em seu código. Pensar
no código quase sempre é bom, mas pensar demasiadamente poderá
causar atrasos. No entanto, você deverá compensar esse tempo perdido ao
identi car erros com um veri cador de tipos, antes mesmo que seu
programa venha a executar. O tempo gasto depurando erros que
poderiam ter sido identi cados por um veri cador de tipos provavelmente
será maior que o tempo gasto pensando nos tipos durante a escrita do
código em qualquer base de código complexa.
Algumas pessoas acham que um código Python com dicas de tipo é
menos legível que um código Python sem elas. Os dois motivos para isso
provavelmente são falta de familiaridade e verbosidade. Qualquer sintaxe
com a qual você não tenha familiaridade será menos legível que uma
sintaxe que você conheça. As dicas de tipo de fato alteram a aparência de
programas Python, deixando-os possivelmente menos reconhecíveis à
primeira vista. Isso só poderá ser atenuado se você escrever e ler mais
código Python com dicas de tipo. O segundo problema, a verbosidade, é
mais básico. Python é famoso por sua sintaxe concisa. Com frequência, o
mesmo programa em Python é signi cativamente menor do que seu
equivalente em outra linguagem. Um código Python com dicas de tipo
não é compacto. Ele não poderá ser analisado com tanta rapidez por meio
de uma inspeção visual direta; simplesmente, há muito código para ver. O
custo-benefício está no fato de que haverá uma melhor compreensão do
código após a primeira leitura, apesar de essa leitura ser mais demorada.
Com as dicas de tipo, você verá imediatamente todos os tipos esperados, o
que é melhor do que ter de analisar o código para saber quais são os
tipos, ou ter de ler a documentação.
Por m, as dicas de tipo ainda estão em desenvolvimento. Sem dúvida,
houve melhorias desde a sua introdução em Python 3.5, mas ainda há
casos extremos em que elas não funcionam bem. Um exemplo disso está
no Capítulo 2. O tipo Protocol, que, em geral, é uma parte importante de
um sistema de tipos, ainda não está no módulo typing da bibliotecapadrão de Pyhton, portanto foi necessário incluir o módulo de terceiros
typing_extensions no Capítulo 2. Não há planos para incluir Protocol em
uma versão futura da biblioteca-padrão o cial de Python, mas o fato de
não estar incluído é um testemunho de que ainda estamos no início da
era das dicas de tipo em Python. Durante a escrita deste livro, eu deparei
com vários casos extremos como esse, complicados para resolver,
considerando as primitivas existentes, disponíveis na biblioteca-padrão.
Como as dicas de tipo não são obrigatórias em Python, atualmente, não
há problemas em apenas ignorá-las nas áreas em que elas forem
inconvenientes para usar. Você ainda terá algumas vantagens, mesmo que
use as dicas de tipo apenas parcialmente.
C.5 Obtendo mais informações
Todos os capítulos deste livro estão repletos de exemplos de dicas de tipo,
mas ele não é um tutorial sobre como usá-las. O melhor lugar para uma
introdução às dicas de tipo é a documentação o cial de Python para o
módulo typing (https://docs.python.org/3/library/typing.html). Essa
documentação explica não só todos os diferentes tipos embutidos
disponíveis, mas também como usá-los em vários cenários so sticados,
que estão além do escopo desta introdução rápida.
O outro recurso para saber mais sobre dicas de tipo, que você deve
realmente consultar, é o projeto mypy (http://mypy-lang.org). O mypy é o
principal veri cador de tipos para Python. Em outras palavras, é o
software que você usará para de fato veri car a validade de suas dicas de
tipo. Além de instalá-lo e usá-lo, você também deve consultar a
documentação do mypy (https://mypy.readthedocs.io/). A documentação é
detalhada e explica como usar as dicas de tipo em alguns cenários não
incluídos na documentação da biblioteca-padrão. Por exemplo, uma área
particularmente confusa é a área de genéricos (generics). A documentação
do mypy sobre genéricos é um bom ponto de partida. Outro recurso
interessante é o “type hints cheat sheet” (folha de “cola” para dicas de
tipo),
disponibilizada
pelo
mypy
(https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html).
Python para análise de dados
McKinney, Wes
9788575227510
616 p�ginas
Compre agora e leia
Obtenha instruções completas para manipular, processar, limpar e
extrair informações de conjuntos de dados em Python. Atualizada
para Python 3.6, este guia prático está repleto de casos de estudo
práticos que mostram como resolver um amplo conjunto de
problemas de análise de dados de forma eficiente. Você conhecerá
as versões mais recentes do pandas, da NumPy, do IPython e do
Jupyter no processo. Escrito por Wes McKinney, criador do projeto
Python pandas, este livro contém uma introdução prática e moderna
às ferramentas de ciência de dados em Python. É ideal para
analistas, para quem Python é uma novidade, e para programadores
Python iniciantes nas áreas de ciência de dados e processamento
científico. Os arquivos de dados e os materiais relacionados ao livro
estão disponíveis no GitHub. • utilize o shell IPython e o Jupyter
Notebook para processamentos exploratórios; • conheça os
recursos básicos e avançados da NumPy (Numerical Python); •
comece a trabalhar com ferramentas de análise de dados da
biblioteca pandas; • utilize ferramentas flexíveis para carregar,
limpar, transformar, combinar e reformatar dados; • crie
visualizações informativas com a matplotlib; • aplique o recurso
groupby do pandas para processar e sintetizar conjuntos de dados; •
analise e manipule dados de séries temporais regulares e
irregulares; • aprenda a resolver problemas de análise de dados do
mundo real com exemplos completos e detalhados.
Compre agora e leia
Manual de Análise Técnica
Abe, Marcos
9788575227022
256 p�ginas
Compre agora e leia
Este livro aborda o tema Investimento em Ações de maneira inédita
e tem o objetivo de ensinar os investidores a lucrarem nas mais
diversas condições do mercado, inclusive em tempos de crise.
Ensinará ao leitor que, para ganhar dinheiro, não importa se o
mercado está em alta ou em baixa, mas sim saber como operar em
cada situação. Com o Manual de Análise Técnica o leitor aprenderá:
- os conceitos clássicos da Análise Técnica de forma diferenciada,
de maneira que assimile não só os princípios, mas que desenvolva
o raciocínio necessário para utilizar os gráficos como meio de
interpretar os movimentos da massa de investidores do mercado; identificar oportunidades para lucrar na bolsa de valores, a longo e
curto prazo, até mesmo em mercados baixistas; um sistema de
investimentos completo com estratégias para abrir, conduzir e fechar
operações, de forma que seja possível maximizar lucros e minimizar
prejuízos; - estruturar e proteger operações por meio do
gerenciamento de capital. Destina-se a iniciantes na bolsa de
valores e investidores que ainda não desenvolveram uma
metodologia própria para operar lucrativamente.
Compre agora e leia
Avaliando Empresas, Investindo em
Ações
Debastiani, Carlos Alberto
9788575225974
224 p�ginas
Compre agora e leia
Avaliando Empresas, Investindo em Ações é um livro destinado a
investidores que desejam conhecer, em detalhes, os métodos de
análise que integram a linha de trabalho da escola fundamentalista,
trazendo ao leitor, em linguagem clara e acessível, o conhecimento
profundo dos elementos necessários a uma análise criteriosa da
saúde financeira das empresas, envolvendo indicadores de balanço
e de mercado, análise de liquidez e dos riscos pertinentes a fatores
setoriais e conjunturas econômicas nacional e internacional. Por
meio de exemplos práticos e ilustrações, os autores exercitam os
conceitos teóricos abordados, desde os fundamentos básicos da
economia até a formulação de estratégias para investimentos de
longo prazo.
Compre agora e leia
Microsserviços prontos para a produção
Fowler, Susan J.
9788575227473
224 p�ginas
Compre agora e leia
Um dos maiores desafios para as empresas que adotaram a
arquitetura de microsserviços é a falta de padronização de
arquitetura – operacional e organizacional. Depois de dividir uma
aplicação monolítica ou construir um ecossistema de microsserviços
a partir do zero, muitos engenheiros se perguntam o que vem a
seguir. Neste livro prático, a autora Susan Fowler apresenta com
profundidade um conjunto de padrões de microsserviço,
aproveitando sua experiência de padronização de mais de mil
microsserviços do Uber. Você aprenderá a projetar microsserviços
que são estáveis, confiáveis, escaláveis, tolerantes a falhas, de alto
desempenho, monitorados, documentados e preparados para
qualquer catástrofe. Explore os padrões de disponibilidade de
produção, incluindo: Estabilidade e confiabilidade – desenvolva,
implante, introduza e descontinue microsserviços; proteja-se contra
falhas de dependência. Escalabilidade e desempenho – conheça os
componentes essenciais para alcançar mais eficiência do
microsserviço. Tolerância a falhas e prontidão para catástrofes –
garanta a disponibilidade forçando ativamente os microsserviços a
falhar em tempo real. Monitoramento – aprenda como monitorar,
gravar logs e exibir as principais métricas; estabeleça
procedimentos de alerta e de prontidão. Documentação e
compreensão – atenue os efeitos negativos das contrapartidas que
acompanham a adoção dos microsserviços, incluindo a dispersão
organizacional e a defasagem técnica.
Compre agora e leia
Fundos de Investimento Imobiliário
Mendes, Roni Antônio
9788575226766
256 p�ginas
Compre agora e leia
Você sabia que o investimento em imóveis é um dos preferidos dos
brasileiros? Você também gostaria de investir em imóveis, mas tem
pouco dinheiro? Saiba que é possível, mesmo com poucos
recursos, investir no mercado de imóveis por meio dos Fundos de
Investimento Imobiliário (FIIs). Investir em FIIs representa uma
excelente alternativa para aumentar o patrimônio no longo prazo.
Além disso, eles são ótimos ativos geradores de renda que pode ser
usada para complementar a aposentadoria. Infelizmente, no Brasil,
os FIIs são pouco conhecidos. Pouco mais de 100 mil pessoas
investem nesses ativos. Lendo este livro, você aprenderá os
aspectos gerais dos FIIs: o que são; as vantagens que oferecem; os
riscos que possuem; os diversos tipos de FIIs que existem no
mercado e como proceder para investir bem e com segurança. Você
também aprenderá os princípios básicos para avaliá-los, inclusive
empregando um método poderoso, utilizado por investidores do
mundo inteiro: o método do Fluxo de Caixa Descontado (FCD).
Alguns exemplos reais de FIIs foram estudados neste livro e os
resultados são apresentados de maneira clara e didática, para que
você aprenda a conduzir os próprios estudos e tirar as próprias
conclusões. Também são apresentados conceitos gerais de como
montar e gerenciar uma carteira de investimentos. Aprenda a
investir em FIIs. Leia este livro.
Compre agora e leia
Download