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:

Comparação R vs Python
  • 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 0x80

No 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ódulo functools.
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 56

Verificaçã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.5

Os tipos de dados básicos mais comuns são:

  • int: Inteiros
  • float: Números decimais (ponto flutuante)
  • str: Cadeias de caracteres (texto)
  • bool: Valores booleanos (True ou False)

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, e 2 ** 100 é calculado exatamente.
  • Os float seguem 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 usar math.isclose().
  • A divisão / sempre retorna float (7 / 2 vale 3.5); a divisão inteira é // (7 // 2 vale 3), o resto é % e a potência é **.
  • bool é um subtipo de int: True + True vale 2, o que permite contar condições verdadeiras com sum().
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])        # 2

Note 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, set e 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)         # texto

Para 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)   # 2

A 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.0

Quando 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.0

Uma 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:

  1. 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]
    Importante

    Diferente do R, a indexação em Python começa em 0 (zero). Assim, idades[0] retornará o primeiro elemento (25).

  2. 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'
  3. 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)
  4. 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] -> inverte
Comparação com o R

Em 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 = 1024

4.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:

  • *args reúne os argumentos posicionais extras em uma tupla;
  • **kwargs reú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.5
Argumentos padrão mutáveis

O 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)                       # 42

4.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ção StopIteration quando 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 termina

Implementando 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
# 333332833333500000

O 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ódigo

A 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.py

4.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
deactivate

A 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.yml
Dica

Nunca 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 UTC

O ú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     # 8238

No 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:

  1. 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.
  2. 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.
  3. Atributo: São as características ou variáveis pertencentes a uma classe (ex: nome, idade, salário).
  4. 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):

  1. Abstração: representar apenas as características relevantes de uma entidade para o problema em questão, ocultando detalhes irrelevantes.
  2. Encapsulamento: agrupar dados e os métodos que os manipulam, controlando o acesso ao estado interno do objeto e expondo uma interface bem definida.
  3. Herança: criar novas classes a partir de classes existentes, reutilizando e especializando seu comportamento (relação “é um”).
  4. 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ção

Conferindo à 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)
Ordem de armazenamento

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)
Atenção ao 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 R

4.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:

  1. forem iguais, ou
  2. 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_linhas

A 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), .T e ravel() 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 x

Esse 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 - 1

Conferindo: \(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.0149011612

O ú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.9553

A 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 pi

Note 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 de Series que 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, int64

As 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)   # nan

4.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 s

O 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.

Hierarquia de estratégias de desempenho em Python
  1. Escolha a estrutura de dados e o algoritmo adequados (set/dict para buscas, ordenação \(O(n \log n)\) em vez de comparações \(O(n^2)\)).
  2. Use funções nativas e da biblioteca padrão (sum, max, sorted, collections.Counter), implementadas em C.
  3. Vetorize com NumPy e pandas, evitando laços explícitos e DataFrame.apply com funções Python linha a linha.
  4. Para laços numéricos inevitáveis, compile o trecho crítico com Numba ou Cython.
  5. 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.00s

Cada 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\).

Testes como documentação executável

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 navegador

Alguns 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.

Estado oculto em notebooks

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 em CapWords (CientistaDados); constantes em MAIUSCULAS;
  • 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 None usando is (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, dict e set diferem 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, **kwargs e lambda, com escopo regido pela regra LEGB.
  • Módulos e pacotes organizam o código; pip/venv e conda gerenciam dependências em ambientes isolados. Exceções são tratadas com try/except/else/finally, e arquivos com with 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 ndarray homogêneo, operações vetorizadas, broadcasting e álgebra linear; o pandas fornece Series e DataFrame, seleção com loc/iloc, groupby, merge, melt e pivot.
  • 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 float seguem o IEEE 754: float.hex() e as_integer_ratio() revelam a representação exata, math.fsum() reduz o erro em somas, e decimal/fractions oferecem 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ço for; 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 (@f equivale a g = f(g)); functools.lru_cache implementa memoização, reduzindo a recursão de Fibonacci de custo exponencial para linear. Anotações de tipo (PEP 484) e @dataclass documentam e estruturam o código.
  • O módulo re oferece expressões regulares (com grupos nomeados e quantificadores gulosos e preguiçosos); datetime, zoneinfo e o acessor .dt do pandas tratam datas, horas e fusos.
  • Um ndarray é um bloco de memória interpretado por dtype, shape e 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; o scipy.stats fornece distribuições com a interface pmf/pdf, cdf, sf, ppf e rvs, equivalente às funções d/p/q/r do R, e permite simulações de Monte Carlo vetorizadas.
  • O desempenho deve ser medido com timeit e cProfile; testes automatizados com pytest (e pytest.approx para 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

  1. (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" + 1 em Python e c(1, "a") em R).

  2. (Teórico) Sem executar, determine o valor de a ao final: a = [1, 2]; b = a; b += [3]; c = a[:]; c.append(4). Em seguida, repita com a = (1, 2); b = a; b += (3,). Explique a diferença usando os conceitos de mutabilidade e referência.

  3. (Teórico) Para x = list(range(10, 20)), determine x[2:5], x[-3:], x[::3] e x[7:2:-2]. Mostre que, para quaisquer \(0 \le a \le b \le\) len(x), vale len(x[a:b]) == b - a.

  4. (Teórico) Explique, com base em tabelas hash, por que testar v in s é \(O(1)\) em média para um set e \(O(n)\) para uma list. Por que uma lista não pode ser chave de dicionário, mas uma tupla pode?

  5. (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.

  6. (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 levantar ValueError se receber menos de dois valores. Teste-a com resumo(2, 4, 4, 5, 10) e confira os resultados à mão.

  7. (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 com try/except. Retorne a lista de valores válidos e o número de linhas descartadas.

  8. (Prático) Implemente uma classe Amostra que armazene uma lista de números e tenha os métodos media(), variancia() e os métodos especiais __len__, __repr__ e __add__ (que concatena duas amostras). Crie uma subclasse AmostraPareada que armazene dois vetores e calcule a correlação de Pearson.

  9. (Prático) Estenda a hierarquia Distribuicao do texto com as classes Binomial(n, p) e Poisson(lam). Verifique numericamente, simulando 100.000 observações com np.random.default_rng, que as médias e variâncias amostrais se aproximam dos valores teóricos.

  10. (Teórico/Prático) Determine, pelas regras de broadcasting, se as operações entre arrays com os seguintes shape sã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.

  11. (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).

  12. (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.solve e verifique a solução com o operador @.

  13. (Prático) Com o pandas, construa o DataFrame vendas do 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.

  14. (Teórico) Compare a seleção df.loc[0:3] e df.iloc[0:3] em um DataFrame com índice padrão. Quantas linhas cada uma retorna e por quê?

  15. (Prático) Reescreva, em pandas, o pipeline do mtcars apresentado no capítulo de R (filtrar câmbio manual, converter mpg para km/l e resumir por número de cilindros). Dica: o conjunto pode ser obtido com pd.read_csv a partir de um arquivo exportado pelo R com write.csv(mtcars, "mtcars.csv").

  16. (Teórico) Explique por que (0.1).hex() termina em a e por que (0.5).hex() é '0x1.0000000000000p-1'. Use as_integer_ratio() para mostrar que \(0{,}1\) é armazenado como uma fração cujo denominador é uma potência de 2 e determine essa potência.

  17. (Prático) Compare sum, math.fsum e np.sum ao 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)\).

  18. (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})\).

  19. (Prático) Estenda o gerador welford para 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 com np.corrcoef em dados simulados com deslocamento de \(10^9\).

  20. (Prático) Escreva uma função geradora le_em_blocos(caminho, tamanho) que leia um arquivo CSV grande e produza listas de tamanho linhas por vez. Use-a para calcular a média de uma coluna sem jamais carregar o arquivo inteiro na memória.

  21. (Prático) Escreva um decorador @contar_chamadas que registre, em um atributo da própria função, quantas vezes ela foi chamada. Aplique-o à função fib recursiva e confirme que \(C(n) = 2F_{n+1} - 1\) para \(n = 1, \dots, 20\).

  22. (Prático) Implemente o decorador @validar_tipos que, usando o atributo __annotations__, verifique em tempo de execução se os argumentos recebidos têm os tipos anotados, levantando TypeError caso contrário. Discuta por que essa verificação não é feita pelo próprio interpretador.

  23. (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 objetos date e os valores para float, tratando o separador decimal brasileiro.

  24. (Teórico/Prático) Para A = np.arange(24).reshape(2, 3, 4) com dtype=int64, calcule à mão os strides de A, de A.transpose(2, 0, 1) e de A[:, ::2, 1:]. Confira no NumPy e determine quais dessas operações produzem visões.

  25. (Prático) Mostre, com np.shares_memory, que x[x > 0] produz uma cópia, mas que x[x > 0] = 0 modifica x. Explique a aparente contradição (dica: x[mascara] = valor chama o método __setitem__).

  26. (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.

  27. (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%.

  28. (Prático) Use timeit para comparar a construção de uma lista com \(10^6\) quadrados por (a) laço com append, (b) compreensão de lista e (c) np.arange(10**6) ** 2. Em seguida, use cProfile para perfilar um script que chama as três versões.

  29. (Prático) Escreva uma suíte de testes com pytest para a classe Amostra do Exercício 8, incluindo testes parametrizados, um teste para entradas inválidas com pytest.raises e um teste de propriedade (por exemplo, \(\operatorname{Var}(aX) = a^2 \operatorname{Var}(X)\)) com pytest.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.