Olá! Este é o primeiro artigo que publico neste site e do qual tenho orgulho de lançar com meu amigo Bruno. A ideia é podermos, em conjunto com vocês, filosofarmos sobre os mais diversos assuntos! 😉
Sem mais delongas, este artigo é sobre como resolver o Problema do Ditador com simulação de Monte Carlo.
Mas, o que é o Problema do Ditador? Eu fiquei sabendo sobre ele assistindo ao seguinte vídeo do Daniel Nunes, do canal Tem Ciência:
Interessante, não? Logo depois que eu vi o vídeo, fiquei pensando em como responder as perguntas propostas e achei que fazer um programa para simular a situação descrita seria uma boa ideia. Pois é aí que entra o termo “Monte Carlo” na jogada. De acordo com a Wikipedia:
A ideia principal por trás deste método é que seus resultados são baseados em amostragens aleatórias e análises estatísticas, de forma que o método gera experimentos aleatórios, onde seus resultados são desconhecidos.
O site da IBM nos traz um exemplo para entendermos melhor:
Um exemplo simples de uma Simulação de Monte Carlo é calcular a probabilidade de jogar dois dados comuns. Há 36 combinações de jogadas de dados. Com base nisso, é possível calcular manualmente a probabilidade de um resultado específico. Usando uma Simulação de Monte Carlo, é possível simular 10.000 jogadas de dados (ou mais) para obter previsões mais precisas.
Essa é a ideia que usaremos aqui para resolver o Problema do Ditador. Ou seja, ao invés de calcular as probabilidades por meio da matemática, iremos fazer diversos experimentos aleatórios e verificar os seus resultados. Se tudo der certo, iremos obter os mesmos valores que obteríamos por meios dos cálculos! 😉
Mas, antes de continuar, achei interessante mostrar a origem do nome do método (texto adaptado dos sites da Wikipedia e IBM, citados anteriormente):
O Método de Monte Carlo foi inventado por John von Neumann e Stanislaw Ulam durante a Segunda Guerra Mundial para melhorar a tomada de decisão em condições incertas. Por ser secreto, o trabalho exigia um codinome. Um colega de von Neumann e Ulam, Nicholas Metropolis, sugeriu usar o nome Monte Carlo, se referindo ao Cassino Monte Carlo em Mônaco, uma vez que o acaso é o principal elemento da abordagem de modelagem, semelhante a um jogo de roleta.
Programando a base da simulação
Logo no início do vídeo, o Daniel Nunes nos apresenta a lei que o ditador impõe a essa sociedade fictícia:
Uma família só pode ter no máximo um filho homem. Quando nascer um menino, a família fica proibida de ter novos filhos. Enquanto só tiver meninas, a família deve continuar tentando ter novos filhos até o momento em que nasce um menino.
Somente com essa definição já é possível começar a programar o que queremos. No meu caso, gosto muito de usar uma abordagem orientada a testes (TDD – Test Driven Development – link patrocinado). E, como estou a aprofundar os estudos em Python, usei esta linguagem programar a simulação.
Teste para uma nova família
Assim, abri o PyCharm, criei um projeto chamado TemCiencia
e comecei a estruturar o primeiro teste com o seguinte código no arquivo test_ditador.py
:
import unittest
class DitadorTest(unittest.TestCase):
def test_nova_familia_nao_tem_filhos(self):
f = Familia()
self.assertEqual(0, f.get_meninos())
self.assertEqual(0, f.get_meninas())
Observação: para fins didáticos, não colocarei aqui todos os arquivos que criei como: README.md
, .gitignore
, etc., bem como o processo de controle de versões com Git (o repositório encontra-se no meu GitHub – dica: é comum fazermos um commit a cada mudança em que os testes estão passando, ou seja, quando se está no “verde“). No entanto, irei explicar o meu raciocínio na construção do programa apoiada no TDD.
A ideia aqui é começar com algo simples. Se estamos falando em famílias que podem ter filhos, um bom primeiro teste é criar uma família e verificar que ela não tem filhos. Uma vez escrito o teste, devemos executá-lo e verificar que ele falha. No nosso caso, o teste em si nem chega a ser executado porque temos o seguinte erro:
Ran 1 test in 0.012s
FAILED (errors=1)
Error
(...)
test_nova_familia_nao_tem_filhos
f = Familia()
^^^^^^^
NameError: name 'Familia' is not defined
Claro, ainda não definimos essa classe! Dessa forma, criamos um arquivo chamado ditador.py
e coloco o seguinte código:
class Familia:
pass
Também importamos a classe Familia
no arquivo test_ditador.py
:
import unittest
from ditador import Familia
class DitadorTest(unittest.TestCase):
def test_nova_familia_nao_tem_filhos(self):
f = Familia()
self.assertEqual(0, f.get_meninos())
self.assertEqual(0, f.get_meninas())
Ao tentar executar o código, agora temos o seguinte erro:
Ran 1 test in 0.012s
FAILED (errors=1)
Error
(...)
test_nova_familia_nao_tem_filhos
self.assertEqual(0, f.get_meninos())
^^^^^^^^^^^^^
AttributeError: 'Familia' object has no attribute 'get_meninos'
Hum! Falta implementar método get_meninos()
! Vamos escrevê-lo (e aproveitar para colocar o get_meninas()
também):
class Familia:
def get_meninos(self):
pass
def get_meninas(self):
pass
Ao executar o teste, vemos que ele falha (que é o que estamos querendo que aconteça neste primeiro momento):
Ran 1 test in 0.015s
FAILED (failures=1)
None != 0
Expected :0
Actual :None
(...)
test_nova_familia_nao_tem_filhos
self.assertEqual(0, f.get_meninos())
AssertionError: 0 != None
Ele falha porque está esperando (expected) o valor 0
, mas o retornado (actual) foi None
.
Vamos relembrar que, quando estamos desenvolvendo usando TDD, a ideia é escrever o código mais simples que faça o teste passar! Neste caso, iremos fazer com que os métodos retornem 0
:
class Familia:
def get_meninos(self):
return 0
def get_meninas(self):
return 0
Agora sim, temos nosso primeiro teste passando!
Ran 1 test in 0.002s
OK
Pausa aqui: pode parecer bobo, chato, demorado, etc. escrever código dessa forma! Mas, a ideia é justamente praticar as Três Leis do TDD. Como o Robert C. Martin diz neste vídeo: “se você não estivesse fazendo TDD, você sabe exatamente o que escreveria (…) então você só precisa escrever o teste que o forçará a escrever o código que você já sabe que deseja escrever. Essa é a regra: você escreve o teste que o força a escrever o código que você já sabe que deseja”.
Teste para o nascimento de meninos
Vamos fazer um segundo teste: neste caso, peguei a primeira parte da regra que diz que: “Quando nascer um menino…”. Ou seja, precisamos indicar nascimentos de meninos e meninas. Então, podemos acrescentar:
import unittest
from ditador import Familia
class DitadorTest(unittest.TestCase):
def test_nova_familia_nao_tem_filhos(self):
f = Familia()
self.assertEqual(0, f.get_meninos())
self.assertEqual(0, f.get_meninas())
def test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar(self):
f = Familia()
f.nasceu_menino()
self.assertEqual(1, f.get_meninos())
self.assertEqual(0, f.get_meninas())
Ao tentarmos executar o teste, obtemos o erro:
Ran 2 tests in 0.011s
FAILED (errors=1)
Error
(...)
test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar
f.nasceu_menino()
^^^^^^^^^^^^^^^
AttributeError: 'Familia' object has no attribute 'nasceu_menino'
O erro indica que não existe o método nasceu_menino()
. Vamos, então, adicionar esse código:
class Familia:
def get_meninos(self):
return 0
def get_meninas(self):
return 0
def nasceu_menino(self):
pass
Agora, conseguimos executar o teste, mas ele falha porque estamos esperando o valor 1
, mas o método get_meninos()
está retornando 0
:
Ran 2 tests in 0.014s
FAILED (failures=1)
0 != 1
Expected :1
Actual :0
(...)
test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar
self.assertEqual(1, f.get_meninos())
AssertionError: 1 != 0
Neste momento, temos um impasse: se colocarmos o método get_meninos()
para retornar 1
(lembrando que a ideia é escrever o código mais simples que faça o teste passar), o teste anterior vai falhar, mas, se mantivermos o 0
, o teste atual falha! O que fazer? Bom, é neste momento que o TDD pede que você desenvolva um pouquinho mais o código, tornando-o mais genérico de forma a fazer os dois testes passarem!
Nota: Robert C. Martin nos explica essa regra do TDD neste vídeo: o código dos testes e o código de produção se movem em direções opostas: cada novo teste que você adiciona, torna os testes mais restritos, mais específicos; o que você adicionar no código de produção deve fazê-lo ficar mais genérico.
Neste caso, vamos acrescentar um atributo para guardar a quantidade de meninos que essa família tem:
class Familia:
def __init__(self):
self.meninos = 0
def get_meninos(self):
return self.meninos
def get_meninas(self):
return 0
def nasceu_menino(self):
self.meninos += 1
Executado o teste, agora temos os 2 passando!
Ran 2 tests in 0.002s
OK
Refatoração de código duplicado
Agora que temos os testes passando, é o momento de refatorar o código, pois temos duplicação:
import unittest
from ditador import Familia
class DitadorTest(unittest.TestCase):
def test_nova_familia_nao_tem_filhos(self):
f = Familia()
self.assertEqual(0, f.get_meninos())
self.assertEqual(0, f.get_meninas())
def test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar(self):
f = Familia()
f.nasceu_menino()
self.assertEqual(1, f.get_meninos())
self.assertEqual(0, f.get_meninas())
Resolvemos isso por meio do método setUp()
do unittest
, que é executado antes de cada teste:
import unittest
from ditador import Familia
class DitadorTest(unittest.TestCase):
def setUp(self):
self.f = Familia()
def test_nova_familia_nao_tem_filhos(self):
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
def test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar(self):
self.f.nasceu_menino()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
Executamos o teste mais uma vez para nos certificarmos que está tudo ok:
Ran 2 tests in 0.004s
OK
Teste para o nascimento de meninas
Como terceiro teste, farei o mesmo para o nascimento de meninas. Já que você entendeu a ideia, vou mostrar o código final para este caso:
import unittest
from ditador import Familia
class DitadorTest(unittest.TestCase):
def setUp(self):
self.f = Familia()
def test_nova_familia_nao_tem_filhos(self):
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
def test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar(self):
self.f.nasceu_menino()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
def test_ao_nascer_menina_o_numero_de_meninas_deve_aumentar(self):
self.f.nasceu_menina()
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(1, self.f.get_meninas())
class Familia:
def __init__(self):
self.meninos = 0
self.meninas = 0
def get_meninos(self):
return self.meninos
def get_meninas(self):
return self.meninas
def nasceu_menino(self):
self.meninos += 1
def nasceu_menina(self):
self.meninas += 1
Com isso, temos 3 testes passando:
Ran 3 tests in 0.003s
OK
Testes para a proibição de ter novos filhos
Agora que temos a parte da regra “Quando nascer um menino…” (e meninas também) funcionando, podemos tratar do resto da frase “… a família fica proibida de ter novos filhos”. Para isso, vamos escrever um teste para verificar se a família pode ou não ter novos filhos, começando com o caso mais simples, ou seja, de uma nova família:
class DitadorTest(unittest.TestCase):
(...)
def test_nova_familia_pode_ter_filhos(self):
self.assertEqual(True, self.f.pode_ter_filhos())
Avançando rapidamente (uma vez que você já entendeu o “esquema”), vamos acrescentar o seguinte código à classe Família
:
class Familia:
(...)
def pode_ter_filhos(self):
return True
Continuamos com mais um novo teste, verificando, de fato, o caso de quando nasce um menino:
class DitadorTest(unittest.TestCase):
(...)
def test_nova_familia_pode_ter_filhos(self):
self.assertEqual(True, self.f.pode_ter_filhos())
def test_ao_nascer_menino_familia_nao_pode_mais_ter_filhos(self):
self.f.nasceu_menino()
self.assertEqual(False, self.f.pode_ter_filhos())
E verificamos que ele falha, pois, se antes a gente retornava True
, agora devemos retornar False
:
Ran 5 tests in 0.017s
FAILED (failures=1)
True != False
Expected :False
Actual :True
(...)
test_ao_nascer_menino_familia_nao_pode_mais_ter_filhos
self.assertEqual(False, self.f.pode_ter_filhos())
AssertionError: False != True
Atenção: é muito importante que a gente verifique se o teste está falhando antes de prosseguir na adição do código de “produção”. Temos que ter um teste falhando para podermos ver a transição de “teste falhando” para “teste passando”.
Da mesma forma que anteriormente, agora devemos elaborar um pouco mais a lógica. Isso porque se colocarmos o método pode_ter_filhos()
para retornar False
o teste anterior irá falha, mas, se mantivermos o True
, o teste atual falha! Dessa forma, iremos acrescentar um atributo para controlar a situação sendo testada:
class Familia:
def __init__(self):
self.meninos = 0
self.meninas = 0
self._pode_ter_filhos = True
def get_meninos(self):
return self.meninos
def get_meninas(self):
return self.meninas
def nasceu_menino(self):
self.meninos += 1
self._pode_ter_filhos = False
def nasceu_menina(self):
self.meninas += 1
def pode_ter_filhos(self):
return self._pode_ter_filhos
Executamos os testes e verificamos que todos estão passando! \o/
Ran 5 tests in 0.003s
OK
Refatoração para seguir a convenção
Já que todos os testes estão passando, vamos aproveitar o momento para refatorar o código – só refatoramos código com todos os testes passando, hein? 😉 Quando fui adicionar o atributo _pode_ter_filhos
, percebi que não usei a convenção de colocar um _
(sublinhado) na frente dos atributos meninos
e meninas
, umas vez que eles não são públicos. Dessa forma, vamos alterar o código para:
class Familia:
def __init__(self):
self._meninos = 0
self._meninas = 0
self._pode_ter_filhos = True
def get_meninos(self):
return self._meninos
def get_meninas(self):
return self._meninas
def nasceu_menino(self):
self._meninos += 1
self._pode_ter_filhos = False
def nasceu_menina(self):
self._meninas += 1
def pode_ter_filhos(self):
return self._pode_ter_filhos
Executando mais uma vez os testes, verificamos que tudo continua funcionando:
Ran 5 tests in 0.004s
OK
Mais uma pausa aqui: a “beleza” de usar TDD é que você vai criando uma “rede de segurança” para as alterações que você quer fazer no código. Sim, eu sei que essa refatoração que fizemos agora (a mudança de nome de atributos) é muito simples. Mas, pense que essa segurança servirá tanto para mudanças simples quanto para as mais complexas também!
Testes para a proibição de ter novos filhos – parte 2
Continuando com mais testes, vamos verificar essa última regra para o nascimento de meninas também:
class DitadorTest(unittest.TestCase):
(...)
def test_nova_familia_pode_ter_filhos(self):
self.assertEqual(True, self.f.pode_ter_filhos())
def test_ao_nascer_menino_familia_nao_pode_mais_ter_filhos(self):
self.f.nasceu_menino()
self.assertEqual(False, self.f.pode_ter_filhos())
def test_ao_nascer_menina_familia_continua_podendo_ter_mais_filhos(self):
self.f.nasceu_menina()
self.assertEqual(True, self.f.pode_ter_filhos())
Neste caso, não é surpresa que o teste irá passar, pois o nascimento de uma menina não irá impedir a família de ter filhos, situação que já ocorria antes!
E o fato de um teste recém-criado estar passando não é exatamente um problema. Isso só demonstra uma “funcionalidade” que já estava funcionando corretamente.
Além disso, acabamos de adicionar mais uma “rede de proteção” ao nosso projeto. Isso porque, caso alguém mude o código de forma inadvertida, incluindo um bug onde o nascimento de meninas impede novos nascimentos, esse teste irá acusar o problema!
Ran 6 tests in 0.004s
OK
Refatoração para melhoria de código
Neste momento, eu me dei conta de que a regra “Quando nascer um menino a família fica proibida de ter novos filhos” não precisa de um atributo para controlar o resultado do método pode_ter_filhos()
. Veja, basta que verifiquemos se a quantidade de _meninos
é menor que 1
. Ou que é igual a 0
, a escolha é sua! 😉
Como vimos anteriormente, pelo fato de termos os testes, podemos fazer essa alteração de forma segura. Então, vamos tirar o atributo _pode_ter_filhos
nas linhas abaixo:
class Familia:
def __init__(self):
self._meninos = 0
self._meninas = 0
self._pode_ter_filhos = True
def get_meninos(self):
return self._meninos
def get_meninas(self):
return self._meninas
def nasceu_menino(self):
self._meninos += 1
self._pode_ter_filhos = False
def nasceu_menina(self):
self._meninas += 1
def pode_ter_filhos(self):
return self._pode_ter_filhos
E colocar a comparação proposta no método pode_ter_filhos()
:
class Familia:
def __init__(self):
self._meninos = 0
self._meninas = 0
def get_meninos(self):
return self._meninos
def get_meninas(self):
return self._meninas
def nasceu_menino(self):
self._meninos += 1
def nasceu_menina(self):
self._meninas += 1
def pode_ter_filhos(self):
return self._meninos < 1
Executando mais uma vez os testes, verificamos que tudo continua funcionando:
Ran 6 tests in 0.007s
OK
Boas práticas de Programação Orientada a Objetos
Neste ponto, eu gostaria de discutir com vocês sobre boas práticas de Programação Orientada a Objetos.
A questão é: depois que nasceu um menino, o que acontece se chamarmos novamente o método nasceu_menino()
ou nasceu_menina()
, uma vez que não poderiam mais nascer filhos?
No mínimo, o efeito deveria ser nulo (o número de meninos ou meninas deveria permanecer o mesmo). Apesar de ser uma possibilidade, não gosto dela porque, caso a situação ocorra, não ficaremos sabendo que ela ocorreu.
Observação: você deve estar se perguntando o porquê eu chamaria o método novamente, uma vez que o projeto é pequeno e só tem eu (ou você) programando. Acontece que pequenos projetos podem vir a crescer. Ou, eu posso estar no meio de uma lógica que, sem que eu me dê conta, acabe introduzindo um bug que chama o método mais de uma vez. Ou, Murphy está por aí e sabe-se lá o que pode ocorrer. O ponto é: uma classe é responsável por manter seus dados íntegros e, portanto, devemos programar de forma defensiva implementando os controles necessários para que essa integridade se mantenha.
Dessa forma, eu acredito que a melhor opção seria lançar uma exceção para indicar o erro. Mas, para fazermos isso, vamos, antes, escrever um teste para indicar que queremos ter essa validação:
class DitadorTest(unittest.TestCase):
(...)
def test_apos_nascer_menino_deve_lancar_excecao_se_nascer_outro_menino(self):
self.f.nasceu_menino()
self.assertRaises(AssertionError, self.f.nasceu_menino)
E verificamos que o teste está falhando:
Ran 7 tests in 0.020s
FAILED (failures=1)
Failure
(...)
test_apos_nascer_menino_deve_lancar_excecao_se_nascer_outro_menino
self.assertRaises(AssertionError, self.f.nasceu_menino)
AssertionError: AssertionError not raised by nasceu_menino
Excelente! O teste indicou que a exceção não foi lançada (e queremos que seja). Vamos, então, escrever o código para isso acontecer quando chamarmos o método nasceu_menino()
e a família não está podendo mais ter filhos. Para simplificar, optei por usar a instrução assert
do Python (você deve ter percebido, no teste, que estamos esperando que uma exceção da classe AssertionError
seja lançada):
class Familia:
(...)
def nasceu_menino(self):
assert self.pode_ter_filhos()
self._meninos += 1
(...)
Executando mais uma vez os testes, verificamos que todos estão passando: \o/
Ran 7 tests in 0.006s
OK
Vamos fazer a mesma coisa para o caso de nascimento de meninas.
Primeiro, o teste:
class DitadorTest(unittest.TestCase):
(...)
def test_apos_nascer_menino_deve_lancar_excecao_se_nascer_uma_menina(self):
self.f.nasceu_menino()
self.assertRaises(AssertionError, self.f.nasceu_menina)
Na sequência, vemos que o teste falha:
Ran 8 tests in 0.023s
FAILED (failures=1)
Failure
(...)
test_apos_nascer_menino_deve_lancar_excecao_se_nascer_uma_menina
self.assertRaises(AssertionError, self.f.nasceu_menina)
AssertionError: AssertionError not raised by nasceu_menina
Mudamos o código para fazer o teste passar:
class Familia:
(...)
def nasceu_menina(self):
assert self.pode_ter_filhos()
self._meninas += 1
(...)
E, por fim, verificamos que todos os testes estejam passando:
Ran 8 tests in 0.005s
OK
Testes para o sorteio de números aleatórios – o uso de mocks
Note que, neste momento, temos quase tudo o que precisamos para fazer nossa simulação. Falta a parte da lei que diz que “Enquanto só tiver meninas, a família deve continuar tentando ter novos filhos até o momento em que nasce um menino.”
Dessa forma, precisamos de uma maneira de sortear se nasceu menino ou menina! A função do Python que usaremos para realizar esse sorteio é a random.randint()
. Iremos fazer ela sortear entre dois números: 0
e 1
. Se sortear 0
, nasce um menino. Caso contrário, nasce uma menina.
A questão é: como iremos testar isso, uma vez que não temos controle sobre o código dessa função? A resposta para isso é substituir a função original por uma falsa (que chamamos de mock) e, dessa forma, teremos controle sobre o resultado. O pacote unittest
tem funcionalidades para facilitar a criação desses mocks. Vejamos como ficaria um teste para que, quando tiver um novo nascimento, verificarmos que nasceu um menino se o random.randint()
retornar 0
:
import unittest
from unittest import mock
from ditador import Familia
class DitadorTest(unittest.TestCase):
(...)
@mock.patch('random.randint')
def test_novo_nascimento_deve_ser_de_menino_se_sortear_0(self, mocked_randint):
mocked_randint.return_value = 0
self.f.novo_nascimento()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
Note que:
- Na linha 2, importamos o módulo
mock
dounittest
; - Na linha 11, usamos um decorator no método de teste, indicando que queremos um mock da função
random.randint()
; - Na linha 12, recebemos a função mock para ser usada dentro do teste; e
- Na linha 13, configuramos o valor que queremos que a função
random.randint()
retorne quando for chamada.
Além disso, note que iremos criar um método na classe Familia
para indicar um novo nascimento (linha 14).
Como de praxe, devemos executar os testes e vermos que este novo irá falhar. No nosso caso, como você já deve ter percebido, o problema é que ainda não temos o método novo_nascimento()
na classe Familia
. Para encurtar o texto, vamos direto para o código em que fazemos o teste passar:
import random
class Familia:
(...)
def novo_nascimento(self):
res = random.randint(0, 1)
if res == 0:
self.nasceu_menino()
Executando os testes, vemos que todos passam:
Ran 9 tests in 0.010s
OK
Talvez você tenha se surpreendido da forma que escrevemos o método novo_nascimento()
. Se a ideia é escrever o código mais simples que faz o teste passar, por que não chamar self.nasceu_menino()
apenas?
Fato é que poderíamos ter feito isso mesmo. E, quando fôssemos fazer o teste para o nascimento de meninas, invariavelmente teríamos que expandir a lógica.
Mas, a questão é que, na verdade, se você olhar novamente o teste, estamos querendo testar para o caso em que chamamos o random.randint()
e ele retorna 0
, sorteando o nascimento de um menino. Dessa forma, o código mais simples que faz o teste passar precisa chamar o random.randint()
. 😉
Uma vez que estamos com os testes passando, vamos avançar e fazer um novo teste para o caso de o sorteio ter saído 1
, indicando o nascimento de uma menina:
class DitadorTest(unittest.TestCase):
(...)
@mock.patch('random.randint')
def test_novo_nascimento_deve_ser_de_menina_se_sortear_1(self, mocked_randint):
mocked_randint.return_value = 1
self.f.novo_nascimento()
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(1, self.f.get_meninas())
Verificamos que temos o teste falhando:
Ran 10 tests in 0.020s
FAILED (failures=1)
0 != 1
Expected :1
Actual :0
(...)
test_novo_nascimento_deve_ser_de_menina_se_sortear_1
self.assertEqual(1, self.f.get_meninas())
AssertionError: 1 != 0
E implementamos o que falta para o método novo_nascimento()
passar nos testes:
class Familia:
(...)
def novo_nascimento(self):
res = random.randint(0, 1)
if res == 0:
self.nasceu_menino()
else:
self.nasceu_menina()
Ran 10 tests in 0.009s
OK
Programando a simulação
Por fim, vamos voltar ao fato de que precisamos terminar a parte que diz “Enquanto só tiver meninas, a família deve continuar tentando ter novos filhos até o momento em que nasce um menino”, ou seja, fazer a simulação!
Dessa forma, vamos escrever um teste para a simulação de uma família que vai ter, digamos, 3 meninas e 1 menino:
class DitadorTest(unittest.TestCase):
(...)
@mock.patch('random.randint')
def test_ao_sortear_1_1_1_0_deve_ter_3_meninas_e_1_menino(self, mocked_randint):
mocked_randint.side_effect = [1, 1, 1, 0]
self.f.simular()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(3, self.f.get_meninas())
Note, na linha 7, que a forma de configurar múltiplos retornos para múltiplas chamadas da função mock é por meio de uma lista no atributo side_effect
. E que, na linha 8, criamos um método (simular()
) que indica que queremos simular esses múltiplos nascimentos.
Avançando rapidamente, vamos colocar o código para o método simular()
na classe Familia
:
class Familia:
(...)
def simular(self):
while self.pode_ter_filhos():
self.novo_nascimento()
É interessante notar como o código é quase uma tradução literal da lei imposta pelo ditador: “enquanto pode ter filhos, faça novos nascimentos”. 🙂
Rodando os testes, temos todos passando:
Ran 11 tests in 0.009s
OK
O resultado dessa maratona de testes é uma classe Familia
que tem a capacidade de simular o que queremos. Poderíamos agora começar a fazer vários testes, com várias combinações de nascimento, que todos eles passariam.
Ou seja, não vou continuar com esses testes, mas avançar na direção de responder as perguntas propostas pelo Daniel Nunes:
- Qual será a proporção entre meninas e meninos na sociedade?
- Quantos filhos em média tem uma família nesse país?
Para respondê-las, precisaremos simular os nascimentos não apenas para uma família, mas para várias delas. Poderíamos pensar em uma classe Sociedade
e começar a fazer testes para elas. Não é má ideia, mas, penso que seria apenas uma transposição do código que estaria no nosso programa principal para uma classe, não tendo um ganho real (em princípio).
Dessa forma, vamos começar a criar esse código principal. Primeiramente, precisamos definir a quantidade de famílias nessa sociedade. Pensando na Lei dos Grandes Números, creio que um tamanho de 10.000 famílias pode dar um bom resultado. Essa quantidade será a nossa constante N
. Na sequência, vamos criar uma lista chamada sociedade
de tamanho N
contendo essa quantidade de famílias e, finalmente, rodar a simulação para cada família. Nosso código no arquivo ditador.py
deve estar assim:
import random
class Familia:
(...)
def simular(self):
while self.pode_ter_filhos():
self.novo_nascimento()
if __name__ == '__main__':
N = 10000
sociedade = [Familia() for _ in range(0, N)]
for f in sociedade:
f.simular()
E é isso! Nossa simulação está pronta! Basta, agora, calcular os valores para cada questão!
Para a questão 1, precisamos saber o número de meninas e de meninos na sociedade. Dessa forma, usamos a função sum()
em uma lista com a quantidade de meninas de cada família e o mesmo para os meninos (dica: o uso de list comprehension aqui ajudou muito; outra possibilidade que eu tinha pensado era usar reduce()
, mas isso fica para outra oportunidade). Finalmente, a proporção é calculada por meio da divisão entre esses dois valores:
meninas = sum([f.get_meninas() for f in sociedade])
meninos = sum([f.get_meninos() for f in sociedade])
proporcao = meninas / meninos
print(f'Proporção entre meninas e meninos = {meninas}:{meninos} = {proporcao:.1f}')
A questão 2 é mais fácil, uma vez que temos o número de meninas e de meninos calculados anteriormente. Para calcular a média, basta dividir o número de filhos (meninos + meninas
) pelo número de famílias (N
):
media = (meninos + meninas) / N
print(f'Média de filhos por família = {media:.1f}')
Código final
Apesar do código estar no GitHub, para facilitar, vou deixar aqui o código completo dos testes (test_ditador.py
) e do programa principal (ditador.py
). Na sequência, vamos discutir os resultados obtidos na simulação.
test_ditador.py
import unittest
from unittest import mock
from ditador import Familia
class DitadorTest(unittest.TestCase):
def setUp(self):
self.f = Familia()
def test_nova_familia_nao_tem_filhos(self):
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
def test_ao_nascer_menino_o_numero_de_meninos_deve_aumentar(self):
self.f.nasceu_menino()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
def test_ao_nascer_menina_o_numero_de_meninas_deve_aumentar(self):
self.f.nasceu_menina()
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(1, self.f.get_meninas())
def test_nova_familia_pode_ter_filhos(self):
self.assertEqual(True, self.f.pode_ter_filhos())
def test_ao_nascer_menino_familia_nao_pode_mais_ter_filhos(self):
self.f.nasceu_menino()
self.assertEqual(False, self.f.pode_ter_filhos())
def test_ao_nascer_menina_familia_continua_podendo_ter_mais_filhos(self):
self.f.nasceu_menina()
self.assertEqual(True, self.f.pode_ter_filhos())
def test_apos_nascer_menino_deve_lancar_excecao_se_nascer_outro_menino(self):
self.f.nasceu_menino()
self.assertRaises(AssertionError, self.f.nasceu_menino)
def test_apos_nascer_menino_deve_lancar_excecao_se_nascer_uma_menina(self):
self.f.nasceu_menino()
self.assertRaises(AssertionError, self.f.nasceu_menina)
@mock.patch('random.randint')
def test_novo_nascimento_deve_ser_de_menino_se_sortear_0(self, mocked_randint):
mocked_randint.return_value = 0
self.f.novo_nascimento()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(0, self.f.get_meninas())
@mock.patch('random.randint')
def test_novo_nascimento_deve_ser_de_menina_se_sortear_1(self, mocked_randint):
mocked_randint.return_value = 1
self.f.novo_nascimento()
self.assertEqual(0, self.f.get_meninos())
self.assertEqual(1, self.f.get_meninas())
@mock.patch('random.randint')
def test_ao_sortear_1_1_1_0_deve_ter_3_meninas_e_1_menino(self, mocked_randint):
mocked_randint.side_effect = [1, 1, 1, 0]
self.f.simular()
self.assertEqual(1, self.f.get_meninos())
self.assertEqual(3, self.f.get_meninas())
ditador.py
import random
class Familia:
def __init__(self):
self._meninos = 0
self._meninas = 0
def get_meninos(self):
return self._meninos
def get_meninas(self):
return self._meninas
def nasceu_menino(self):
assert self.pode_ter_filhos()
self._meninos += 1
def nasceu_menina(self):
assert self.pode_ter_filhos()
self._meninas += 1
def pode_ter_filhos(self):
return self._meninos < 1
def novo_nascimento(self):
res = random.randint(0, 1)
if res == 0:
self.nasceu_menino()
else:
self.nasceu_menina()
def simular(self):
while self.pode_ter_filhos():
self.novo_nascimento()
if __name__ == '__main__':
N = 10000
sociedade = [Familia() for _ in range(0, N)]
for f in sociedade:
f.simular()
# questão 1
meninas = sum([f.get_meninas() for f in sociedade])
meninos = sum([f.get_meninos() for f in sociedade])
proporcao = meninas / meninos
print(f'Proporção entre meninas e meninos = {meninas}:{meninos} = {proporcao:.1f}')
# questão 2
media = (meninos + meninas) / N
print(f'Média de filhos por família = {media:.1f}')
Resultados
Ao executarmos nosso programa, temos a seguinte saída:
Proporção entre meninas e meninos = 9828:10000 = 1.0
Média de filhos por família = 2.0
Ou seja, considerando que a chance de nascer menino ou menina é de 50% cada, a lei imposta pelo ditador não alterou essa proporção, sendo que as famílias têm, na média, 2 filhos: 1 menina e 1 menino.
Uma coisa interessante de se notar é que sempre teremos 1 menino em cada família (totalizando 10.000 meninos), pois ele é a condição de parada para novos nascimentos! No entanto, o número de meninas irá variar, podendo ser, no total, menor ou maior que 10.000.
Vamos “plotar” a quantidade de meninos e de meninas em cada família para ver o que ocorre:
import random
import matplotlib.pyplot as plt
import pandas as pd
(...)
if __name__ == '__main__':
N = 10000
sociedade = [Familia() for _ in range(0, N)]
for f in sociedade:
f.simular()
# questão 1
meninas = sum([f.get_meninas() for f in sociedade])
meninos = sum([f.get_meninos() for f in sociedade])
proporcao = meninas / meninos
print(f'Proporção entre meninas e meninos = {meninas}:{meninos} = {proporcao:.1f}')
# questão 2
media = (meninos + meninas) / N
print(f'Média de filhos por família = {media:.1f}')
# gráficos
df = pd.DataFrame({
'Familias': [i for i in range(1, N + 1)],
'Meninas': [f.get_meninas() for f in sociedade],
'Meninos': [f.get_meninos() for f in sociedade],
})
df['Filhos'] = df['Meninas'] + df['Meninos']
df['Filhos Acc'] = df['Filhos'].cumsum()
df['Média'] = df['Filhos Acc'] / df['Familias']
# Famílias x Meninos e Famílias x Meninas
df.plot(kind='scatter', x='Familias', y='Meninos').set_ylim(bottom=-1, top=df['Meninas'].max()+1)
df.plot(kind='scatter', x='Familias', y='Meninas').set_ylim(bottom=-1, top=df['Meninas'].max()+1)
plt.show()
Executando o código, temos os seguintes gráficos:
Note que toda família tem 1 menino. Mas, no caso das meninas, há uma gama de possibilidades, sendo que, temos muitas famílias com nenhuma ou poucas meninas e poucas famílias com muitas meninas. Nos dados dessa execução, por exemplo, tem 1 família com mais de 12 meninas!!!
Vamos fazer um histograma para ver essa distribuição:
(...)
# Histograma para a quantidade de meninas
df.plot(kind='hist', column='Meninas', bins=df['Meninas'].max())
plt.show()
Executando o código, temos o seguinte gráfico:
Por meio do histograma, podemos perceber melhor o que acabamos de comentar: há muitas famílias com nenhuma ou poucas meninas e poucas famílias com muitas meninas.
Mas, se temos famílias com 13 filhos (12 meninas + 1 menino), como é que, na média, temos 2 crianças por família? Lembra que citei a Lei dos Grandes Números anteriormente? O fato de a chance de cada criança ser de 50% de nascer menino e 50% de nascer menina não altera o fato de que podemos ter sequências de nascimentos de 12 meninas e 1 menino. A chance é menor, mas ela existe (e, por isso, pode acontecer). No entanto, conforme fazemos mais e mais experimentos (10.000 no nosso caso), a média tende a se igualar com o valor esperado para as probabilidades.
Vamos “plotar” a quantidade de famílias pela média de filhos:
(...)
# Famílias x Média de Filhos
df.plot(kind='line', x='Familias', y='Média')
plt.axhline(y=2, color='red', linestyle='dashed', label='Média esperada')
plt.show()
Executando o código, temos o seguinte gráfico (à esquerda):
No gráfico, vemos que a média inicialmente oscila bem, mas, após aumentarmos o número de famílias no experimento, ela converge para 2 filhos por família. No gráfico à direita, foi dado um zoom para vermos que mais ou menos a partir de 6.000 famílias já teríamos uma boa aproximação para os valores esperados.
Para verificar isso, podemos trocar o N
no código por 6.000 e temos a seguinte saída:
Proporção entre meninas e meninos = 6157:6000 = 1.0
Média de filhos por família = 2.0
Mas, dito tudo isso, será que o resultado que obtivemos está certo? Será que o método de simulação de Monte Carlo funciona? Veja o vídeo do Daniel Nunes resolvendo matematicamente o problema e descubra se os resultados bateram!
E o que você achou de usar TDD para implementar a solução? Por favor, deixe nos comentários sua opinião!
Até o próximo artigo!
Ficou sensacional!!!!
Tá muito bom mesmo, parabéns!
Obrigado, André!!!
Show de bola!! Adoro suas discussões, sempre com contribuições muito relevantes! Parabéns pela iniciativa do site!