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