Ferramentas e Anotações de Tipo

Autor

Douglas Braga

Nota

Esta seção corresponde a Randomness, Regular Expressions, Functional Programming, zip and Argument Unpacking, args and kwargs, Type Annotations e Welcome to DataSciencester!, do capítulo 2 de Grus (2019).

DicaO que esta seção assume

Que você conhece aleatoriedade pseudoaleatória, expressões regulares e funções de alta ordem. O que vale desacelerar: zip e, sobretudo, anotações de tipo — que atravessam a maior parte das funções do livro e são a contribuição mais distintiva do Grus (2019).

Aleatoriedade e sementes

O módulo random produz números pseudoaleatórios:

import random

random.seed(10)   # fixa a semente

quatro_uniformes = [random.random() for _ in range(4)]
quatro_uniformes
[0.5714025946899135,
 0.4288890546751146,
 0.5780913011344704,
 0.20609823213950174]

Outras funções úteis: randrange para inteiros, shuffle para embaralhar no lugar, choice para sortear um elemento, e sample para sortear vários sem reposição.

random.seed(10)

print("randrange(10):", random.randrange(10))
print("sample de 6 em 60:", random.sample(range(60), 6))

up_to_ten = list(range(1, 11))
random.shuffle(up_to_ten)
print("shuffle:", up_to_ten)

# com reposição: o mesmo elemento pode sair duas vezes
print("com reposição:", [random.choice(range(10)) for _ in range(4)])
randrange(10): 9
sample de 6 em 60: [2, 27, 30, 36, 0, 13]
shuffle: [3, 9, 7, 4, 1, 2, 6, 5, 10, 8]
com reposição: [3, 5, 0, 6]
ImportanteA semente não é opcional

random.seed(10) faz o gerador produzir sempre a mesma sequência. Sem ela, cada execução dá números diferentes — e um experimento cuja resposta muda a cada execução não é reproduzível. Quando o resultado mudar, você não vai saber se mudou porque o algoritmo mudou ou porque o sorteio saiu diferente; e outra pessoa, rodando o mesmo código, não consegue conferir o seu número.

Por isso, todo trecho de código que amostra, embaralha ou inicializa pesos ao acaso começa fixando a semente. Você vai ver random.seed(...) em todos os capítulos que fazem isso. Repare que é o random da biblioteca padrão, não o numpy.random — este livro é Python puro.

Expressões regulares, rapidamente

import re

re_examples = [
    not re.match("a", "cat"),              # 'cat' não COMEÇA com 'a'
    re.search("a", "cat"),                 # mas 'cat' CONTÉM 'a'
    not re.search("c", "dog"),             # 'dog' não contém 'c'
    3 == len(re.split("[ab]", "carbs")),   # divide em a ou b: ['c','r','s']
    "R-D-" == re.sub("[0-9]", "-", "R2D2") # troca dígitos por hífens
]

assert all(re_examples), "todos os exemplos de regex deveriam ser True"
all(re_examples)
True

A distinção que mais confunde é match contra search: o primeiro ancora no início da string, o segundo procura em qualquer posição.

zip e desempacotamento

Aqui vale desacelerar. zip combina iteráveis, elemento a elemento:

list1 = ['a', 'b', 'c']
list2 = [1, 2, 3]

list(zip(list1, list2))
[('a', 1), ('b', 2), ('c', 3)]

zip é preguiçoso: ele não produz nada até alguém iterar. É por isso que a linha acima precisa do list().

E dá para desfazer o zip com o operador *, que desempacota uma lista em argumentos posicionais:

pairs = [('a', 1), ('b', 2), ('c', 3)]
letters, numbers = zip(*pairs)

letters, numbers
(('a', 'b', 'c'), (1, 2, 3))

zip é o motor da aritmética de vetores do Capítulo 4:

def add(v: Vector, w: Vector) -> Vector:
    return [v_i + w_i for v_i, w_i in zip(v, w)]

def dot(v: Vector, w: Vector) -> float:
    return sum(v_i * w_i for v_i, w_i in zip(v, w))

“Percorrer duas sequências em paralelo” é a operação mais frequente em código numérico, e zip é como se escreve isso em Python. Sem ele, seria um laço por índice com range(len(v)) — e aí você teria que lembrar de verificar se os comprimentos batem, coisa que o assert na primeira linha da função faz de propósito.

O * funciona em qualquer chamada de função, e ** faz o mesmo com dicionários e argumentos nomeados:

def other_way_magic(x, y, z):
    return x + y + z

x_y_list = [1, 2]
z_dict = {"z": 3}

assert other_way_magic(*x_y_list, **z_dict) == 6, "1 + 2 + 3 deveria dar 6"
other_way_magic(*x_y_list, **z_dict)
6
Nota

Na definição de uma função, *args recolhe os argumentos posicionais numa tupla e **kwargs recolhe os nomeados num dicionário. É útil para escrever funções de alta ordem que aceitam qualquer assinatura.

O livro usa isso com parcimônia, e o autor explica por quê: o código fica mais correto e mais legível quando você é explícito sobre que argumentos a função exige. *args e **kwargs só quando não há alternativa.

Anotações de tipo

Esta é a parte mais importante da seção, e a mais distintiva do livro.

Python é dinamicamente tipado: em geral, ele não se importa com os tipos dos objetos, desde que sejam usados de forma válida.

def add(a, b):
    return a + b

assert add(10, 5) == 15,              "+ é válido para números"
assert add([1, 2], [3]) == [1, 2, 3], "+ é válido para listas"
assert add("oi ", "lá") == "oi lá",   "+ é válido para strings"

try:
    add(10, "cinco")
except TypeError:
    print("não dá para somar um int com uma string")
não dá para somar um int com uma string

Numa linguagem estaticamente tipada, funções e objetos teriam tipos específicos. Python permite escrever isso:

def add(a: int, b: int) -> int:
    return a + b

add(10, 5)
15
AvisoAs anotações não fazem nada

Este é o ponto que mais confunde quem chega de uma linguagem estática.

A função anotada acima continua somando strings sem reclamar, e add(10, "cinco") levanta exatamente o mesmo TypeError de antes. O interpretador não verifica coisa alguma.

def add(a: int, b: int) -> int:
    return a + b

add("mesmo ", "assim")   # a anotação diz int; o Python não se importa
'mesmo assim'

Então por que usá-las? O Grus (2019) dá quatro razões, e a primeira é a que mais importa aqui:

1. Tipos são documentação. Isso vale em dobro num livro que usa código para ensinar conceitos matemáticos. Compare:

def dot_product(x, y): ...

def dot_product(x: Vector, y: Vector) -> float: ...

A segunda versão diz o que a função faz antes de você ler o corpo dela. Duas listas de números entram; um número sai. O autor escreve que se acostumou tanto com isso que hoje acha Python sem anotação difícil de ler.

2. Ferramentas externas verificam. O mypy lê o código, inspeciona as anotações e acusa erros de tipo antes de você executar. Como o assert, é uma forma de achar erro cedo. O autor conta que roda o mypy sobre todo o código do livro — é parte do que garante que ele esteja correto.

3. Pensar nos tipos força um projeto melhor. Uma função cujo parâmetro precisa ser anotado como Union[str, int, float, bool] está dizendo alguma coisa sobre si mesma: provavelmente é frágil e difícil de usar. A anotação torna o problema visível.

4. O editor ajuda. Com os tipos declarados, o editor autocompleta corretamente e reclama de erros na hora.

Como escrever anotações

Para tipos embutidos, use o próprio tipo. Para coleções, o módulo typing traz versões parametrizadas — e é isso que dá a informação útil:

from typing import List, Dict, Optional, Tuple, Callable

def total(xs: List[float]) -> float:      # lista de floats, não lista qualquer
    return sum(xs)

# quando o tipo não é óbvio pela atribuição, anote a variável
values: List[int] = []
best_so_far: Optional[float] = None       # pode ser float ou None

counts: Dict[str, int] = {'data': 1, 'science': 2}
triple: Tuple[int, float, int] = (10, 2.3, 5)

total([1.0, 2.0, 3.0])
6.0

Como Python tem funções de primeira classe, existe um tipo para representá-las:

def twice(repeater: Callable[[str, int], str], s: str) -> str:
    """repeater é uma função que recebe (str, int) e devolve str"""
    return repeater(s, 2)

def comma_repeater(s: str, n: int) -> str:
    return ', '.join([s for _ in range(n)])

assert twice(comma_repeater, "dicas de tipo") == "dicas de tipo, dicas de tipo"
twice(comma_repeater, "dicas de tipo")
'dicas de tipo, dicas de tipo'

E, como anotações são apenas objetos Python, dá para dar nome a elas:

Number = int
Numbers = List[Number]

def total(xs: Numbers) -> Number:
    return sum(xs)

total([1, 2, 3])
6

Esse último recurso é o que produz o Vector = List[float] do Capítulo 4 — o apelido que dá nome à ideia sem criar tipo novo.

É um bom resumo do que as anotações fazem neste livro: elas não mudam o que o código executa. Mudam o que ele diz.

Bem-vindo à DataSciencester!

Isto conclui a integração de novos funcionários. Ah, e mais uma coisa: tente não desviar dinheiro.

Grus, Joel. 2019. Data Science from Scratch: First Principles with Python. 2nd ed. O’Reilly Media.