4 Introdução à Linguagem Python
Python tornou-se a linguagem de uso geral mais utilizada em ciência de dados. Este capítulo fornece os alicerces de Python, comparando-a com R, discutindo sua filosofia e seu modelo de dados, apresentando as estruturas nativas, funções, módulos, exceções e a orientação a objetos e, por fim, introduzindo as duas bibliotecas sobre as quais se apoia todo o ecossistema científico da linguagem: NumPy e pandas.
4.1 R vs. Python: Qual Linguagem Escolher?
No universo da Ciência de Dados, o debate sobre o uso de R ou Python é constante. Ambas as linguagens são excelentes, mas possuem origens e propostas distintas:

- R: Criado por e para estatísticos. É imbatível em análise de dados clássica, modelagem estatística rigorosa e na facilidade de gerar gráficos rápidos e elegantes com poucas linhas de código.
- Python: Criada como uma linguagem multipropósito de uso geral. Destaca-se pela sintaxe extremamente limpa e legível, facilidade de integração com sistemas web, bancos de dados corporativos de grande escala e na criação de fluxos complexos de engenharia de software e modelos de aprendizado profundo (Deep Learning).
De acordo com dados mundiais de pesquisa com desenvolvedores (como a pesquisa do Statista), o Python ocupa posições de topo no uso global (geralmente entre as 3 primeiras linguagens mais usadas no mundo), enquanto o R situa-se em posições mais modestas (frequentemente ao redor da 20ª posição), sendo esta uma linguagem muito mais especializada.
Na prática, a escolha raramente é exclusiva: muitas equipes usam R para análise estatística e relatórios e Python para engenharia de dados e colocação de modelos em produção. Ferramentas como o Quarto (com o qual este livro foi escrito) e o pacote reticulate permitem inclusive misturar as duas linguagens em um mesmo documento. A tabela a seguir resume as correspondências entre os conceitos estudados no Capítulo de Introdução à Linguagem R e os deste capítulo:
| Conceito | R | Python |
|---|---|---|
| Atribuição | x <- 5 |
x = 5 |
| Primeiro índice | 1 | 0 |
| Intervalo de índices | x[2:4] (inclui o 4) |
x[1:4] (exclui o 4) |
| Vetor homogêneo | vetor atômico c(1, 2, 3) |
numpy.array([1, 2, 3]) |
| Coleção heterogênea | list() |
list, tuple, dict |
| Tabela de dados | data.frame / tibble |
pandas.DataFrame |
| Variável categórica | factor |
pandas.Categorical |
| Valor ausente | NA |
None, numpy.nan, pandas.NA |
| Valor lógico | TRUE / FALSE |
True / False |
| Definição de função | f <- function(x) x^2 |
def f(x): return x**2 |
| Função anônima | \(x) x^2 |
lambda x: x**2 |
| Blocos de código | chaves { } |
indentação |
| Repositório de pacotes | CRAN | PyPI (e conda-forge) |
| Instalar pacote | install.packages("dplyr") |
pip install pandas |
| Carregar pacote | library(dplyr) |
import pandas as pd |
| Ajuda | ?mean |
help(len) ou len? (Jupyter) |
| Agrupar e resumir | group_by() + summarise() |
groupby() + agg() |
| Junção de tabelas | left_join() |
merge(how="left") |
4.2 Por que Programar e os Níveis de Linguagem
A principal razão pela qual programamos é a necessidade de automatizar processos do nosso dia a dia. Para isso, utilizamos linguagens de programação que possibilitam informar ao computador como ele deve se comportar. As linguagens dividem-se tipicamente em:
- Baixo Nível: Mais próximas do que a máquina (hardware) entende. Exigem do programador um profundo conhecimento da arquitetura do computador, dificultando o processo de “conversar” com a máquina. O Assembly é o exemplo clássico.
- Alto Nível: Disponibilizam comandos (palavras-chaves) muito mais próximos de uma linguagem natural humana (como o inglês). Facilitam enormemente o processo de orquestrar o computador. Python e R são excelentes exemplos.
Para ilustrar de forma clara essa diferença, veja a disparidade na complexidade para imprimir uma simples mensagem “Hello, World!” nestas diferentes linguagens:
Em Python (Alto Nível):
print('Hello, World!')Em R (Alto Nível):
cat('Hello, World!\n')Em Assembly (Baixo Nível):
section .data
msg db 'Hello, World!', 0xA
len equ $ - msg
section .text
global _start
_start:
mov eax, 4
mov ebx, 1
mov ecx, msg
mov edx, len
int 0x80No código Assembly (para Linux em arquitetura x86 de 32 bits), o programador precisa manipular diretamente os registradores do processador (eax, ebx, ecx, edx) e acionar uma interrupção do sistema operacional (int 0x80) para que a chamada de sistema write (código 4) escreva len bytes na saída padrão (descritor 1). Observe ainda que esse trecho sequer encerra o programa corretamente — seria necessária outra chamada de sistema (exit). Toda essa complexidade está “escondida” atrás do simples print() do Python.
4.2.1 Compilação e Interpretação
Entre o código-fonte e o processador existe sempre uma etapa de tradução. Ela pode ocorrer de duas formas principais:
- Compilação: um programa chamado compilador traduz todo o código-fonte para código de máquina antes da execução, gerando um executável (por exemplo, C, C++, Fortran, Rust). A execução é rápida, mas o ciclo escrever–compilar–testar é mais lento.
- Interpretação: um interpretador lê e executa o código instrução por instrução, permitindo uso interativo (R, Python, JavaScript). A execução é mais lenta, mas o desenvolvimento é muito mais ágil.
A implementação de referência do Python, o CPython (escrito em C), adota um modelo híbrido: o código-fonte .py é primeiro compilado para um formato intermediário chamado bytecode (os arquivos .pyc na pasta __pycache__), que é então executado por uma máquina virtual. O módulo dis permite inspecionar esse bytecode:
import dis
def quadrado(x):
return x * x
dis.dis(quadrado)
# Exibe instruções como LOAD_FAST, BINARY_OP e RETURN_VALUE
# (os nomes exatos variam conforme a versão do Python)Esse modelo explica por que laços em Python puro são relativamente lentos — cada instrução passa pela máquina virtual e pela verificação dinâmica de tipos — e por que bibliotecas como NumPy, cujo núcleo é escrito em C, são tão importantes para a computação científica. Existem ainda outras implementações da linguagem, como PyPy (com compilação just-in-time), e a mesma estratégia de “compilar os trechos críticos” é usada por ferramentas como Numba e Cython.
4.3 História e Filosofia do Python
O Python foi criado pelo programador holandês Guido van Rossum no Centrum Wiskunde & Informatica (CWI), em Amsterdã. O desenvolvimento começou no final de 1989, como um projeto de férias de fim de ano, e a primeira versão pública (0.9.0) foi lançada em 1991. O nome não faz referência à serpente, mas ao grupo humorístico britânico Monty Python’s Flying Circus, do qual Van Rossum era fã. A linguagem herdou ideias da linguagem educacional ABC, na qual Van Rossum havia trabalhado, em particular a ênfase na legibilidade e o uso de indentação para delimitar blocos (Van Rossum e Drake 2009).
| Ano | Marco |
|---|---|
| 1991 | Primeira versão pública (0.9.0) |
| 1994 | Python 1.0 |
| 2000 | Python 2.0 (compreensões de lista, coletor de lixo para ciclos) |
| 2001 | Criação da Python Software Foundation (PSF) |
| 2006 | Lançamento do NumPy 1.0 |
| 2008 | Python 3.0, versão incompatível com a série 2 |
| 2008 | Início do desenvolvimento do pandas por Wes McKinney |
| 2014 | Projeto Jupyter se separa do IPython |
| 2020 | Fim do suporte oficial ao Python 2 |
Van Rossum atuou por quase três décadas como o “Ditador Benevolente Vitalício” (Benevolent Dictator For Life, BDFL) do projeto, até 2018, quando a governança passou a um conselho diretor eleito. A evolução da linguagem é feita por meio de PEPs (Python Enhancement Proposals), documentos públicos que propõem e justificam cada mudança. Dois deles são especialmente relevantes para iniciantes: a PEP 8, que define o guia de estilo oficial, e a PEP 20, o Zen do Python.
4.3.1 O Zen do Python
O Zen do Python (Peters 2004) é um conjunto de 19 aforismos, escritos por Tim Peters, que resumem a filosofia de desenho da linguagem. Ele pode ser exibido no próprio interpretador:
import this
# The Zen of Python, by Tim Peters
#
# Beautiful is better than ugly.
# Explicit is better than implicit.
# Simple is better than complex.
# Complex is better than complicated.
# Flat is better than nested.
# Sparse is better than dense.
# Readability counts.
# ...
# There should be one-- and preferably only one --obvious way to do it.
# ...Alguns desses princípios explicam diretamente diferenças em relação ao R. “Explícito é melhor que implícito” justifica, por exemplo, o fato de o Python não converter automaticamente texto em número numa soma (ao contrário da coerção implícita do R, estudada no capítulo anterior). “A legibilidade conta” justifica a indentação obrigatória. E “deve haver uma — e preferencialmente apenas uma — maneira óbvia de fazer algo” contrasta com a cultura do R, em que frequentemente há várias formas equivalentes (base R, tidyverse, data.table) para a mesma tarefa.
4.3.2 Paradigmas de Programação
Python é uma linguagem multiparadigma, isto é, suporta diferentes estilos de organização do código:
- Imperativo/procedural: o programa é uma sequência de instruções que alteram o estado (variáveis), organizadas em procedimentos (funções);
- Orientado a objetos: o programa é organizado em torno de classes e objetos (em Python, tudo é objeto, inclusive números e funções);
- Funcional: funções são objetos de primeira classe, que podem ser passadas como argumento e retornadas; há suporte a
lambda,map,filter, compreensões e ao módulofunctools.
dados = [3, 1, 4, 1, 5, 9, 2, 6]
# Estilo imperativo
soma_quadrados = 0
for x in dados:
if x % 2 == 0:
soma_quadrados += x ** 2
# Estilo funcional
soma_quadrados_f = sum(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, dados)))
# Estilo "pythônico" (compreensão / expressão geradora)
soma_quadrados_p = sum(x ** 2 for x in dados if x % 2 == 0)
print(soma_quadrados, soma_quadrados_f, soma_quadrados_p) # 56 56 56Verificação: os pares em dados são \(4, 2, 6\), e \(4^2 + 2^2 + 6^2 = 16 + 4 + 36 = 56\).
4.4 Fundamentos da Linguagem Python
Conforme vimos, Python é uma linguagem de alto nível conhecida por sua excepcional legibilidade, o que significa que o código se assemelha muito à escrita em inglês. Uma de suas principais características e diferenciais é a indentação obrigatória: enquanto a maioria das linguagens usa chaves {} para definir blocos de código, o Python usa recuos de texto (espaços) para identificar o escopo das funções, estruturas condicionais e laços de repetição. A PEP 8 recomenda quatro espaços por nível de indentação, e misturar tabulações com espaços resulta em erro (TabError).
4.4.1 Variáveis e Tipos de Dados Básicos
Em Python, a atribuição de valor a uma variável é feita utilizando o sinal de igualdade simples (=):
# Atribuição simples em Python
idade = 25
nome = "Alice"
usuario_ativo = True
peso = 70.5Os tipos de dados básicos mais comuns são:
int: Inteirosfloat: Números decimais (ponto flutuante)str: Cadeias de caracteres (texto)bool: Valores booleanos (TrueouFalse)
A eles somam-se complex (números complexos, como 2 + 3j) e o tipo especial NoneType, cujo único valor, None, representa a “ausência de valor” — algo próximo do NULL do R. Algumas propriedades merecem atenção:
- Os inteiros (
int) têm precisão arbitrária: não há overflow, e2 ** 100é calculado exatamente. - Os
floatseguem o padrão IEEE 754 de dupla precisão, com as mesmas limitações discutidas no capítulo de R:0.1 + 0.2 == 0.3éFalse, e comparações devem usarmath.isclose(). - A divisão
/sempre retornafloat(7 / 2vale3.5); a divisão inteira é//(7 // 2vale3), o resto é%e a potência é**. boolé um subtipo deint:True + Truevale2, o que permite contar condições verdadeiras comsum().
import math
type(25) # <class 'int'>
type(25.0) # <class 'float'>
2 ** 100 # 1267650600228229401496703205376
7 / 2, 7 // 2, 7 % 2, -7 // 2 # (3.5, 3, 1, -4) -> // arredonda para baixo
0.1 + 0.2 == 0.3 # False
math.isclose(0.1 + 0.2, 0.3) # True
sum([True, False, True]) # 2Note que -7 // 2 vale -4: a divisão inteira do Python é a divisão piso, \(a \,//\, b = \lfloor a/b \rfloor\), e o resto satisfaz \(a = b\,(a \,//\, b) + (a \,\%\, b)\) com o resto tendo o mesmo sinal de \(b\). Assim, \(-7 = 2 \cdot (-4) + 1\).
4.4.2 Tipagem Dinâmica e Forte
É comum encontrar a afirmação imprecisa de que Python “não tem tipos”. Na verdade, Python é uma linguagem de tipagem dinâmica e forte:
- Dinâmica: os tipos estão associados aos valores (objetos), e não às variáveis; uma mesma variável pode referenciar, ao longo do programa, objetos de tipos diferentes, e a verificação de tipos ocorre em tempo de execução.
- Forte: a linguagem não realiza conversões implícitas entre tipos incompatíveis; operações sem sentido geram erro em vez de produzir um resultado “adivinhado”.
x = 10 # x referencia um int
x = "dez" # agora referencia um str (tipagem dinâmica)
"1" + 1 # TypeError: can only concatenate str (not "int") to str
int("1") + 1 # 2 (conversão explícita)
"1" * 3 # '111' (operação definida para str e int: repetição)Compare com o R, que realiza coerção implícita em c(1, "a"). Desde o Python 3.5 é possível adicionar anotações de tipo (type hints), que documentam a intenção e podem ser verificadas por ferramentas externas (como o mypy), mas que não são impostas pelo interpretador:
def media(valores: list[float]) -> float:
return sum(valores) / len(valores)4.4.3 Variáveis como Referências e Mutabilidade
Um ponto conceitual central — e fonte de muitos erros — é que, em Python, variáveis são nomes que referenciam objetos, e não “caixas” que contêm valores. A atribuição b = a não copia o objeto: faz b referenciar o mesmo objeto que a. A função id() retorna a identidade do objeto, e o operador is testa se dois nomes referenciam o mesmo objeto (enquanto == testa igualdade de valor).
Os objetos se dividem em:
- Imutáveis: não podem ser alterados após criados —
int,float,bool,str,tuple,frozenset; - Mutáveis: podem ser alterados no lugar —
list,dict,sete a maioria dos objetos definidos pelo usuário.
a = [1, 2, 3]
b = a # b é outro nome para o MESMO objeto
b.append(4)
print(a) # [1, 2, 3, 4] -> a também "mudou"!
a is b # True
c = a.copy() # cópia rasa: novo objeto lista
c.append(5)
print(a, c) # [1, 2, 3, 4] [1, 2, 3, 4, 5]
a == [1, 2, 3, 4], a is c # (True, False)
s = "texto"
s.upper() # 'TEXTO' -> retorna NOVO objeto; s não muda
print(s) # textoPara estruturas aninhadas (listas de listas, por exemplo), a cópia rasa copia apenas o “nível de fora”; para uma cópia independente em todos os níveis, usa-se copy.deepcopy(). Esse comportamento difere do R, que adota a semântica de cópia na modificação (copy-on-modify): em R, b <- a; b[1] <- 0 nunca altera a.
4.4.4 O Modelo de Objetos e o Gerenciamento de Memória
A documentação oficial resume o modelo de dados do Python em uma frase: todo objeto possui uma identidade, um tipo e um valor (Van Rossum e Drake 2009; Ramalho 2022). A identidade (id(obj)) nunca muda durante a vida do objeto — no CPython, ela é o próprio endereço de memória —, o tipo (type(obj)) determina as operações suportadas, e o valor pode ou não mudar, conforme o objeto seja mutável ou imutável.
Como os nomes são apenas referências, surge a pergunta: quando um objeto deixa de existir? O CPython usa contagem de referências (reference counting): cada objeto guarda quantas referências apontam para ele, e o objeto é destruído assim que esse contador chega a zero. A função sys.getrefcount() permite observar o contador (o valor exibido é uma unidade maior, pois a própria chamada cria uma referência temporária):
import sys
a = [1, 2, 3]
sys.getrefcount(a) # 2 (o nome 'a' + o argumento temporário)
b = a
sys.getrefcount(a) # 3
del b # 'del' remove o NOME, não o objeto
sys.getrefcount(a) # 2A contagem de referências não resolve o caso de ciclos (por exemplo, uma lista que contém a si mesma, x = []; x.append(x)): o contador nunca chega a zero, mesmo que o ciclo esteja inacessível. Para isso, o CPython possui um coletor de lixo (garbage collector) complementar, no módulo gc, que periodicamente detecta e libera ciclos inacessíveis.
Dois detalhes de implementação costumam confundir iniciantes. Primeiro, por eficiência, o CPython mantém em cache os inteiros pequenos (de \(-5\) a \(256\)), de modo que x is y pode ser True para inteiros pequenos e False para inteiros grandes de mesmo valor — mais um motivo para nunca usar is para comparar valores. Segundo, objetos Python carregam um cabeçalho considerável: um int pequeno ocupa 28 bytes, e um float, 24 bytes, enquanto o mesmo número em um array do NumPy ocupa 8 bytes:
x = int("1000"); y = int("1000")
x == y, x is y # (True, False) -> mesmo valor, objetos distintos
sys.getsizeof(1.0) # 24 bytes
sys.getsizeof(2 ** 100) # 40 bytes (inteiros crescem conforme necessário)
sys.getsizeof([1, 2, 3]) # 88 bytes (só a lista de PONTEIROS, sem os inteiros)Uma lista de um milhão de float consome, portanto, cerca de 8 MB apenas em ponteiros, mais 24 MB nos objetos float para os quais eles apontam, espalhados pela memória; um array NumPy equivalente ocupa exatamente 8 MB contíguos. Essa diferença de representação é a raiz da diferença de desempenho entre listas e arrays, discutida na seção sobre NumPy.
4.4.5 Números de Ponto Flutuante em Python
Os float do Python são doubles do padrão IEEE 754, idênticos aos do R: valem as mesmas propriedades de arredondamento, overflow e underflow estudadas no capítulo anterior (Goldberg 1991). O Python, porém, oferece ferramentas especialmente convenientes para inspecionar a representação:
import sys, math
sys.float_info.epsilon # 2.220446049250313e-16 (épsilon da máquina)
sys.float_info.max # 1.7976931348623157e+308
(0.1).hex() # '0x1.999999999999ap-4'
(0.1).as_integer_ratio() # (3602879701896397, 36028797018963968)A representação hexadecimal mostra a mantissa e o expoente: \(0{,}1 \approx (1{,}999999999999\text{a})_{16} \times 2^{-4}\). A dízima \(1{,}999\ldots_{16}\) é a versão em base 16 da dízima periódica \((1{,}\overline{1001})_2\) calculada no capítulo de R, e o último dígito, a (em vez de 9), revela o arredondamento para cima da mantissa. Já as_integer_ratio() exibe a fração exata armazenada: o denominador é \(36\,028\,797\,018\,963\,968 = 2^{55}\), confirmando que todo double é uma fração diádica \(m/2^k\).
Os erros de arredondamento se acumulam em somas longas. O módulo math oferece math.fsum(), que acompanha os erros parciais e retorna a soma corretamente arredondada:
sum([0.1] * 10) # 0.9999999999999999
sum([0.1] * 10) == 1.0 # False
math.fsum([0.1] * 10) # 1.0Quando a exatidão decimal é um requisito — por exemplo, em cálculos financeiros —, usa-se o módulo decimal, que implementa aritmética em base 10 com precisão configurável; para aritmética racional exata, há o módulo fractions:
from decimal import Decimal
from fractions import Fraction
Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
Decimal(0.1) # Decimal('0.1000000000000000055511151231257827021181583404541015625')
Fraction(1, 3) + Fraction(1, 6) # Fraction(1, 2)Observe a diferença entre Decimal("0.1") (construído a partir do texto, exato) e Decimal(0.1) (construído a partir do double, que já carrega o erro de representação). Essas classes são muito mais lentas que float e não são usadas em computação científica, mas ilustram que a escolha da representação numérica é uma decisão de projeto.
Exemplo resolvido: a variância negativa. No capítulo de R, vimos que a fórmula de uma passada \(s^2 = \left(\sum x_i^2 - n\bar{x}^2\right)/(n-1)\) sofre cancelamento catastrófico para \(\mathbf{x} = (10^9 + 4,\ 10^9 + 7,\ 10^9 + 13,\ 10^9 + 16)\), cuja variância exata é \(30\). Em Python, o resultado é ainda mais alarmante:
x = [1e9 + 4, 1e9 + 7, 1e9 + 13, 1e9 + 16]
n = len(x)
(sum(v * v for v in x) - n * (sum(x) / n) ** 2) / (n - 1) # -170.66666666666666 (!)
m = sum(x) / n
sum((v - m) ** 2 for v in x) / (n - 1) # 30.0 (duas passadas)
import statistics
statistics.variance(x) # 30.0Uma variância negativa é um absurdo matemático, mas é exatamente o que a aritmética de ponto flutuante produz quando dois números da ordem de \(4 \times 10^{18}\), cada um com erro de dezenas de unidades, são subtraídos. A diferença em relação ao R (que retornou 0) vem de detalhes de implementação — a função sum() do R acumula as parcelas em precisão estendida —, o que reforça a lição: o resultado de um algoritmo instável é imprevisível. Na seção sobre geradores, implementaremos o algoritmo de Welford, que calcula a variância em uma única passada de forma estável.
4.4.6 Cadeias de Caracteres (Strings)
Strings são sequências imutáveis de caracteres Unicode. Elas podem ser indexadas e fatiadas como listas e possuem dezenas de métodos úteis para a limpeza de dados textuais:
nome = " Ciência de Dados "
nome.strip() # 'Ciência de Dados'
nome.strip().lower() # 'ciência de dados'
nome.strip().split(" ") # ['Ciência', 'de', 'Dados']
"-".join(["2026", "07", "04"]) # '2026-07-04'
"dados" in nome # False (diferencia maiúsculas)
"Dados".replace("D", "d") # 'dados'
len("Python") # 6
media = 7.856
aluno = "Bia"
f"A média de {aluno} foi {media:.2f}" # 'A média de Bia foi 7.86'
f"{0.1234:.1%}" # '12.3%'As f-strings (cadeias formatadas, prefixadas por f) permitem inserir expressões entre chaves e controlar a formatação numérica (:.2f para duas casas decimais, :.1% para percentual, :, para separador de milhares).
4.5 Estruturas de Dados Nativas do Python
Python possui quatro estruturas de dados nativas fundamentais altamente flexíveis:
Listas (Lists): Estruturas ordenadas e mutáveis (podem ser alteradas após a criação). São delimitadas por colchetes
[]e aceitam dados heterogêneos:idades = [25, 30, 22, 45] cadastro_basico = ["Alice", 25, True]ImportanteDiferente do R, a indexação em Python começa em 0 (zero). Assim,
idades[0]retornará o primeiro elemento (25).Dicionários (Dictionaries): Estruturas mutáveis compostas por pares de chaves e valores. São delimitados por chaves
{}e ideais para mapear dados identificados. Até o Python 3.6 os dicionários não garantiam ordem; a partir do Python 3.7, eles preservam a ordem de inserção das chaves, embora o acesso seja sempre feito pela chave, e não pela posição:cadastro_completo = { "nome": "Alice", "idade": 25, "ativo": True } # Acessando o valor pela chave print(cadastro_completo["nome"]) # Imprime 'Alice'Tuplas (Tuples): Estruturas ordenadas e imutáveis (seus elementos não podem ser alterados após criados). São delimitadas por parênteses
()e muito utilizadas para proteger dados que devem permanecer constantes ao longo do código:coordenadas = (-8.0578, -34.8829)Conjuntos (Sets): Coleções não ordenadas de elementos únicos e mutáveis. Delimitados por chaves
{}(mas sem a estrutura chave-valor dos dicionários):numeros_unicos = {1, 2, 3, 3, 4} # O valor 3 repetido será automaticamente removido
A tabela a seguir resume as propriedades das quatro estruturas:
| Estrutura | Sintaxe | Ordenada | Mutável | Elementos duplicados | Acesso |
|---|---|---|---|---|---|
list |
[1, 2, 2] |
Sim | Sim | Sim | por posição |
tuple |
(1, 2, 2) |
Sim | Não | Sim | por posição |
dict |
{"a": 1} |
Sim (inserção, 3.7+) | Sim | Chaves únicas | por chave |
set |
{1, 2} |
Não | Sim | Não | teste de pertinência |
4.5.1 Indexação e Fatiamento
Sequências (list, tuple, str) admitem índices negativos, contados a partir do fim (-1 é o último elemento), e fatiamento (slicing) com a sintaxe seq[inicio:fim:passo]. O fatiamento corresponde ao intervalo semiaberto \([\text{inicio}, \text{fim})\): o elemento na posição fim não é incluído. Uma consequência elegante dessa convenção é que seq[:k] e seq[k:] particionam a sequência, e len(seq[a:b]) == b - a.
x = [10, 20, 30, 40, 50, 60]
# 0 1 2 3 4 5 (índices positivos)
# -6 -5 -4 -3 -2 -1 (índices negativos)
x[0], x[-1] # (10, 60)
x[1:4] # [20, 30, 40] -> posições 1, 2 e 3
x[:3] # [10, 20, 30]
x[3:] # [40, 50, 60]
x[::2] # [10, 30, 50] -> passo 2
x[::-1] # [60, 50, 40, 30, 20, 10] -> inverteEm R, x[2:4] retorna os elementos nas posições 2, 3 e 4 (intervalo fechado, base 1). Em Python, x[1:4] retorna os mesmos três elementos (intervalo semiaberto, base 0). Além disso, índices negativos têm significados opostos: em R, x[-1] exclui o primeiro elemento; em Python, x[-1] seleciona o último.
4.5.2 Operações Principais
# Listas
notas = [7.5, 8.0, 6.5]
notas.append(9.0) # adiciona ao final
notas.insert(0, 5.0) # insere na posição 0
notas.extend([8.5, 7.0]) # concatena outra lista
notas.remove(6.5) # remove a primeira ocorrência do valor
ultima = notas.pop() # remove e retorna o último elemento
notas.sort(reverse=True) # ordena no lugar
sorted(notas) # retorna NOVA lista ordenada
len(notas), max(notas), sum(notas) / len(notas)
# Dicionários
estoque = {"maçã": 10, "banana": 5}
estoque["laranja"] = 8 # insere/atualiza
estoque.get("uva", 0) # 0 -> valor padrão se a chave não existe
for fruta, qtd in estoque.items(): # itera sobre pares (chave, valor)
print(f"{fruta}: {qtd}")
list(estoque.keys()), list(estoque.values())
del estoque["banana"]
# Conjuntos: operações da teoria dos conjuntos
A = {1, 2, 3, 4}
B = {3, 4, 5}
A | B # união {1, 2, 3, 4, 5}
A & B # interseção {3, 4}
A - B # diferença {1, 2}
A ^ B # dif. simétrica {1, 2, 5}
3 in A # True
# Tuplas: desempacotamento
lat, lon = coordenadas
a, *resto = [1, 2, 3, 4] # a = 1, resto = [2, 3, 4]As operações de conjunto correspondem exatamente às da teoria dos conjuntos: \(A \cup B\), \(A \cap B\), \(A \setminus B\) e \(A \,\triangle\, B = (A \setminus B) \cup (B \setminus A)\). Uma aplicação comum em dados é calcular o índice de Jaccard entre dois conjuntos,
\[ J(A, B) = \frac{|A \cap B|}{|A \cup B|}, \]
que, para os conjuntos acima, vale \(J = |\{3, 4\}| / |\{1, 2, 3, 4, 5\}| = 2/5 = 0{,}4\). Em Python: len(A & B) / len(A | B).
4.5.3 Complexidade Computacional das Operações
A escolha da estrutura adequada tem impacto direto no desempenho. Usando a notação assintótica \(O(\cdot)\) — em que \(O(1)\) significa tempo constante e \(O(n)\) tempo proporcional ao número de elementos \(n\) —, o custo médio das principais operações é:
| Operação | list |
dict |
set |
|---|---|---|---|
Acesso por índice/chave (x[i], d[k]) |
\(O(1)\) | \(O(1)\) | — |
Teste de pertinência (v in x) |
\(O(n)\) | \(O(1)\) | \(O(1)\) |
Inserção no final (append) / inserção de chave |
\(O(1)\) amortizado | \(O(1)\) | \(O(1)\) |
Inserção/remoção no início (insert(0, v), pop(0)) |
\(O(n)\) | — | — |
| Remoção de um valor arbitrário | \(O(n)\) | \(O(1)\) | \(O(1)\) |
Ordenação (sorted) |
\(O(n \log n)\) | — | — |
Listas são arrays dinâmicos: o acesso por posição é imediato, mas procurar um valor exige percorrê-la. Dicionários e conjuntos são tabelas de dispersão (hash tables): a chave é transformada por uma função hash \(h(k)\) em uma posição de memória, o que torna a busca, em média, independente de \(n\). Por isso, as chaves de dicionários e os elementos de conjuntos precisam ser imutáveis (hashable): uma tupla pode ser chave de dicionário, mas uma lista não.
Exemplo numérico. Suponha que precisamos verificar, para cada um de \(m = 10^5\) identificadores de clientes, se ele pertence a uma lista de \(n = 10^5\) clientes inadimplentes. Usando uma list, o custo total é da ordem de \(m \cdot n = 10^{10}\) comparações; convertendo a lista para set (custo \(O(n)\)) e testando pertinência, o custo cai para a ordem de \(n + m = 2 \times 10^5\) operações — uma redução de cerca de cinco ordens de grandeza.
inadimplentes = set(lista_inadimplentes) # conversão única, O(n)
marcados = [c for c in clientes if c in inadimplentes] # O(m)4.5.4 Compreensões de Lista, Dicionário e Conjunto
As compreensões (comprehensions) são uma sintaxe concisa, inspirada na notação matemática de construção de conjuntos. A expressão \(\{x^2 : x \in \{0, \dots, 9\},\ x \text{ par}\}\) traduz-se quase literalmente:
quadrados_pares = [x ** 2 for x in range(10) if x % 2 == 0]
# [0, 4, 16, 36, 64]
# Compreensão de dicionário: nome -> comprimento
nomes = ["Ana", "Bruno", "Carla"]
tamanhos = {n: len(n) for n in nomes} # {'Ana': 3, 'Bruno': 5, 'Carla': 5}
# Compreensão de conjunto: iniciais únicas
iniciais = {n[0] for n in nomes} # {'A', 'B', 'C'}
# Expressão condicional dentro da compreensão
situacao = ["aprovado" if n >= 7 else "reprovado" for n in [8.0, 5.5, 7.0]]
# ['aprovado', 'reprovado', 'aprovado']
# Compreensões aninhadas: "achatar" uma matriz representada como lista de listas
matriz = [[1, 2, 3], [4, 5, 6]]
[x for linha in matriz for x in linha] # [1, 2, 3, 4, 5, 6]
# Transposta
[[linha[j] for linha in matriz] for j in range(3)] # [[1, 4], [2, 5], [3, 6]]Trocando os colchetes por parênteses obtém-se uma expressão geradora (generator expression), que produz os valores sob demanda sem armazenar a lista inteira na memória — útil para somar milhões de termos: sum(x ** 2 for x in range(10**7)).
4.6 Estruturas de Controle de Fluxo
4.6.1 Condicionais (if-elif-else)
Para tomar decisões lógicas, Python utiliza as palavras-chaves if (se), elif (senão se) e else (senão), sempre acompanhadas de indentação rigorosa:
nota = 8.5
if nota >= 7.0:
print("Aprovado")
elif nota >= 5.0:
print("Recuperação")
else:
print("Reprovado")Os operadores lógicos em Python são palavras: and, or e not (todos de curto-circuito). Comparações podem ser encadeadas como na matemática: 0 <= p <= 1 equivale a 0 <= p and p <= 1. Além disso, todo objeto possui um valor de verdade: são considerados falsos False, None, 0, 0.0, a string vazia "" e coleções vazias ([], {}, (), set()); todo o resto é verdadeiro. Assim, if lista: é a forma idiomática de testar se uma lista não está vazia.
4.6.2 Laços de Repetição (Loops)
Para repetir blocos de instruções, usamos o laço for (para percorrer itens de uma coleção) ou while (enquanto uma condição for verdadeira):
# Loop for percorrendo uma lista de idades
for idade in idades:
if idade >= 18:
print(f"Idade {idade} é maior de idade.")O for do Python percorre qualquer objeto iterável. Algumas funções auxiliares tornam os laços mais expressivos:
for i in range(3): # 0, 1, 2 (range(inicio, fim, passo))
print(i)
nomes = ["Ana", "Bruno", "Carla"]
notas = [8.0, 6.5, 9.0]
for i, nome in enumerate(nomes, start=1): # índice e valor
print(i, nome)
for nome, nota in zip(nomes, notas): # percorre em paralelo
print(f"{nome}: {nota}")
# while com break: primeira potência de 2 maior que 1000
k = 0
while True:
if 2 ** k > 1000:
break
k += 1
print(k) # 10, pois 2^10 = 10244.7 Funções
4.7.1 Definição, Argumentos e Retorno
Funções são definidas com def, e o valor é retornado com return (se omitido, a função retorna None). É boa prática documentar a função com uma docstring — a string logo abaixo do def, exibida por help().
def coef_variacao(valores, percentual=True):
"""Calcula o coeficiente de variação amostral (desvio padrão / média)."""
n = len(valores)
media = sum(valores) / n
var = sum((x - media) ** 2 for x in valores) / (n - 1)
cv = var ** 0.5 / media
return 100 * cv if percentual else cv
coef_variacao([2, 4, 4, 5, 10]) # 60.0
coef_variacao([2, 4, 4, 5, 10], percentual=False) # 0.6
help(coef_variacao)O resultado coincide com o obtido no Capítulo de Introdução à Linguagem R: para \((2, 4, 4, 5, 10)\), \(\bar{x} = 5\), \(s^2 = 36/4 = 9\), \(s = 3\) e \(\mathrm{CV} = 3/5 = 60\%\).
Os argumentos podem ser passados por posição ou por nome (keyword arguments). Além disso, Python oferece dois mecanismos para um número variável de argumentos:
*argsreúne os argumentos posicionais extras em uma tupla;**kwargsreúne os argumentos nomeados extras em um dicionário.
def resumo(*args, **kwargs):
print("args =", args)
print("kwargs =", kwargs)
resumo(1, 2, 3, metodo="media", arredondar=2)
# args = (1, 2, 3)
# kwargs = {'metodo': 'media', 'arredondar': 2}
def media_ponderada(*valores, pesos=None):
if pesos is None:
pesos = [1] * len(valores)
return sum(v * p for v, p in zip(valores, pesos)) / sum(pesos)
media_ponderada(7, 8, 10, pesos=[2, 3, 5]) # (14 + 24 + 50) / 10 = 8.8
# Os operadores * e ** também "desempacotam" coleções na chamada
config = {"pesos": [1, 1, 2]}
media_ponderada(*[6, 8, 10], **config) # (6 + 8 + 20) / 4 = 8.5O valor padrão de um argumento é avaliado uma única vez, quando a função é definida. Se ele for mutável, as modificações persistem entre chamadas:
def adiciona(item, lista=[]): # ERRADO
lista.append(item)
return lista
adiciona(1) # [1]
adiciona(2) # [1, 2] -> a mesma lista foi reutilizada!A solução idiomática é usar None como padrão e criar a lista dentro da função (if lista is None: lista = []), como feito em media_ponderada acima.
4.7.2 Funções Anônimas (lambda) e Funções de Ordem Superior
Uma expressão lambda cria uma função pequena e anônima, restrita a uma única expressão. Ela é usada sobretudo como argumento de funções de ordem superior — funções que recebem outras funções —, como sorted, map, filter, max e min:
alunos = [("Ana", 8.0), ("Bruno", 6.5), ("Carla", 9.0)]
sorted(alunos, key=lambda t: t[1], reverse=True)
# [('Carla', 9.0), ('Ana', 8.0), ('Bruno', 6.5)]
max(alunos, key=lambda t: t[1]) # ('Carla', 9.0)
list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]
list(filter(lambda x: x > 1, [0, 1, 2, 3])) # [2, 3]
from functools import reduce
reduce(lambda acc, x: acc * x, [1, 2, 3, 4]) # 24 = 4!4.7.3 Escopo de Variáveis: a Regra LEGB
Quando um nome é usado, o Python o procura em quatro escopos, nesta ordem (regra LEGB): Local (dentro da função), Enclosing (funções envolventes), Global (módulo) e Built-in (nomes nativos, como len e print). Assim como no R, o escopo é léxico: o que importa é onde a função foi definida. Para atribuir a uma variável global ou envolvente dentro de uma função, é preciso declará-la com global ou nonlocal — o que, em geral, deve ser evitado.
taxa = 0.1 # global
def aplica_juros(valor):
return valor * (1 + taxa) # lê a variável global
def fabrica_multiplicador(k):
def multiplicar(x):
return k * x # 'k' vem do escopo envolvente (closure)
return multiplicar
dobro = fabrica_multiplicador(2)
dobro(21) # 424.7.4 Iteradores e Geradores: Avaliação Preguiçosa
O laço for do Python funciona sobre qualquer objeto iterável, mas o que exatamente isso significa? A resposta está no protocolo de iteração, um dos pilares do modelo de dados da linguagem:
- um iterável é um objeto com o método
__iter__(), que retorna um iterador; - um iterador é um objeto com o método
__next__(), que retorna o próximo valor a cada chamada e levanta a exceçãoStopIterationquando os valores se esgotam.
O comando for x in colecao: é, na verdade, uma abreviação do seguinte procedimento:
it = iter([10, 20, 30]) # chama colecao.__iter__()
next(it), next(it), next(it) # (10, 20, 30) -> chama it.__next__()
next(it) # StopIteration: o laço for captura essa exceção e terminaImplementando os dois métodos, qualquer classe pode ser percorrida por um for:
class Contagem:
def __init__(self, inicio, fim):
self.atual, self.fim = inicio, fim
def __iter__(self):
return self
def __next__(self):
if self.atual > self.fim:
raise StopIteration
valor = self.atual
self.atual += 1
return valor
list(Contagem(1, 4)) # [1, 2, 3, 4]Escrever classes iteradoras é trabalhoso. As funções geradoras oferecem uma alternativa elegante: qualquer função que contenha a palavra-chave yield torna-se uma fábrica de iteradores. A cada next(), a função executa até o próximo yield, entrega o valor e suspende sua execução, preservando todas as variáveis locais até ser retomada. Esse mecanismo implementa a avaliação preguiçosa (lazy evaluation): os valores são produzidos sob demanda, um de cada vez, e nunca precisam estar todos na memória — o que permite inclusive representar sequências infinitas:
from itertools import islice
def fibonacci():
a, b = 0, 1
while True: # sequência infinita!
yield a
a, b = b, a + b
list(islice(fibonacci(), 10)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]A economia de memória é drástica. Uma expressão geradora ocupa um tamanho constante, independentemente do número de elementos que irá produzir:
import sys
sys.getsizeof(x ** 2 for x in range(10 ** 6)) # 208 bytes
sys.getsizeof([x ** 2 for x in range(10 ** 6)]) # 8448728 bytes (cerca de 8 MB)Em termos de complexidade, uma lista de \(n\) elementos exige memória \(O(n)\), enquanto um gerador exige \(O(1)\). Isso é fundamental para processar arquivos maiores que a memória disponível: iterar sobre um arquivo aberto (for linha in f:) lê uma linha por vez, porque objetos de arquivo são iteradores.
Exemplo resolvido: média e variância em fluxo com o algoritmo de Welford. Suponha que as observações chegam uma a uma (por exemplo, leituras de um sensor) e queremos atualizar a média e a variância a cada nova observação, sem armazenar os dados e sem a instabilidade da fórmula de uma passada. O algoritmo de Welford (1962) mantém três quantidades: o número de observações \(n\), a média corrente \(\bar{x}_n\) e a soma dos quadrados dos desvios \(M_n = \sum_{i=1}^n (x_i - \bar{x}_n)^2\), atualizadas por \[ \bar{x}_n = \bar{x}_{n-1} + \frac{x_n - \bar{x}_{n-1}}{n}, \qquad M_n = M_{n-1} + (x_n - \bar{x}_{n-1})(x_n - \bar{x}_n), \] de modo que \(s_n^2 = M_n/(n-1)\). A atualização da média segue de \(n\bar{x}_n = (n-1)\bar{x}_{n-1} + x_n\). Um gerador é a estrutura natural para esse cálculo:
def welford(fluxo):
"""Gera (n, média, variância amostral) a cada nova observação."""
n, media, m2 = 0, 0.0, 0.0
for x in fluxo:
n += 1
delta = x - media # desvio em relação à média ANTIGA
media += delta / n
m2 += delta * (x - media) # produto com o desvio em relação à média NOVA
yield n, media, (m2 / (n - 1) if n > 1 else float("nan"))
for estado in welford([2, 4, 4, 5, 10]):
print(estado)
# (1, 2.0, nan)
# (2, 3.0, 2.0)
# (3, 3.3333333333333335, 1.3333333333333333)
# (4, 3.75, 1.5833333333333333)
# (5, 5.0, 9.0)Verificando o segundo passo à mão: com \(\bar{x}_1 = 2\) e \(M_1 = 0\), a chegada de \(x_2 = 4\) dá \(\bar{x}_2 = 2 + (4 - 2)/2 = 3\) e \(M_2 = 0 + (4 - 2)(4 - 3) = 2\), logo \(s_2^2 = 2/1 = 2\), que é de fato a variância de \(\{2, 4\}\). Ao final, recuperamos \(\bar{x} = 5\) e \(s^2 = 9\), como no capítulo de R. Mais importante, o algoritmo é estável: aplicado ao conjunto problemático da seção anterior, list(welford(x))[-1] retorna (4, 1000000010.0, 30.0) — a resposta exata, onde a fórmula ingênua produzia \(-170{,}67\).
O módulo itertools da biblioteca padrão reúne geradores muito úteis para combinatória e manipulação de sequências:
import itertools
list(itertools.accumulate([1, 2, 3, 4])) # [1, 3, 6, 10] (somas acumuladas)
list(itertools.combinations("ABC", 2)) # [('A','B'), ('A','C'), ('B','C')]
len(list(itertools.permutations(range(4), 2))) # 12 = 4!/(4-2)!
list(itertools.product([0, 1], repeat=2)) # [(0,0), (0,1), (1,0), (1,1)]4.7.5 Decoradores
Como funções são objetos de primeira classe, uma função pode receber outra função e retornar uma nova função que “envolve” a original, acrescentando comportamento antes ou depois de sua execução. Esse padrão é tão comum que o Python lhe dedica uma sintaxe própria, o decorador: escrever
@decorador
def f(x): ...é exatamente o mesmo que escrever f = decorador(f). O exemplo a seguir cria um decorador que mede o tempo de execução de qualquer função; functools.wraps preserva o nome e a docstring da função original:
import functools, time
def cronometrar(func):
@functools.wraps(func)
def envoltorio(*args, **kwargs):
t0 = time.perf_counter()
resultado = func(*args, **kwargs)
print(f"{func.__name__} levou {time.perf_counter() - t0:.4f} s")
return resultado
return envoltorio
@cronometrar
def soma_quadrados(n):
"""Soma dos quadrados de 0 a n-1."""
return sum(i * i for i in range(n))
soma_quadrados(10 ** 6)
# soma_quadrados levou 0.0223 s
# 333332833333500000O resultado pode ser conferido pela fórmula \(\sum_{i=0}^{n-1} i^2 = \frac{(n-1)n(2n-1)}{6}\), que para \(n = 10^6\) dá \(333\,332\,833\,333\,500\,000\). Os decoradores já apareceram neste capítulo (@property, @dataclass) e são onipresentes em bibliotecas: validação de argumentos, registro de logs, controle de acesso, cache e definição de rotas em aplicações web.
Exemplo resolvido: memoização e complexidade. A definição recursiva da sequência de Fibonacci, \(F_n = F_{n-1} + F_{n-2}\) com \(F_0 = 0\) e \(F_1 = 1\), traduz-se diretamente em código, mas a implementação ingênua recalcula os mesmos valores um número exponencial de vezes. Seja \(C(n)\) o número de chamadas necessárias para calcular \(F_n\); então \(C(0) = C(1) = 1\) e \(C(n) = 1 + C(n-1) + C(n-2)\). Por indução, verifica-se que \(C(n) = 2F_{n+1} - 1\): de fato, \(1 + (2F_n - 1) + (2F_{n-1} - 1) = 2(F_n + F_{n-1}) - 1 = 2F_{n+1} - 1\). Como \(F_n\) cresce como \(\varphi^n/\sqrt{5}\), com \(\varphi = (1 + \sqrt{5})/2 \approx 1{,}618\) (a razão áurea), o custo é exponencial. Para \(n = 25\), \(C(25) = 2F_{26} - 1 = 2 \times 121\,393 - 1 = 242\,785\) chamadas:
chamadas = 0
def fib(n):
global chamadas
chamadas += 1
return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(25), chamadas # (75025, 242785)A memoização consiste em guardar os resultados já calculados, de modo que cada \(F_k\) seja calculado uma única vez, reduzindo o custo para \(O(n)\). O decorador functools.lru_cache faz isso automaticamente:
@functools.lru_cache(maxsize=None)
def fib_memo(n):
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)
fib_memo(25) # 75025
fib_memo.cache_info() # CacheInfo(hits=23, misses=26, maxsize=None, currsize=26)
fib_memo(200) # 280571172992510140037611932413038677189525 (instantâneo)As 26 “falhas” (misses) correspondem ao cálculo de \(F_0, F_1, \dots, F_{25}\), cada um exatamente uma vez. Note também que \(F_{200}\) tem 42 dígitos e é calculado exatamente, graças aos inteiros de precisão arbitrária.
4.7.6 Anotações de Tipo e Dataclasses
As anotações de tipo apresentadas anteriormente foram formalizadas na PEP 484 (Rossum, Lehtosalo, e Langa 2014) e se tornaram parte essencial do Python moderno. Elas não alteram a execução do programa — o interpretador apenas as armazena no atributo __annotations__ —, mas permitem que verificadores estáticos, como o mypy, e os editores de código detectem erros antes da execução:
from typing import Optional
def media(valores: list[float]) -> float:
return sum(valores) / len(valores)
def busca(cadastro: dict[str, int], nome: str) -> Optional[int]:
return cadastro.get(nome) # retorna int ou None
media.__annotations__ # {'valores': list[float], 'return': <class 'float'>}
media(["a", "b"]) # o interpretador só falha AO EXECUTAR (TypeError);
# o mypy acusa o erro de tipo sem executar o códigoA tipagem estática opcional (ou gradual) busca combinar a flexibilidade da tipagem dinâmica, útil na exploração interativa, com a segurança da tipagem estática, valiosa em código de produção. Os tipos mais usados são int, float, str, bool, os genéricos list[T], dict[K, V], tuple[T1, T2], a união int | None (equivalente a Optional[int]) e Callable, para funções.
As anotações também alimentam o decorador @dataclass, que gera automaticamente os métodos especiais de classes que servem essencialmente para guardar dados. A opção frozen=True torna as instâncias imutáveis (e, portanto, utilizáveis como chaves de dicionário), e order=True gera os métodos de comparação, comparando os campos na ordem em que foram declarados:
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Ponto:
x: float
y: float = 0.0
@dataclass(order=True)
class Aluno:
nota: float
nome: str = field(compare=False) # ignorado nas comparações
p = Ponto(1.0, 2.0)
p, p == Ponto(1.0, 2.0) # (Ponto(x=1.0, y=2.0), True)
p.x = 5 # FrozenInstanceError
sorted([Aluno(8.0, "Ana"), Aluno(6.5, "Bruno"), Aluno(9.0, "Carla")])
# [Aluno(nota=6.5, nome='Bruno'), Aluno(nota=8.0, nome='Ana'), Aluno(nota=9.0, nome='Carla')]4.8 Módulos, Pacotes e Ambientes
4.8.1 Módulos e Pacotes
Um módulo é simplesmente um arquivo .py; um pacote é um diretório que agrupa módulos (tradicionalmente contendo um arquivo __init__.py). A biblioteca padrão do Python — o lema é “baterias incluídas” — oferece centenas de módulos: math, statistics, random, datetime, csv, json, pathlib, collections, itertools, entre outros.
import math # importa o módulo inteiro
import numpy as np # importa com apelido (convenção)
from statistics import mean, median # importa nomes específicos
from collections import Counter
math.sqrt(16) # 4.0
mean([1, 2, 3, 4]), median([1, 3, 2, 10]) # (2.5, 2.5)
Counter("abracadabra").most_common(2) # [('a', 5), ('b', 2)]Evite from modulo import *, que despeja todos os nomes no escopo atual e pode sobrescrever outros nomes silenciosamente — exatamente o problema de “mascaramento” discutido no capítulo de R. Ao escrever seus próprios módulos, use o idioma abaixo para que um trecho seja executado apenas quando o arquivo é rodado diretamente (e não quando é importado):
# arquivo: estatisticas.py
def amplitude(valores):
return max(valores) - min(valores)
if __name__ == "__main__":
print(amplitude([3, 9, 1, 7])) # executado só com: python estatisticas.py4.8.2 Gerenciadores de Pacotes e Ambientes Virtuais
Pacotes de terceiros são distribuídos pelo PyPI (Python Package Index) e instalados com o pip. Como diferentes projetos podem exigir versões diferentes do mesmo pacote, recomenda-se isolar cada projeto em um ambiente virtual — um diretório com seu próprio interpretador e seus próprios pacotes. O papel é análogo ao do renv no R.
# Ambiente virtual com o módulo nativo venv
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
pip install numpy pandas matplotlib jupyter
pip freeze > requirements.txt # registra versões exatas
pip install -r requirements.txt # recria o ambiente em outra máquina
deactivateA distribuição Anaconda (e sua versão mínima, Miniconda) traz o gerenciador conda, que instala pacotes Python e também bibliotecas de outras linguagens (como as bibliotecas de álgebra linear em C/Fortran), resolvendo dependências binárias — o que historicamente facilitou a instalação do ecossistema científico, sobretudo no Windows.
conda create -n cdados python=3.12 numpy pandas jupyter
conda activate cdados
conda env export > environment.ymlNunca instale pacotes diretamente no Python do sistema operacional. Crie um ambiente por projeto, registre as dependências em requirements.txt ou environment.yml e versione esse arquivo com Git, conforme visto no Capítulo de Controle de Versão com Git e GitHub.
4.9 Tratamento de Exceções
Erros em tempo de execução em Python são exceções: objetos que interrompem o fluxo normal e “sobem” pela pilha de chamadas até serem tratados ou encerrarem o programa. Exceções comuns são ValueError (valor inadequado), TypeError (tipo inadequado), KeyError (chave inexistente), IndexError (índice fora do intervalo), ZeroDivisionError e FileNotFoundError. O tratamento é feito com o bloco try/except, que admite as cláusulas opcionais else (executada se não houve exceção) e finally (executada sempre):
def converte_idade(texto):
try:
idade = int(texto)
except ValueError:
print(f"'{texto}' não é um inteiro válido.")
return None
else:
if idade < 0:
raise ValueError("Idade não pode ser negativa.")
return idade
finally:
print("Conversão tentada.") # sempre executa
[converte_idade(t) for t in ["25", "vinte", "40"]]
# [25, None, 40]Seguindo o princípio “é mais fácil pedir perdão do que permissão” (Easier to Ask for Forgiveness than Permission, EAFP), o estilo pythônico costuma tentar a operação e tratar a exceção, em vez de testar previamente todas as condições. Por outro lado, deve-se capturar apenas as exceções esperadas: um except: genérico esconde erros de programação e dificulta a depuração. Também é possível definir exceções próprias, herdando de Exception:
class DadosInsuficientesError(Exception):
"""Levantada quando não há observações suficientes para o cálculo."""
def variancia(valores):
if len(valores) < 2:
raise DadosInsuficientesError("São necessárias ao menos 2 observações.")
m = sum(valores) / len(valores)
return sum((x - m) ** 2 for x in valores) / (len(valores) - 1)4.10 Leitura e Escrita de Arquivos
Arquivos são abertos com open(), preferencialmente dentro de um bloco with (um gerenciador de contexto), que garante o fechamento do arquivo mesmo que ocorra uma exceção. O modo indica a operação: "r" (leitura), "w" (escrita, sobrescreve), "a" (acréscimo). Sempre especifique a codificação para evitar problemas com acentos.
from pathlib import Path
import csv
import json
pasta = Path("dados")
pasta.mkdir(exist_ok=True)
# Escrita de texto
with open(pasta / "notas.csv", "w", encoding="utf-8", newline="") as f:
escritor = csv.writer(f)
escritor.writerow(["aluno", "nota"])
escritor.writerows([["Ana", 8.0], ["Bruno", 6.5], ["Carla", 9.0]])
# Leitura linha a linha como dicionários
with open(pasta / "notas.csv", encoding="utf-8") as f:
leitor = csv.DictReader(f)
notas = {linha["aluno"]: float(linha["nota"]) for linha in leitor}
print(notas) # {'Ana': 8.0, 'Bruno': 6.5, 'Carla': 9.0}
# JSON: formato natural para dicionários e listas aninhados
with open(pasta / "notas.json", "w", encoding="utf-8") as f:
json.dump(notas, f, ensure_ascii=False, indent=2)Na prática da ciência de dados, arquivos tabulares são lidos quase sempre com o pandas (pd.read_csv, pd.read_excel, pd.read_parquet), apresentado mais adiante; os módulos nativos acima são úteis para formatos não tabulares e para entender o que acontece “por baixo”.
4.11 Expressões Regulares e Datas
4.11.1 O Módulo re
As expressões regulares, cuja sintaxe e fundamentos teóricos foram apresentados no capítulo de R (Friedl 2006), estão disponíveis no módulo re da biblioteca padrão. A sintaxe dos padrões é praticamente a mesma; a diferença prática é que, em Python, os padrões costumam ser escritos como strings brutas (prefixo r), nas quais a barra invertida não precisa ser duplicada: r"\d{3}" em vez de "\\d{3}". As principais funções são:
| Função | Retorno |
|---|---|
re.search(p, s) |
Primeira ocorrência em qualquer posição (objeto Match ou None) |
re.match(p, s) |
Ocorrência apenas no início de s |
re.fullmatch(p, s) |
s inteira deve casar com o padrão (útil para validação) |
re.findall(p, s) |
Lista com todas as ocorrências |
re.sub(p, r, s) |
Nova string com as ocorrências substituídas por r |
re.split(p, s) |
Lista com os pedaços de s separados pelo padrão |
re.compile(p) |
Padrão pré-compilado, reutilizável e mais eficiente em laços |
Retomando o exemplo dos CPFs:
import re
cad = ["CPF 123.456.789-09 cadastrado", "sem documento",
"CPFs: 987.654.321-00 e 111.222.333-44"]
pad = re.compile(r"\d{3}\.\d{3}\.\d{3}-\d{2}")
[bool(pad.search(s)) for s in cad] # [True, False, True]
[pad.findall(s) for s in cad]
# [['123.456.789-09'], [], ['987.654.321-00', '111.222.333-44']]
# Grupos nomeados: (?P<nome>...)
m = re.search(r"(?P<dia>\d{2})/(?P<mes>\d{2})/(?P<ano>\d{4})", "Coleta em 05/03/2024")
m.group(0), m.group("ano") # ('05/03/2024', '2024')
m.groupdict() # {'dia': '05', 'mes': '03', 'ano': '2024'}
m.span() # (10, 20) -> posições de início e fim (semiaberto)
re.split(r"\s*[;,]\s*", "a ; b,c ;d") # ['a', 'b', 'c', 'd']
re.sub(r"(\w+) (\w+)", r"\2, \1", "Jodavid Ferreira") # 'Ferreira, Jodavid'Um conceito importante é a diferença entre quantificadores gulosos (greedy) e preguiçosos (lazy). Por padrão, * e + casam com o maior trecho possível; acrescentando ? (*?, +?), passam a casar com o menor:
re.findall(r"<.+>", "<a><b>") # ['<a><b>'] (guloso)
re.findall(r"<.+?>", "<a><b>") # ['<a>', '<b>'] (preguiçoso)No pandas, os métodos do acessor .str (str.contains, str.extract, str.replace) aceitam expressões regulares e operam de forma vetorizada sobre colunas inteiras, como o stringr no R.
4.11.2 Datas e Horas com datetime
O módulo datetime fornece as classes date (data), datetime (data e hora) e timedelta (diferença entre instantes). A conversão entre texto e data é feita por strptime (string parse time) e strftime (string format time), com os mesmos códigos de formato do R (%d, %m, %Y, %H, %M):
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
d1, d2 = date(2024, 3, 5), date(2024, 12, 25)
(d2 - d1).days # 295 (o mesmo resultado obtido no capítulo de R)
d1 + timedelta(days=30) # datetime.date(2024, 4, 4)
d1.isoweekday() # 2 -> terça-feira (segunda = 1, domingo = 7)
dt = datetime.strptime("05/03/2024 14:30", "%d/%m/%Y %H:%M")
dt.strftime("%Y-%m-%d") # '2024-03-05'
dt.isoformat() # '2024-03-05T14:30:00'
rec = datetime(2024, 3, 5, 14, 30, tzinfo=ZoneInfo("America/Recife"))
rec.astimezone(ZoneInfo("UTC")) # 2024-03-05 17:30:00+00:00
rec.timestamp() # 1709659800.0 -> segundos desde 1970-01-01 UTCO último resultado mostra que o Python usa a mesma representação interna do POSIXct do R: segundos desde a época Unix. Um datetime sem fuso horário é chamado de ingênuo (naive); com fuso, de consciente (aware). Misturar os dois tipos em uma operação gera erro — mais uma aplicação do princípio “explícito é melhor que implícito”.
Exemplo resolvido: idade em anos completos. A idade de quem nasceu em 15/08/2001, na data de referência 05/03/2024, é a diferença entre os anos, menos 1 se o aniversário ainda não ocorreu naquele ano. A comparação de tuplas (mês, dia) é lexicográfica (compara o mês e, em caso de empate, o dia), e o booleano resultante é usado como o inteiro 0 ou 1:
nasc, ref = date(2001, 8, 15), date(2024, 3, 5)
idade = ref.year - nasc.year - ((ref.month, ref.day) < (nasc.month, nasc.day))
idade # 22 -> 2024 - 2001 - 1, pois (3, 5) < (8, 15)
(ref - nasc).days # 8238No pandas, colunas de datas são convertidas com pd.to_datetime() e seus componentes são acessados pelo acessor .dt; as operações de calendário, como “somar um mês”, são feitas com pd.DateOffset, que, assim como o %m+% do lubridate, ajusta para o último dia válido do mês:
import pandas as pd
s = pd.to_datetime(pd.Series(["05/03/2024", "17/11/2023", "31/01/2024"]), format="%d/%m/%Y")
s.dt.year.tolist() # [2024, 2023, 2024]
s.dt.dayofweek.tolist() # [1, 4, 2] (segunda = 0)
(s.max() - s.min()).days # 109
pd.Timestamp("2024-01-31") + pd.DateOffset(months=1) # Timestamp('2024-02-29 00:00:00')4.12 Introdução à Programação Orientada a Objetos (POO)
A Programação Orientada a Objetos (POO) é um paradigma de desenvolvimento essencial para estruturar sistemas robustos de dados. Em vez de escrever códigos lineares passo a passo, estruturamos nossos programas em torno de classes e objetos que simulam entidades do mundo real (Leite et al. 2016).
Os quatro conceitos fundamentais da POO são:
- Classe: É o “molde” ou projeto conceitual a partir do qual os objetos são criados. Define quais propriedades (atributos) e quais ações (métodos) os objetos daquela classe terão.
- Objeto: É a instância física criada a partir de uma classe. Cada objeto tem seus próprios valores para os atributos definidos no molde da classe.
- Atributo: São as características ou variáveis pertencentes a uma classe (ex: nome, idade, salário).
- Método: São as funções definidas dentro da classe que representam as ações que o objeto pode realizar (ex: atualizar_cadastro(), calcular_bonus()).
4.12.1 Exemplo Prático de Classe e Objeto em Python
Vejamos como codificar uma classe simples representando um Cientista de Dados:
# Definição da Classe
class CientistaDados:
# O método especial __init__ é o construtor da classe, usado para inicializar atributos
def __init__(self, nome, linguagem_favorita, anos_experiencia):
self.nome = nome
self.linguagem_favorita = linguagem_favorita
self.anos_experiencia = anos_experiencia
# Definição de um método para realizar ação
def apresentar(self):
return f"Olá, meu nome é {self.nome}. Programo em {self.linguagem_favorita} há {self.anos_experiencia} anos."
# Instanciação dos Objetos (criando objetos físicos a partir da classe)
cientista_1 = CientistaDados("Alice", "Python", 5)
cientista_2 = CientistaDados("Bob", "R", 3)
# Chamando o método nos objetos criados
print(cientista_1.apresentar())
print(cientista_2.apresentar())O parâmetro self representa a própria instância: a chamada cientista_1.apresentar() é traduzida pelo interpretador em CientistaDados.apresentar(cientista_1). Atributos definidos via self são atributos de instância (cada objeto tem o seu); atributos definidos diretamente no corpo da classe são atributos de classe, compartilhados por todas as instâncias.
4.12.2 Os Pilares da POO
Além dos conceitos básicos, a POO se apoia em quatro princípios — frequentemente chamados de pilares — que justificam o seu uso na construção de sistemas grandes (Leite et al. 2016):
- Abstração: representar apenas as características relevantes de uma entidade para o problema em questão, ocultando detalhes irrelevantes.
- Encapsulamento: agrupar dados e os métodos que os manipulam, controlando o acesso ao estado interno do objeto e expondo uma interface bem definida.
- Herança: criar novas classes a partir de classes existentes, reutilizando e especializando seu comportamento (relação “é um”).
- Polimorfismo: permitir que objetos de classes diferentes respondam à mesma mensagem (chamada de método) de maneiras específicas.
4.12.2.1 Encapsulamento
Python não possui modificadores de acesso rígidos como private do Java. Em vez disso, usa convenções: um nome iniciado por um sublinhado (_saldo) indica um atributo “interno”, que não deve ser acessado fora da classe; dois sublinhados (__saldo) ativam a “desfiguração de nomes” (name mangling), dificultando o acesso acidental. O decorador @property permite expor um atributo de forma controlada, com validação:
class ContaBancaria:
def __init__(self, titular, saldo_inicial=0.0):
self.titular = titular
self._saldo = 0.0
self.depositar(saldo_inicial)
@property
def saldo(self): # leitura: conta.saldo
return self._saldo
def depositar(self, valor):
if valor < 0:
raise ValueError("Depósito deve ser não negativo.")
self._saldo += valor
def sacar(self, valor):
if valor > self._saldo:
raise ValueError("Saldo insuficiente.")
self._saldo -= valor
conta = ContaBancaria("Ana", 100)
conta.sacar(30)
conta.saldo # 70.0
conta.saldo = 1e6 # AttributeError: a propriedade não tem 'setter'4.12.2.2 Herança e Polimorfismo
Na herança, a classe filha (subclasse) herda atributos e métodos da classe mãe (superclasse) e pode sobrescrever métodos. A função super() dá acesso à implementação da superclasse. O polimorfismo aparece quando código genérico chama o mesmo método em objetos de classes diferentes. O exemplo a seguir modela distribuições de probabilidade, um tema familiar ao estudante de Estatística:
import math
class Distribuicao:
"""Classe base (abstrata) para distribuições de probabilidade."""
def __init__(self, nome):
self.nome = nome
def media(self):
raise NotImplementedError
def variancia(self):
raise NotImplementedError
def desvio_padrao(self):
return math.sqrt(self.variancia()) # reutilizado por todas as filhas
def __repr__(self):
return f"{self.nome}(média={self.media():.3f}, dp={self.desvio_padrao():.3f})"
class Bernoulli(Distribuicao):
def __init__(self, p):
super().__init__("Bernoulli")
self.p = p
def media(self):
return self.p # E[X] = p
def variancia(self):
return self.p * (1 - self.p) # Var[X] = p(1 - p)
class Exponencial(Distribuicao):
def __init__(self, taxa):
super().__init__("Exponencial")
self.taxa = taxa
def media(self):
return 1 / self.taxa # E[X] = 1/lambda
def variancia(self):
return 1 / self.taxa ** 2 # Var[X] = 1/lambda^2
modelos = [Bernoulli(0.3), Exponencial(2.0)]
for m in modelos: # polimorfismo: mesma chamada, comportamentos distintos
print(m)
# Bernoulli(média=0.300, dp=0.458)
# Exponencial(média=0.500, dp=0.500)
isinstance(modelos[0], Distribuicao) # True: um Bernoulli "é uma" DistribuiçãoConferindo à mão: para \(X \sim \text{Bernoulli}(0{,}3)\), \(\mathbb{E}[X] = 0{,}3\) e \(\operatorname{Var}(X) = 0{,}3 \times 0{,}7 = 0{,}21\), logo \(\sigma = \sqrt{0{,}21} \approx 0{,}458\). Para \(X \sim \text{Exp}(\lambda = 2)\), \(\mathbb{E}[X] = 1/\lambda = 0{,}5\) e \(\operatorname{Var}(X) = 1/\lambda^2 = 0{,}25\), logo \(\sigma = 0{,}5\).
Observe que o método desvio_padrao() foi escrito uma única vez na classe base e funciona para todas as subclasses — esse é o ganho da herança. A biblioteca scipy.stats organiza suas dezenas de distribuições exatamente dessa forma, e o scikit-learn usa a mesma ideia: todo modelo possui os métodos fit() e predict(), o que permite trocar uma árvore de decisão por um k-NN sem alterar o restante do código (conforme veremos no Capítulo de Introdução ao Aprendizado de Máquina).
4.12.2.3 Métodos Especiais
Métodos cujos nomes começam e terminam com dois sublinhados — os métodos especiais ou dunder methods (de double underscore) — permitem que objetos definidos pelo usuário se integrem à sintaxe da linguagem. Já vimos __init__ (construtor) e __repr__ (representação textual). Outros exemplos:
| Método | Acionado por | Exemplo |
|---|---|---|
__len__ |
len(obj) |
número de elementos |
__getitem__ |
obj[i] |
indexação |
__add__, __mul__ |
a + b, a * b |
sobrecarga de operadores |
__eq__, __lt__ |
a == b, a < b |
comparações |
__iter__ |
for x in obj |
iteração |
__call__ |
obj(x) |
objeto “chamável” como função |
class Vetor2D:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, outro):
return Vetor2D(self.x + outro.x, self.y + outro.y)
def __mul__(self, escalar):
return Vetor2D(self.x * escalar, self.y * escalar)
def __abs__(self): # norma euclidiana
return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self):
return f"Vetor2D({self.x}, {self.y})"
u, v = Vetor2D(1, 2), Vetor2D(2, 2)
u + v # Vetor2D(3, 4)
abs(u + v) # 5.0 -> sqrt(3^2 + 4^2)
u * 3 # Vetor2D(3, 6)É exatamente esse mecanismo que permite ao NumPy definir + e * elemento a elemento para arrays, e ao pandas definir df["coluna"] > 5. Para classes que servem basicamente para guardar dados, o decorador @dataclass (módulo dataclasses) gera automaticamente __init__, __repr__ e __eq__.
4.13 NumPy: Computação Numérica Vetorizada
O NumPy (Numerical Python) é a base de praticamente todo o ecossistema científico do Python (Harris et al. 2020). Seu objeto central é o ndarray, um array \(n\)-dimensional homogêneo, armazenado em um bloco contíguo de memória — muito semelhante ao vetor atômico e à matriz do R. Um ndarray é caracterizado por:
dtype: o tipo dos elementos (int64,float64,bool, …);shape: uma tupla com o tamanho de cada dimensão (eixo);ndim: o número de dimensões.
import numpy as np
x = np.array([2, 4, 4, 5, 10])
x.dtype, x.shape, x.ndim # (dtype('int64'), (5,), 1)
A = np.array([[1, 2, 3],
[4, 5, 6]])
A.shape # (2, 3)
A[1, 2] # 6 (linha 1, coluna 2, base 0)
A[:, 0] # array([1, 4]) -> primeira coluna
A.T # transposta (3 x 2)
np.zeros((2, 3)), np.ones(4), np.eye(3) # matrizes especiais
np.arange(0, 1, 0.25) # array([0. , 0.25, 0.5 , 0.75])
np.linspace(0, 10, 3) # array([ 0., 5., 10.])
np.arange(6).reshape(2, 3) # reorganiza em 2 x 3 (por linhas)Por padrão, o NumPy armazena matrizes por linhas (row-major, convenção da linguagem C), enquanto o R as armazena por colunas (column-major, convenção do Fortran). Por isso, np.arange(6).reshape(2, 3) produz [[0, 1, 2], [3, 4, 5]], ao passo que matrix(0:5, nrow = 2) no R produz as colunas (0, 1), (2, 3) e (4, 5).
4.13.1 Vetorização e Funções Universais
As operações aritméticas e as funções universais (ufuncs) do NumPy (np.sqrt, np.exp, np.log, …) atuam elemento a elemento, executando o laço em C. O mesmo cálculo da variância amostral feito no capítulo de R fica:
x = np.array([2, 4, 4, 5, 10])
media = x.mean() # 5.0
s2 = ((x - media) ** 2).sum() / (len(x) - 1) # 9.0
s2, x.var(ddof=1), x.std(ddof=1) # (9.0, 9.0, 3.0)ddof
Por padrão, np.var e np.std dividem por \(n\) (estimador de máxima verossimilhança sob normalidade), e não por \(n - 1\). Para obter a variância amostral não viesada, como var() do R, use ddof=1 (delta degrees of freedom): o divisor passa a ser \(n - \text{ddof}\). O pandas, por outro lado, usa ddof=1 por padrão.
A diferença de desempenho entre um laço em Python puro e a operação vetorizada é tipicamente de uma a duas ordens de grandeza:
import time
n = 1_000_000
dados = np.random.default_rng(42).normal(size=n)
t0 = time.perf_counter()
soma = 0.0
for v in dados:
soma += v * v
t_laco = time.perf_counter() - t0
t0 = time.perf_counter()
soma_vet = np.dot(dados, dados)
t_vet = time.perf_counter() - t0
print(f"laço: {t_laco:.3f} s | vetorizado: {t_vet:.5f} s")4.13.2 Indexação Booleana
Como no R, uma comparação gera um array lógico que pode ser usado como máscara:
notas = np.array([8.5, 4.0, 7.2, 5.9, 9.1])
aprovado = notas >= 6
notas[aprovado] # array([8.5, 7.2, 9.1])
aprovado.sum(), aprovado.mean() # (3, 0.6) -> contagem e proporção
notas[(notas > 5) & (notas < 9)] # array([8.5, 7.2, 5.9]) -> use & e |, com parênteses
np.where(notas >= 6, "A", "R") # análogo ao ifelse() do R4.13.3 Broadcasting
O broadcasting é a generalização do NumPy para a regra da reciclagem do R, porém com regras mais estritas e definidas sobre as dimensões. Para operar dois arrays de formatos diferentes, o NumPy compara seus shape da direita para a esquerda; duas dimensões são compatíveis se:
- forem iguais, ou
- uma delas for igual a 1 (essa dimensão é “esticada” virtualmente, sem cópia de memória).
Se um array tiver menos dimensões, acrescentam-se dimensões de tamanho 1 à esquerda. Caso alguma dimensão não seja compatível, ocorre um erro — ao contrário do R, não há reciclagem parcial.
Exemplo resolvido: centralização de colunas. Seja a matriz de dados \(\mathbf{X}\) (\(n = 3\) observações, \(p = 2\) variáveis):
\[ \mathbf{X} = \begin{pmatrix} 1 & 10 \\ 2 & 20 \\ 3 & 60 \end{pmatrix}, \qquad \bar{\mathbf{x}} = (2,\ 30). \]
X tem shape (3, 2) e o vetor de médias tem shape (2,), tratado como (1, 2). Comparando da direita para a esquerda: \(2 = 2\) (compatível) e \(3\) vs. \(1\) (compatível, o vetor é esticado em 3 linhas). Logo,
\[ \mathbf{X} - \mathbf{1}\bar{\mathbf{x}}^\top = \begin{pmatrix} 1-2 & 10-30 \\ 2-2 & 20-30 \\ 3-2 & 60-30 \end{pmatrix} = \begin{pmatrix} -1 & -20 \\ 0 & -10 \\ 1 & 30 \end{pmatrix}. \]
X = np.array([[1, 10],
[2, 20],
[3, 60]])
medias = X.mean(axis=0) # array([ 2., 30.]) -> média de cada coluna
Xc = X - medias # broadcasting (3,2) - (2,)
Xc
# array([[ -1., -20.],
# [ 0., -10.],
# [ 1., 30.]])
# Padronização (escore z) de cada coluna
Z = (X - X.mean(axis=0)) / X.std(axis=0, ddof=1)
# Para operar por LINHAS, o vetor precisa ter shape (3, 1)
medias_linhas = X.mean(axis=1, keepdims=True) # shape (3, 1)
X - medias_linhasA padronização de variáveis é um pré-processamento essencial para métodos baseados em distância, como o k-NN e o k-means, conforme veremos nos capítulos correspondentes.
4.13.4 O Modelo de Memória do NumPy: Strides, Visões e Cópias
Por trás da interface simples do ndarray há um modelo de memória engenhoso (Walt, Colbert, e Varoquaux 2011). Um array é composto por (i) um bloco de dados — uma região contígua de bytes — e (ii) metadados que dizem como interpretar esse bloco: o dtype, o shape e os strides (passos). O stride de um eixo é o número de bytes que se deve avançar na memória para ir de um elemento ao seguinte ao longo daquele eixo. Assim, se o array tem strides \((s_0, s_1)\), o elemento \([i, j]\) está no endereço \[
\text{endereço}(i, j) = \text{início} + i \cdot s_0 + j \cdot s_1 .
\]
A = np.arange(12, dtype=np.int64).reshape(3, 4) # 3 x 4, 8 bytes por elemento
A.strides # (32, 8): avançar uma linha = 4 elementos x 8 bytes
A.T.strides # (8, 32): a transposta apenas TROCA os strides!
A[:, ::2].strides # (32, 16): tomar colunas alternadas dobra o passo
A.flags["C_CONTIGUOUS"], A.T.flags["C_CONTIGUOUS"] # (True, False)Por exemplo, o elemento A[2, 3] (que vale 11) está a \(2 \times 32 + 3 \times 8 = 88\) bytes do início, ou seja, na posição \(88/8 = 11\) do bloco — coerente com a ordem por linhas. O fato notável é que transpor, fatiar e redimensionar um array podem ser feitos apenas alterando os metadados, sem mover nenhum byte. O resultado é uma visão (view): um novo objeto ndarray que compartilha o bloco de dados com o original. A regra prática é:
- fatiamento básico (
x[2:5],x[::2],A[:, 0]),reshape(quando possível),.Teravel()produzem visões; - indexação avançada — por lista de inteiros (
x[[2, 3, 4]]) ou por máscara booleana (x[x > 5]) — produz cópias, pois os elementos selecionados não podem, em geral, ser descritos por um passo constante.
Exemplo resolvido: visão versus cópia. Considere x = np.arange(10). A fatia x[2:5] e a seleção x[[2, 3, 4]] contêm os mesmos valores, mas se comportam de forma muito diferente quando modificadas:
x = np.arange(10)
v = x[2:5] # visão
v[0] = 100 # altera o bloco compartilhado...
x # array([ 0, 1, 100, 3, 4, 5, 6, 7, 8, 9])
c = x[[2, 3, 4]] # cópia (indexação avançada)
c[0] = -1 # altera apenas a cópia
x # inalterado: array([ 0, 1, 100, 3, ...])
np.shares_memory(x, v), np.shares_memory(x, c) # (True, False)
v.base is x # True -> v "pertence" a xEsse comportamento é deliberado — evitar cópias é essencial quando os arrays têm gigabytes —, mas é uma fonte comum de erros: modificar uma fatia altera o array original. Quando se deseja uma cópia independente, deve-se pedi-la explicitamente com .copy(). A mesma lógica explica o famoso aviso SettingWithCopyWarning do pandas, emitido quando não é possível saber se uma atribuição está sendo feita em uma visão ou em uma cópia de um DataFrame.
Outro detalhe sutil envolve operações no lugar. O operador += modifica o array existente, enquanto z = z + 1 cria um array novo e reatribui o nome:
z = np.zeros(3); w = z; w += 1; z # array([1., 1., 1.]) (mesmo objeto alterado)
z = np.zeros(3); w = z; w = w + 1; z # array([0., 0., 0.]) (w passou a ser outro objeto)4.13.4.1 Tipos de Dados e Seus Limites
Ao contrário dos inteiros do Python, os inteiros do NumPy têm tamanho fixo e, ao ultrapassar seus limites, “dão a volta” (wrap around) silenciosamente, segundo a aritmética modular \(2^b\) do complemento de dois, sem nenhum aviso — diferentemente do R, que retorna NA com aviso:
a8 = np.array([100, 120], dtype=np.int8) # int8: de -128 a 127
a8 + a8 # array([-56, -16], dtype=int8) (!)
np.iinfo(np.int64).max # 9223372036854775807 = 2^63 - 1Conferindo: \(100 + 100 = 200\), que excede 127; em 8 bits, \(200 - 256 = -56\). Da mesma forma, \(240 - 256 = -16\). A precisão do ponto flutuante também depende do dtype. O float32 (precisão simples, muito usado em aprendizado profundo por economizar memória) tem épsilon de máquina de cerca de \(1{,}19 \times 10^{-7}\), contra \(2{,}22 \times 10^{-16}\) do float64, e os erros se tornam visíveis rapidamente:
np.float64(np.float32(0.1)) # 0.10000000149011612
np.full(10**7, 0.1, dtype=np.float32).sum() # 999989.44 (exato: 1000000)
np.full(10**7, 0.1, dtype=np.float32).sum(dtype=np.float64) # 1000000.0149011612O último exemplo mostra uma boa prática: mesmo quando os dados estão em float32, os acumuladores de somas e médias devem usar float64. Por fim, como o array é homogêneo, misturar tipos força uma conversão para o tipo mais geral, como a coerção do R: np.array([1, True]).dtype é int64, e np.array([1, 2.5, "a"]) resulta em um array de strings (<U32).
4.13.5 Álgebra Linear
O submódulo np.linalg reúne as operações de álgebra linear. O operador @ realiza o produto matricial (o %*% do R), enquanto * é o produto elemento a elemento. Retomando o exemplo resolvido do capítulo de R, com \(\mathbf{A} = \begin{pmatrix} 2 & 1 \\ 1 & 3 \end{pmatrix}\) e \(\mathbf{b} = (5, 10)^\top\), cuja solução é \(\mathbf{x} = (1, 3)^\top\):
A = np.array([[2, 1],
[1, 3]])
b = np.array([5, 10])
np.linalg.det(A) # aprox. 5.0 (pode exibir pequeno erro de arredondamento)
np.linalg.inv(A) # [[ 0.6, -0.2], [-0.2, 0.4]]
np.linalg.solve(A, b) # array([1., 3.])
A @ A # [[ 5, 5], [ 5, 10]]
A * A # [[4, 1], [1, 9]] (Hadamard)
# Mínimos quadrados: beta = (X'X)^{-1} X'y
x = np.array([1, 2, 3, 4, 5])
y = np.array([2.1, 3.9, 6.2, 7.8, 10.1])
X = np.column_stack([np.ones_like(x), x])
beta = np.linalg.solve(X.T @ X, X.T @ y) # array([0.05, 1.99])4.13.6 Números Aleatórios
Para simulação, o NumPy recomenda o gerador moderno criado por np.random.default_rng(semente). Fixar a semente garante a reprodutibilidade:
rng = np.random.default_rng(2026)
amostra = rng.normal(loc=170, scale=10, size=1000) # N(170, 10^2)
moedas = rng.binomial(n=1, p=0.5, size=10)
rng.choice(["A", "B", "C"], size=5, replace=True)
# Lei dos Grandes Números: a média amostral se aproxima de mu = 170
amostra.mean()Os conceitos de gerador pseudoaleatório, semente e período, discutidos no capítulo de R, aplicam-se igualmente aqui. A diferença é o algoritmo: enquanto o R e o antigo np.random.seed() usam o Mersenne Twister (Matsumoto e Nishimura 1998), o default_rng() usa por padrão o PCG64, da família de geradores PCG (Permuted Congruential Generator) proposta por O’Neill (2014). A ideia do PCG é combinar um gerador congruencial linear de 128 bits — rápido, mas com bits de baixa ordem de qualidade ruim — com uma permutação dos bits de saída, que melhora drasticamente suas propriedades estatísticas. O gerador é um objeto independente, o que permite usar fluxos distintos em diferentes partes de um programa sem interferência, ao contrário do estado global de np.random.seed().
4.13.7 Distribuições de Probabilidade com scipy.stats
O SciPy (Virtanen et al. 2020) complementa o NumPy com algoritmos científicos: otimização, integração numérica, interpolação, álgebra linear avançada, processamento de sinais e, em especial, o módulo scipy.stats, com mais de cem distribuições de probabilidade e dezenas de testes estatísticos. Cada distribuição é uma classe que segue a mesma interface — um exemplo concreto do polimorfismo estudado na seção de POO. “Congelando” uma distribuição com seus parâmetros, obtém-se um objeto com os métodos:
Método scipy.stats |
Equivalente em R | Significado |
|---|---|---|
pmf(k) / pdf(x) |
dbinom, dnorm, … |
função de probabilidade / densidade |
cdf(x) |
pbinom, pnorm, … |
função de distribuição acumulada \(F(x)\) |
sf(x) |
p*(..., lower.tail = FALSE) |
função de sobrevivência \(1 - F(x)\) |
ppf(q) |
qbinom, qnorm, … |
função quantil \(F^{-1}(q)\) |
rvs(size) |
rbinom, rnorm, … |
geração de amostras |
mean(), var(), interval(c) |
— | momentos e intervalo central |
from scipy import stats
X = stats.binom(n=5, p=0.3)
X.pmf(2), X.cdf(2) # (0.3087, 0.83692) -> os mesmos valores do capítulo de R
X.mean(), X.var() # (1.5, 1.05) -> np e np(1 - p)
Z = stats.norm() # normal padrão
Z.cdf(1.96), Z.ppf(0.975) # (0.9750021048517795, 1.959963984540054)
stats.norm.interval(0.95) # (-1.959963984540054, 1.959963984540054)
stats.norm(loc=170, scale=10).sf(180) # 0.15865525393145707 = P(X > 180)
stats.expon(scale=1/2).cdf(1) # 0.8646647167633873 = 1 - e^{-2}Atenção à parametrização: no SciPy, as distribuições usam os parâmetros genéricos de locação (loc) e escala (scale). Para a exponencial com taxa \(\lambda\), a escala é \(1/\lambda\) — por isso stats.expon(scale=1/2) corresponde a pexp(x, rate = 2) no R. Confundir taxa e escala é um erro clássico.
Exemplo resolvido: o Teorema Central do Limite por simulação. Seja \(\bar{X}_{30}\) a média de 30 observações i.i.d. de uma \(\text{Exp}(1)\), que tem média 1 e variância 1. O TCL afirma que \(\bar{X}_{30}\) é aproximadamente normal com média 1 e desvio padrão \(1/\sqrt{30} \approx 0{,}1826\); portanto, cerca de 95% das médias devem estar a menos de \(1{,}96/\sqrt{30} \approx 0{,}358\) de 1. Com o NumPy, as 10.000 réplicas da simulação podem ser geradas de uma só vez, como uma matriz \(10\,000 \times 30\), e as médias calculadas por linha — sem nenhum laço:
rng = np.random.default_rng(7)
medias = rng.exponential(scale=1.0, size=(10_000, 30)).mean(axis=1)
medias.mean(), medias.std(ddof=1) # (0.99899..., 0.17925...)
np.mean(np.abs(medias - 1) <= 1.96 / np.sqrt(30)) # 0.9553A proporção observada, 95,5%, está muito próxima dos 95% previstos; a pequena diferença reflete tanto o erro de Monte Carlo quanto o fato de a aproximação normal não ser exata para \(n = 30\) com uma distribuição assimétrica.
Exemplo resolvido: estimando \(\pi\) com NumPy. O mesmo experimento de Monte Carlo do capítulo de R, agora vetorizado com NumPy:
rng = np.random.default_rng(42)
n = 1_000_000
u, v = rng.random(n), rng.random(n)
dentro = (u ** 2 + v ** 2) <= 1
pi_hat = 4 * dentro.mean()
ep = 4 * dentro.std(ddof=1) / np.sqrt(n)
pi_hat, ep # (3.142436, 0.0016415976001282044)
pi_hat - 1.96 * ep, pi_hat + 1.96 * ep # (3.1392..., 3.1457...) -> contém piNote que a estimativa difere da obtida em R (\(3{,}139992\)), pois os geradores e as sementes são diferentes, mas ambas são compatíveis com \(\pi\) dentro do erro de Monte Carlo — que, como esperado, é praticamente o mesmo nas duas linguagens.
4.14 pandas: Dados Tabulares
O pandas, criado por Wes McKinney a partir de 2008 (McKinney 2010, 2022), trouxe ao Python estruturas de dados tabulares semelhantes ao data frame do R. Ele é construído sobre o NumPy e possui duas estruturas principais:
Series: um vetor unidimensional rotulado — um array NumPy acompanhado de um índice (index);DataFrame: uma tabela bidimensional, formada por um dicionário deSeriesque compartilham o mesmo índice; cada coluna pode ter um tipo diferente.
import pandas as pd
s = pd.Series([25, 30, 22], index=["ana", "bia", "caio"], name="idade")
s["bia"] # 30
s[s > 24] # ana 25, bia 30
s.mean() # 25.666...
vendas = pd.DataFrame({
"vendedor": ["Ana", "Ana", "Bruno", "Bruno", "Carla", "Carla"],
"regiao": ["Norte", "Norte", "Sul", "Sul", "Norte", "Sul"],
"mes": [1, 2, 1, 2, 1, 2],
"valor": [100, 150, 200, 120, 80, 160],
})
vendas.shape # (6, 4)
vendas.dtypes # object, object, int64, int64As operações de inspeção e limpeza de dados (head, info, describe, tratamento de faltantes e outliers) são exploradas em detalhe no Capítulo de Análise Exploratória de Dados com Python. Aqui nos concentramos na lógica de seleção, agregação e combinação.
4.14.1 Seleção com loc e iloc
O pandas distingue claramente a seleção por rótulo da seleção por posição:
df.loc[linhas, colunas]seleciona por rótulos (nomes do índice e das colunas); fatias por rótulo incluem o limite final;df.iloc[linhas, colunas]seleciona por posições inteiras (base 0); fatias excluem o limite final, como em listas.
vendas["valor"] # uma coluna -> Series
vendas[["vendedor", "valor"]] # várias colunas -> DataFrame
vendas.loc[0:2, ["vendedor", "valor"]] # linhas com rótulo 0, 1 e 2 (inclusivo!)
vendas.iloc[0:2, [0, 3]] # linhas nas posições 0 e 1
# Filtragem booleana (equivalente ao filter() do dplyr)
vendas[(vendas["regiao"] == "Norte") & (vendas["valor"] >= 100)]
vendas.loc[vendas["valor"] > 120, "vendedor"]
vendas.query("regiao == 'Sul' and valor > 130")
# Criação de colunas (equivalente ao mutate())
vendas["valor_mil"] = vendas["valor"] / 1000
vendas = vendas.assign(alta=lambda d: d["valor"] >= 150)
# Ordenação (equivalente ao arrange())
vendas.sort_values(["regiao", "valor"], ascending=[True, False])4.14.2 Agrupamento com groupby
O método groupby implementa o mesmo paradigma dividir–aplicar–combinar estudado no capítulo de R com group_by() + summarise(). Com os mesmos dados, os totais por região são \(T_{\text{Norte}} = 100 + 150 + 80 = 330\) e \(T_{\text{Sul}} = 200 + 120 + 160 = 480\), com médias \(110\) e \(160\):
resumo = (
vendas
.groupby("regiao")
.agg(n=("valor", "size"),
total=("valor", "sum"),
media=("valor", "mean"),
maximo=("valor", "max"))
.reset_index()
)
# regiao n total media maximo
# 0 Norte 3 330 110.0 150
# 1 Sul 3 480 160.0 200
# transform: resultado com o mesmo tamanho do original (como mutate após group_by)
vendas["participacao"] = 100 * vendas["valor"] / vendas.groupby("regiao")["valor"].transform("sum")
# Tabela de contingência / tabela dinâmica
pd.crosstab(vendas["vendedor"], vendas["regiao"])
vendas.pivot_table(index="vendedor", columns="mes", values="valor", aggfunc="sum")Note o estilo de encadeamento de métodos (method chaining), envolvido em parênteses para permitir quebras de linha: ele cumpre em pandas o mesmo papel do pipe no R.
4.14.3 Junções com merge e Reestruturação
As junções seguem a mesma álgebra relacional vista no capítulo de R; o argumento how escolhe o tipo ("inner", "left", "right", "outer"), e indicator=True adiciona uma coluna indicando a origem de cada linha — útil para diagnosticar chaves sem correspondência. Já melt e pivot correspondem a pivot_longer e pivot_wider:
vendedores = pd.DataFrame({
"vendedor": ["Ana", "Bruno", "Diego"],
"admissao": [2019, 2021, 2023],
})
pd.merge(vendas, vendedores, on="vendedor", how="left") # Carla com admissao NaN
pd.merge(vendas, vendedores, on="vendedor", how="inner") # sem Carla
pd.merge(vendas, vendedores, on="vendedor", how="outer", indicator=True)
# Formato largo -> longo -> largo
notas_largo = pd.DataFrame({"aluno": ["Ana", "Bia", "Caio"],
"prova1": [7.0, 6.0, 8.0],
"prova2": [8.5, 9.0, 7.5]})
notas_longo = notas_largo.melt(id_vars="aluno", var_name="prova", value_name="nota")
notas_longo.groupby("prova")["nota"].mean() # prova1: 7.0, prova2: 8.333
notas_longo.pivot(index="aluno", columns="prova", values="nota")4.14.4 Valores Ausentes em pandas
O pandas representa dados ausentes principalmente por NaN (o Not a Number do padrão IEEE, herdado do NumPy) e, em tipos mais novos, por pd.NA. Diferentemente do R, as funções de resumo do pandas ignoram valores ausentes por padrão (skipna=True):
renda = pd.Series([2500, None, 3200, 4100])
renda.mean() # 3266.67 (ignora o ausente; no R seria NA sem na.rm = TRUE)
renda.isna().sum() # 1
renda.mean(skipna=False) # nan4.15 Desempenho e Testes Automatizados
4.15.1 Medindo o Desempenho
Assim como no R, a otimização deve ser guiada por medições (Gorelick e Ozsvald 2020). O módulo timeit executa um trecho muitas vezes e reporta o tempo, minimizando a influência de flutuações do sistema; no Jupyter, os comandos mágicos %timeit e %%timeit fazem o mesmo de forma mais conveniente. Para descobrir onde um programa gasta seu tempo, usa-se o perfilador cProfile (python -m cProfile -s cumtime script.py), que reporta o número de chamadas e o tempo gasto em cada função.
Exemplo resolvido: o custo da estrutura de dados e da vetorização. Vamos medir empiricamente duas afirmações teóricas deste capítulo: (i) o teste de pertinência é \(O(n)\) em listas e \(O(1)\) em conjuntos; (ii) somar com laço em Python puro é muito mais lento do que com a função nativa sum() e do que com o NumPy:
import timeit
import numpy as np
x = list(range(10 ** 5)); s = set(x); arr = np.arange(10 ** 5)
timeit.timeit("99_999 in x", globals=globals(), number=1000) / 1000 # 3.5e-04 s
timeit.timeit("99_999 in s", globals=globals(), number=1000) / 1000 # 1.6e-08 s
def soma_laco(v):
t = 0
for e in v:
t += e
return t
min(timeit.repeat("soma_laco(x)", globals=globals(), number=10, repeat=5)) / 10 # 1.1e-03 s
min(timeit.repeat("sum(x)", globals=globals(), number=10, repeat=5)) / 10 # 2.2e-04 s
min(timeit.repeat("arr.sum()", globals=globals(), number=10, repeat=5)) / 10 # 1.1e-05 sO teste de pertinência no conjunto é cerca de 20 mil vezes mais rápido que na lista (o valor procurado está no fim da lista, o pior caso da busca linear). Quanto à soma, a função nativa sum(), implementada em C, é cerca de 5 vezes mais rápida que o laço, e o NumPy é cerca de 100 vezes mais rápido que o laço — pois, além de evitar o interpretador, opera sobre um bloco contíguo de inteiros de 8 bytes, e não sobre ponteiros para objetos int espalhados pela memória. Os tempos absolutos variam de máquina para máquina, mas as razões entre eles são bastante estáveis. É comum reportar o mínimo de várias repetições, pois as flutuações (outros processos, cache) só podem aumentar o tempo, nunca diminuí-lo.
- Escolha a estrutura de dados e o algoritmo adequados (
set/dictpara buscas, ordenação \(O(n \log n)\) em vez de comparações \(O(n^2)\)). - Use funções nativas e da biblioteca padrão (
sum,max,sorted,collections.Counter), implementadas em C. - Vetorize com NumPy e pandas, evitando laços explícitos e
DataFrame.applycom funções Python linha a linha. - Para laços numéricos inevitáveis, compile o trecho crítico com Numba ou Cython.
- Só então considere paralelismo (
multiprocessing,joblib) ou bibliotecas para dados maiores que a memória (Polars, Dask, DuckDB).
4.15.2 Testes com pytest
O equivalente do testthat no ecossistema Python é o pytest (Okken 2022). Sua filosofia é a simplicidade: um teste é qualquer função cujo nome começa com test_, escrita em um arquivo cujo nome começa com test_, e as verificações são feitas com o comando nativo assert. Considere o módulo a seguir:
# arquivo: estatisticas.py
def variancia(valores):
"""Variância amostral (divisor n - 1)."""
n = len(valores)
if n < 2:
raise ValueError("São necessárias ao menos 2 observações.")
m = sum(valores) / n
return sum((x - m) ** 2 for x in valores) / (n - 1)Os testes ficam em um arquivo separado:
# arquivo: test_estatisticas.py
import pytest
from estatisticas import variancia
def test_exemplo_do_livro():
assert variancia([2, 4, 4, 5, 10]) == 9
def test_tolerancia_numerica():
# 0.009999999999999998 != 0.01: comparação exata falharia
assert variancia([0.1, 0.2, 0.3]) == pytest.approx(0.01)
def test_invariancia_por_translacao():
x = [2, 4, 4, 5, 10]
assert variancia([v + 1e6 for v in x]) == pytest.approx(variancia(x))
@pytest.mark.parametrize("dados", [[], [42]])
def test_poucos_dados(dados):
with pytest.raises(ValueError, match="ao menos 2"):
variancia(dados)Executando pytest no terminal, todos os testes são descobertos e executados automaticamente:
$ pytest -q
..... [100%]
5 passed in 0.00sCada ponto representa um teste aprovado (o teste parametrizado conta duas vezes, uma para cada entrada). Os exemplos ilustram as três categorias de testes discutidas no capítulo de R: um caso típico verificado à mão, casos de borda (listas vazias ou unitárias) e uma propriedade matemática — a invariância por translação, \(\operatorname{Var}(X + c) = \operatorname{Var}(X)\). O teste de tolerância mostra por que pytest.approx é indispensável: a variância de \((0{,}1;\ 0{,}2;\ 0{,}3)\) calculada em ponto flutuante é \(0{,}009999999999999998\), e não \(0{,}01\).
Além de detectar regressões, os testes documentam o comportamento esperado de uma função de forma precisa e sempre atualizada: ao contrário de um comentário, um teste desatualizado falha. Em projetos de ciência de dados, vale a pena testar especialmente as funções de limpeza e transformação de dados, nas quais erros silenciosos (uma junção que duplica linhas, uma conversão de unidade invertida) comprometem todas as análises posteriores.
4.16 O Ambiente Jupyter
O Jupyter Notebook — e sua interface mais moderna, o JupyterLab — é o ambiente interativo mais popular para ciência de dados em Python (Kluyver et al. 2016). O nome é uma referência às três linguagens inicialmente suportadas: Julia, Python e R. Um notebook (arquivo .ipynb, em formato JSON) é composto por células de dois tipos principais:
- células de código, executadas por um kernel (o processo do interpretador), cujas saídas — textos, tabelas, gráficos — aparecem logo abaixo;
- células de texto em Markdown (com suporte a equações LaTeX), conforme visto no Capítulo de Documentos Dinâmicos com Markdown e RMarkdown.
pip install jupyterlab
jupyter lab # abre a interface no navegadorAlguns recursos úteis: Shift + Enter executa a célula; funcao? exibe a documentação; %timeit expressao mede o tempo de execução; %matplotlib inline exibe gráficos no próprio notebook. Os notebooks também podem ser usados no VS Code e em serviços em nuvem, como o Google Colab.
Como as células podem ser executadas em qualquer ordem, o estado do kernel pode não corresponder ao que se lê de cima para baixo — uma variável pode ter sido apagada da célula, mas continuar na memória. Antes de compartilhar um notebook, use sempre “Restart Kernel and Run All Cells” para garantir que ele executa do início ao fim. Para análises longas e reprodutíveis, considere converter a lógica estável em módulos .py ou usar documentos Quarto.
4.16.1 Boas Práticas e Estilo (PEP 8)
O guia de estilo PEP 8 estabelece, entre outras, as seguintes convenções:
- nomes de variáveis e funções em
snake_case; classes emCapWords(CientistaDados); constantes emMAIUSCULAS; - indentação de 4 espaços e linhas de até 79 caracteres;
- espaços ao redor de operadores de atribuição e comparação, mas não em argumentos nomeados (
f(x, pesos=[1, 2])); - importações no início do arquivo, uma por linha, agrupadas em: biblioteca padrão, terceiros e módulos locais;
- comparações com
Noneusandois(if x is None), nunca==.
Ferramentas como ruff, flake8 e black verificam e formatam o código automaticamente.
4.17 Resumo do Capítulo
- R e Python são complementares: R nasceu para a estatística; Python é uma linguagem de uso geral com um ecossistema científico maduro (NumPy, pandas, scikit-learn).
- Linguagens de alto nível escondem os detalhes do hardware; o CPython compila o código para bytecode e o interpreta numa máquina virtual, o que torna laços puros lentos e justifica a vetorização.
- Criado por Guido van Rossum (1991), o Python é guiado pelo Zen do Python (PEP 20) e pelo guia de estilo PEP 8; é multiparadigma (imperativo, orientado a objetos e funcional).
- A tipagem é dinâmica (tipos pertencem aos valores) e forte (sem coerção implícita entre tipos incompatíveis). Variáveis são referências a objetos, que podem ser mutáveis ou imutáveis.
- As estruturas nativas
list,tuple,dictesetdiferem em ordem, mutabilidade e custo das operações; dicionários e conjuntos usam tabelas hash com busca \(O(1)\) em média. Indexação começa em 0, e fatias são intervalos semiabertos. - Compreensões traduzem a notação matemática de conjuntos; funções aceitam argumentos padrão,
*args,**kwargselambda, com escopo regido pela regra LEGB. - Módulos e pacotes organizam o código;
pip/venvecondagerenciam dependências em ambientes isolados. Exceções são tratadas comtry/except/else/finally, e arquivos comwith open(...). - A POO se apoia em abstração, encapsulamento, herança e polimorfismo; métodos especiais integram objetos à sintaxe da linguagem.
- O NumPy fornece o
ndarrayhomogêneo, operações vetorizadas, broadcasting e álgebra linear; o pandas forneceSerieseDataFrame, seleção comloc/iloc,groupby,merge,meltepivot. - Todo objeto tem identidade, tipo e valor; o CPython gerencia a memória por contagem de referências, complementada por um coletor de lixo para ciclos. Objetos Python carregam cabeçalhos grandes, o que torna listas de números muito menos compactas que arrays.
- Os
floatseguem o IEEE 754:float.hex()eas_integer_ratio()revelam a representação exata,math.fsum()reduz o erro em somas, edecimal/fractionsoferecem aritmética decimal e racional exatas. Algoritmos instáveis, como a fórmula de uma passada da variância, podem produzir até variâncias negativas. - O protocolo de iteração (
__iter__/__next__) sustenta o laçofor; funções geradoras (yield) implementam avaliação preguiçosa com memória \(O(1)\), como no algoritmo de Welford para média e variância em fluxo. - Decoradores são funções que transformam funções (
@fequivale ag = f(g));functools.lru_cacheimplementa memoização, reduzindo a recursão de Fibonacci de custo exponencial para linear. Anotações de tipo (PEP 484) e@dataclassdocumentam e estruturam o código. - O módulo
reoferece expressões regulares (com grupos nomeados e quantificadores gulosos e preguiçosos);datetime,zoneinfoe o acessor.dtdo pandas tratam datas, horas e fusos. - Um
ndarrayé um bloco de memória interpretado pordtype,shapee strides; fatiamento básico produz visões (que compartilham memória), e indexação avançada produz cópias. Inteiros do NumPy têm tamanho fixo e transbordam silenciosamente. - O
default_rng()usa o gerador PCG64; oscipy.statsfornece distribuições com a interfacepmf/pdf,cdf,sf,ppfervs, equivalente às funçõesd/p/q/rdo R, e permite simulações de Monte Carlo vetorizadas. - O desempenho deve ser medido com
timeitecProfile; testes automatizados compytest(epytest.approxpara números reais) protegem o código contra regressões. - O Jupyter permite análise interativa em notebooks, que devem ser reexecutados do início antes de compartilhados.
4.18 Exercícios Propostos
(Teórico) Explique a diferença entre tipagem dinâmica e tipagem forte. Classifique o R e o Python segundo esses dois critérios, justificando com exemplos de código (
"1" + 1em Python ec(1, "a")em R).(Teórico) Sem executar, determine o valor de
aao final:a = [1, 2]; b = a; b += [3]; c = a[:]; c.append(4). Em seguida, repita coma = (1, 2); b = a; b += (3,). Explique a diferença usando os conceitos de mutabilidade e referência.(Teórico) Para
x = list(range(10, 20)), determinex[2:5],x[-3:],x[::3]ex[7:2:-2]. Mostre que, para quaisquer \(0 \le a \le b \le\)len(x), valelen(x[a:b]) == b - a.(Teórico) Explique, com base em tabelas hash, por que testar
v in sé \(O(1)\) em média para umsete \(O(n)\) para umalist. Por que uma lista não pode ser chave de dicionário, mas uma tupla pode?(Prático) Usando compreensões, construa: (a) a lista dos números primos menores que 50; (b) um dicionário que mapeia cada palavra de uma frase para sua frequência; (c) a matriz identidade \(4 \times 4\) como lista de listas.
(Prático) Escreva uma função
resumo(*valores, decimais=2)que retorne um dicionário com média, mediana, variância amostral e amplitude, arredondados. A função deve levantarValueErrorse receber menos de dois valores. Teste-a comresumo(2, 4, 4, 5, 10)e confira os resultados à mão.(Prático) Escreva uma função que leia um arquivo CSV linha a linha e converta a segunda coluna para
float, ignorando (e contando) as linhas inválidas comtry/except. Retorne a lista de valores válidos e o número de linhas descartadas.(Prático) Implemente uma classe
Amostraque armazene uma lista de números e tenha os métodosmedia(),variancia()e os métodos especiais__len__,__repr__e__add__(que concatena duas amostras). Crie uma subclasseAmostraPareadaque armazene dois vetores e calcule a correlação de Pearson.(Prático) Estenda a hierarquia
Distribuicaodo texto com as classesBinomial(n, p)ePoisson(lam). Verifique numericamente, simulando 100.000 observações comnp.random.default_rng, que as médias e variâncias amostrais se aproximam dos valores teóricos.(Teórico/Prático) Determine, pelas regras de broadcasting, se as operações entre arrays com os seguintes
shapesão válidas e qual o formato do resultado: (a)(4, 3)e(3,); (b)(4, 3)e(4,); (c)(4, 1)e(1, 3); (d)(2, 1, 5)e(3, 1). Confirme no NumPy.(Prático) Gere uma matriz \(\mathbf{X}\) de dimensão \(100 \times 3\) com observações normais e calcule, usando apenas operações vetorizadas e broadcasting, a matriz de covariâncias amostrais \(\mathbf{S} = \frac{1}{n-1}(\mathbf{X} - \bar{\mathbf{x}})^\top(\mathbf{X} - \bar{\mathbf{x}})\). Compare com
np.cov(X, rowvar=False).(Prático) Resolva o sistema \(3x_1 + 2x_2 - x_3 = 1\), \(2x_1 - 2x_2 + 4x_3 = -2\), \(-x_1 + 0{,}5x_2 - x_3 = 0\) com
np.linalg.solvee verifique a solução com o operador@.(Prático) Com o pandas, construa o DataFrame
vendasdo texto e obtenha: (a) o total por vendedor e mês em formato largo; (b) o vendedor com maior venda em cada região; (c) a junção com uma tabela de metas por região e a diferença entre o realizado e a meta.(Teórico) Compare a seleção
df.loc[0:3]edf.iloc[0:3]em um DataFrame com índice padrão. Quantas linhas cada uma retorna e por quê?(Prático) Reescreva, em pandas, o pipeline do
mtcarsapresentado no capítulo de R (filtrar câmbio manual, convertermpgpara km/l e resumir por número de cilindros). Dica: o conjunto pode ser obtido compd.read_csva partir de um arquivo exportado pelo R comwrite.csv(mtcars, "mtcars.csv").(Teórico) Explique por que
(0.1).hex()termina emae por que(0.5).hex()é'0x1.0000000000000p-1'. Useas_integer_ratio()para mostrar que \(0{,}1\) é armazenado como uma fração cujo denominador é uma potência de 2 e determine essa potência.(Prático) Compare
sum,math.fsumenp.sumao somar a lista[1e16, 1.0, -1e16]e a lista com \(10^6\) cópias de \(0{,}1\). Explique os resultados com base no modelo \(\mathrm{fl}(a + b) = (a + b)(1 + \delta)\).(Teórico) Mostre que as fórmulas de atualização de Welford estão corretas, isto é, que \(M_n = M_{n-1} + (x_n - \bar{x}_{n-1})(x_n - \bar{x}_n)\) quando \(M_n = \sum_{i=1}^n (x_i - \bar{x}_n)^2\). Dica: escreva \(x_n - \bar{x}_n = \frac{n-1}{n}(x_n - \bar{x}_{n-1})\).
(Prático) Estenda o gerador
welfordpara calcular também a covariância entre dois fluxos pareados \((x_i, y_i)\) e, a partir dela, a correlação de Pearson. Valide o resultado comnp.corrcoefem dados simulados com deslocamento de \(10^9\).(Prático) Escreva uma função geradora
le_em_blocos(caminho, tamanho)que leia um arquivo CSV grande e produza listas detamanholinhas por vez. Use-a para calcular a média de uma coluna sem jamais carregar o arquivo inteiro na memória.(Prático) Escreva um decorador
@contar_chamadasque registre, em um atributo da própria função, quantas vezes ela foi chamada. Aplique-o à funçãofibrecursiva e confirme que \(C(n) = 2F_{n+1} - 1\) para \(n = 1, \dots, 20\).(Prático) Implemente o decorador
@validar_tiposque, usando o atributo__annotations__, verifique em tempo de execução se os argumentos recebidos têm os tipos anotados, levantandoTypeErrorcaso contrário. Discuta por que essa verificação não é feita pelo próprio interpretador.(Prático) Usando o módulo
re, escreva uma função que receba um texto livre e retorne um dicionário com todas as datas (dd/mm/aaaa), e-mails e valores monetários (R$ 1.234,56) encontrados. Converta as datas para objetosdatee os valores parafloat, tratando o separador decimal brasileiro.(Teórico/Prático) Para
A = np.arange(24).reshape(2, 3, 4)comdtype=int64, calcule à mão os strides deA, deA.transpose(2, 0, 1)e deA[:, ::2, 1:]. Confira no NumPy e determine quais dessas operações produzem visões.(Prático) Mostre, com
np.shares_memory, quex[x > 0]produz uma cópia, mas quex[x > 0] = 0modificax. Explique a aparente contradição (dica:x[mascara] = valorchama o método__setitem__).(Prático) Com
scipy.stats, calcule: (a) \(P(X \le 3)\) para \(X \sim \text{Poisson}(2)\); (b) o quantil de 95% de uma \(\chi^2_{10}\); (c) \(P(|T| > 2{,}228)\) para \(T \sim t_{10}\). Compare cada resultado com a função correspondente do R.(Prático) Simule o problema do aniversário com NumPy para grupos de tamanho \(k = 2, \dots, 60\), estimando a probabilidade de coincidência para cada \(k\) com \(10^4\) réplicas. Compare com a fórmula exata e determine o menor \(k\) para o qual a probabilidade supera 50%.
(Prático) Use
timeitpara comparar a construção de uma lista com \(10^6\) quadrados por (a) laço comappend, (b) compreensão de lista e (c)np.arange(10**6) ** 2. Em seguida, usecProfilepara perfilar um script que chama as três versões.(Prático) Escreva uma suíte de testes com
pytestpara a classeAmostrado Exercício 8, incluindo testes parametrizados, um teste para entradas inválidas compytest.raisese um teste de propriedade (por exemplo, \(\operatorname{Var}(aX) = a^2 \operatorname{Var}(X)\)) compytest.approx.
4.19 Leituras Recomendadas
- McKinney (2022) — Python for Data Analysis, escrito pelo criador do pandas: referência principal para NumPy, pandas e Jupyter, com ênfase em manipulação de dados.
- VanderPlas (2016) — Python Data Science Handbook: cobre IPython/Jupyter, NumPy, pandas, Matplotlib e scikit-learn; disponível gratuitamente on-line.
- Ramalho (2022) — Fluent Python, do brasileiro Luciano Ramalho: aprofundamento no modelo de dados do Python, métodos especiais, estruturas de dados, funções e orientação a objetos.
- Harris et al. (2020) — artigo na Nature que descreve o desenho do NumPy e o papel da programação com arrays na ciência.
- McKinney (2010) — artigo original de apresentação das estruturas de dados do pandas.
- Leite et al. (2016) — introdução aos conceitos de orientação a objetos, em português.
- Kluyver et al. (2016) — artigo sobre o Jupyter como formato para fluxos computacionais reprodutíveis.
- Peters (2004) e Van Rossum e Drake (2009) — o Zen do Python e o manual de referência da linguagem; a documentação oficial atualizada está em https://docs.python.org/pt-br/3/.
- Walt, Colbert, e Varoquaux (2011) — artigo que descreve o modelo de memória do
ndarray(strides, visões e broadcasting) e as técnicas para computação numérica eficiente. - Virtanen et al. (2020) — artigo de apresentação do SciPy 1.0, com a organização de seus módulos, inclusive o
scipy.stats. - Goldberg (1991) — o texto clássico sobre aritmética de ponto flutuante, cujas lições valem igualmente para R, Python e qualquer outra linguagem.
- Welford (1962) — o artigo original do algoritmo de uma passada para somas de quadrados corrigidas, implementado neste capítulo com geradores.
- O’Neill (2014) e Matsumoto e Nishimura (1998) — os geradores PCG e Mersenne Twister, usados respectivamente pelo NumPy moderno e pelo R.
- Rossum, Lehtosalo, e Langa (2014) — a PEP 484, que introduziu as anotações de tipo na linguagem.
- Gorelick e Ozsvald (2020) — High Performance Python: perfilamento, estruturas de dados, NumPy, compilação com Numba e Cython e paralelismo.
- Okken (2022) — guia prático e completo do
pytest. - Friedl (2006) — referência sobre expressões regulares, aplicável ao módulo
re.