Análise de Dados com Pandas Avançado e Plotly

Análise de Dados com Pandas Avançado e Plotly

Merge, pivot, melt, janelas e groupby no pandas 3, e gráficos interativos com Plotly e Dash — com os erros que não avisam: o merge que duplica linhas, o fill_value que mostra zero ao lado da média, o rank que dá sempre 1 e a otimização que ignora o texto do pandas 3.
Python

• • 19 min de leitura

Em Introdução ao Data Science com Python vimos os fundamentos do pandas. Neste artigo aprofundamos as técnicas mais poderosas da biblioteca — operações de janela, merge, reshape e otimização de memória — e introduzimos o Plotly, a biblioteca de visualização interativa que transforma análises em dashboards prontos para a web. A combinação pandas avançado + Plotly é o padrão em ciência de dados aplicada ao negócio.

Instalação

pip install plotly pandas numpy dash statsmodels

O statsmodels está na lista por causa de um único parâmetro: a linha de tendência trendline="ols" do Plotly Express, que ajusta a reta com ele. O Plotly não o declara como dependência, e sem ele o gráfico de dispersão morre com ModuleNotFoundError: No module named 'statsmodels'. Os exemplos foram conferidos com pandas 3.0, Plotly 7.1 e Dash 4.4. A versão principal do pandas faz diferença aqui: a 3.0 mudou o tipo padrão das colunas de texto, o que aparece na seção de otimização de memória.

Pandas Avançado: Operações de Merge e Join

import pandas as pd
import numpy as np

# DataFrames de exemplo
alunos = pd.DataFrame({
    "id":      [1, 2, 3, 4, 5],
    "nome":    ["Ana", "Bruno", "Carla", "Diego", "Elena"],
    "turma_id":[1, 1, 2, 2, 3],
    "ativo":   [True, True, True, False, True]
})

turmas = pd.DataFrame({
    "id":       [1, 2, 3, 4],
    "nome":     ["Turma A", "Turma B", "Turma C", "Turma D"],
    "turno":    ["Manhã", "Tarde", "Noite", "Manhã"],
    "capacidade":[30, 25, 20, 30]
})

notas = pd.DataFrame({
    "aluno_id":    [1, 1, 2, 3, 3, 4, 5],
    "disciplina":  ["Mat", "Port", "Mat", "Mat", "Port", "Mat", "Port"],
    "nota":        [9.5, 8.0, 7.0, 8.5, 6.0, 5.5, 9.0]
})


# INNER JOIN — apenas correspondências
inner = pd.merge(alunos, turmas, left_on="turma_id", right_on="id",
                 suffixes=("_aluno", "_turma"))
print("=== INNER JOIN ===")
print(inner[["nome_aluno", "nome_turma", "turno"]])


# LEFT JOIN — todos os alunos, mesmo sem turma
left = pd.merge(alunos, turmas, left_on="turma_id", right_on="id", how="left")

# OUTER JOIN — tudo de ambos
outer = pd.merge(alunos, turmas, left_on="turma_id", right_on="id", how="outer")


# Join múltiplo — alunos + turmas + notas
completo = (
    alunos
    .merge(turmas, left_on="turma_id", right_on="id", suffixes=("", "_turma"))
    .merge(notas,  left_on="id",       right_on="aluno_id")
    [["nome", "nome_turma", "turno", "disciplina", "nota"]]
)
print("\n=== Dataset Completo ===")
print(completo)


# Concatenação
semestre1 = pd.DataFrame({
    "aluno": ["Ana", "Bruno"],
    "nota":  [8.5, 7.0],
    "semestre": ["S1", "S1"]
})
semestre2 = pd.DataFrame({
    "aluno": ["Ana", "Bruno", "Carla"],
    "nota":  [9.0, 7.5, 8.0],
    "semestre": ["S2", "S2", "S2"]
})

historico = pd.concat([semestre1, semestre2], ignore_index=True)
print("\n=== Histórico Concatenado ===")
print(historico)

Reshape: Pivot e Melt

# PIVOT — de linhas para colunas
notas_pivot = notas.pivot(
    index="aluno_id",
    columns="disciplina",
    values="nota"
).reset_index()
notas_pivot.columns.name = None
print("=== Pivot ===")
print(notas_pivot)


# PIVOT TABLE — com agregação
pivot_agg = pd.pivot_table(
    completo,
    values="nota",
    index="nome",
    columns="disciplina",
    aggfunc="mean",
    margins=True,      # linha e coluna "Média"
    margins_name="Média"
)
print("\n=== Pivot Table com Totais ===")
print(pivot_agg.round(2))
# Bruno não fez prova de Port: a célula sai NaN, e a média dele é 7.0.
# Com fill_value=0 a célula mostraria 0.00 ao lado da mesma média 7.00,
# porque as margens são calculadas sobre os dados, antes do preenchimento.


# MELT — de colunas para linhas (inverso do pivot)
df_wide = pd.DataFrame({
    "aluno": ["Ana", "Bruno"],
    "Mat":   [9.5, 7.0],
    "Port":  [8.0, 6.5],
    "Hist":  [7.5, 8.0],
})

df_long = pd.melt(
    df_wide,
    id_vars=["aluno"],
    value_vars=["Mat", "Port", "Hist"],
    var_name="disciplina",
    value_name="nota"
)
print("\n=== Melt (wide → long) ===")
print(df_long)


# STACK e UNSTACK
df_stacked = notas_pivot.set_index("aluno_id").stack()
print("\n=== Stack ===")
print(df_stacked.head(8))

Operações de Janela (Window Functions)

# Dataset temporal de notas ao longo do ano
np.random.seed(42)
datas = pd.date_range("2024-01-01", periods=52, freq="W")

df_temp = pd.DataFrame({
    "data":    datas,
    "aluno":   np.random.choice(["Ana", "Bruno", "Carla"], 52),
    "nota":    np.random.uniform(5, 10, 52).round(1),
    "horas":   np.random.randint(2, 10, 52),
})
df_temp = df_temp.sort_values(["aluno", "data"]).reset_index(drop=True)


# Rolling — média móvel
df_temp["media_movel_4s"] = (
    df_temp.groupby("aluno")["nota"]
    .transform(lambda x: x.rolling(window=4, min_periods=1).mean())
    .round(2)
)

# Expanding — média acumulada
df_temp["media_acumulada"] = (
    df_temp.groupby("aluno")["nota"]
    .transform(lambda x: x.expanding().mean())
    .round(2)
)

# Shift — nota da semana anterior
df_temp["nota_anterior"] = (
    df_temp.groupby("aluno")["nota"]
    .shift(1)
)

# Variação em relação à semana anterior
df_temp["variacao"] = (
    df_temp["nota"] - df_temp["nota_anterior"]
).round(2)

# Rank dentro de cada aluno: 1 = a melhor semana dele.
# Agrupar por "data" daria sempre 1 — cada semana tem uma linha só.
df_temp["rank_no_aluno"] = (
    df_temp.groupby("aluno")["nota"]
    .rank(method="dense", ascending=False)
    .astype(int)
)

print("=== Window Functions ===")
print(df_temp[df_temp["aluno"] == "Ana"].head(8).to_string())

GroupBy Avançado

# Múltiplas agregações simultâneas
stats = df_temp.groupby("aluno").agg(
    media=        ("nota",  "mean"),
    mediana=      ("nota",  "median"),
    desvio=       ("nota",  "std"),
    melhor=       ("nota",  "max"),
    pior=         ("nota",  "min"),
    total_horas=  ("horas", "sum"),
    semanas=      ("nota",  "count"),
).round(2)
print("=== Estatísticas por Aluno ===")
print(stats)


# Apply com função customizada
def resumo_aluno(grupo):
    notas_acima_7 = (grupo["nota"] >= 7).sum()
    tendencia     = np.polyfit(range(len(grupo)), grupo["nota"], 1)[0]
    return pd.Series({
        "notas_acima_7":  notas_acima_7,
        "tendencia":      round(tendencia, 4),
        "melhorando":     tendencia > 0
    })

resumos = df_temp.groupby("aluno").apply(resumo_aluno)
print("\n=== Análise de Tendência ===")
print(resumos)


# Transform — preserva o índice original
df_temp["media_turma"] = df_temp.groupby("aluno")["nota"].transform("mean")
df_temp["acima_media"]  = df_temp["nota"] > df_temp["media_turma"]


# Filter — filtra grupos inteiros
alunos_bons = df_temp.groupby("aluno").filter(
    lambda g: g["nota"].mean() >= 7.5
)
print(f"\nAlunos com média ≥ 7.5: {alunos_bons['aluno'].unique().tolist()}")

Otimização de Memória

def otimizar_dataframe(df: pd.DataFrame) -> pd.DataFrame:
    """Reduz uso de memória ajustando tipos de dados."""
    df_opt = df.copy()
    mem_antes = df_opt.memory_usage(deep=True).sum() / 1024 ** 2

    for col in df_opt.columns:
        tipo = df_opt[col].dtype

        # Inteiros
        if tipo in ["int64", "int32"]:
            c_min = df_opt[col].min()
            c_max = df_opt[col].max()
            if c_min >= np.iinfo(np.int8).min and c_max <= np.iinfo(np.int8).max:
                df_opt[col] = df_opt[col].astype(np.int8)
            elif c_min >= np.iinfo(np.int16).min and c_max <= np.iinfo(np.int16).max:
                df_opt[col] = df_opt[col].astype(np.int16)
            elif c_min >= np.iinfo(np.int32).min and c_max <= np.iinfo(np.int32).max:
                df_opt[col] = df_opt[col].astype(np.int32)

        # Floats
        elif tipo == "float64":
            df_opt[col] = df_opt[col].astype(np.float32)

        # Strings com baixa cardinalidade → category
        # (texto é "str" no pandas 3 e "object" antes: a checagem cobre os dois)
        elif pd.api.types.is_string_dtype(tipo):
            num_unicos = df_opt[col].nunique()
            if num_unicos / len(df_opt) < 0.5:
                df_opt[col] = df_opt[col].astype("category")

    mem_depois = df_opt.memory_usage(deep=True).sum() / 1024 ** 2
    reducao    = (1 - mem_depois / mem_antes) * 100

    print(f"Memória antes:  {mem_antes:.2f} MB")
    print(f"Memória depois: {mem_depois:.2f} MB")
    print(f"Redução:        {reducao:.1f}%")

    return df_opt


# Testando com dataset grande
df_grande = pd.DataFrame({
    "id":       range(100_000),
    "nota":     np.random.uniform(0, 10, 100_000),
    "faltas":   np.random.randint(0, 30, 100_000),
    "turma":    np.random.choice(["A", "B", "C", "D"], 100_000),
    "aprovado": np.random.choice([True, False], 100_000),
})

df_otimizado = otimizar_dataframe(df_grande)

Medido com pandas 3.0: a memória cai de 7,15 MB para 1,05 MB, 85% a menos, e quase tudo vem de uma coluna. A turma em texto ocupa 5.000.000 bytes; como category, 100.200 — quatro rótulos guardados uma vez e um código de 1 byte por linha. Até o pandas 2, texto chegava como object; no 3.0 chega como str, e uma função que testa tipo == "object" simplesmente pula a coluna, sem erro: a redução medida nesse caso fica em 20%. Duas ressalvas antes de aplicar isso em produção. O float32 guarda uns 7 dígitos significativos, suficientes para nota e insuficientes para dinheiro ou coordenada. E o tipo reduzido ao mínimo estoura em silêncio quando chega um dado maior: um int8 vai só até 127.

Plotly: Visualizações Interativas

import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
import numpy as np


# Dataset para visualizações
np.random.seed(42)
df_vis = pd.DataFrame({
    "aluno":     np.random.choice(["Ana", "Bruno", "Carla", "Diego"], 200),
    "nota":      np.random.normal(7.0, 1.5, 200).clip(0, 10),
    "horas":     np.random.uniform(1, 10, 200),
    "faltas":    np.random.randint(0, 15, 200),
    "turma":     np.random.choice(["A", "B", "C"], 200),
    "semestre":  np.random.choice(["S1", "S2"], 200),
})
df_vis["aprovado"] = (df_vis["nota"] >= 6).map({True: "Aprovado", False: "Reprovado"})

Gráficos Básicos com Plotly Express

# Histograma interativo
fig = px.histogram(
    df_vis,
    x="nota",
    color="aprovado",
    nbins=30,
    title="Distribuição de Notas",
    labels={"nota": "Nota", "count": "Frequência"},
    color_discrete_map={"Aprovado": "#375623", "Reprovado": "#C00000"},
    barmode="overlay",
    opacity=0.7,
    template="plotly_white"
)
fig.add_vline(x=6, line_dash="dash", line_color="orange",
              annotation_text="Mínimo para aprovação")
fig.write_html("histograma.html")
fig.show()


# Scatter com correlação
fig = px.scatter(
    df_vis,
    x="horas", y="nota",
    color="aprovado",
    size="faltas",
    hover_data=["aluno", "turma"],
    trendline="ols",
    title="Horas de Estudo vs Nota",
    labels={"horas": "Horas de Estudo", "nota": "Nota Final"},
    color_discrete_map={"Aprovado": "#2E75B6", "Reprovado": "#C00000"},
    template="plotly_white"
)
fig.write_html("scatter.html")
fig.show()


# Box plot por turma
fig = px.box(
    df_vis,
    x="turma", y="nota",
    color="semestre",
    points="outliers",
    title="Distribuição de Notas por Turma e Semestre",
    template="plotly_white"
)
fig.write_html("boxplot.html")
fig.show()

Cada write_html embute a biblioteca JavaScript do Plotly inteira no arquivo: o histograma.html sai com 4,7 MB. Com fig.write_html("histograma.html", include_plotlyjs="cdn") o mesmo gráfico cai para alguns kilobytes, ao preço de exigir internet para abrir. Fora do Jupyter, fig.show() abre o navegador padrão; num servidor sem interface gráfica, fique só com o write_html.

Plotly Graph Objects: Controle Total

# Dashboard com subplots
fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=(
        "Distribuição de Notas",
        "Horas de Estudo vs Nota",
        "Aprovação por Turma",
        "Evolução Semanal"
    ),
    vertical_spacing=0.15
)

# Plot 1: Histograma
for status, cor in [("Aprovado", "#375623"), ("Reprovado", "#C00000")]:
    subset = df_vis[df_vis["aprovado"] == status]["nota"]
    fig.add_trace(
        go.Histogram(x=subset, name=status, marker_color=cor,
                     opacity=0.7, nbinsx=20),
        row=1, col=1
    )

# Plot 2: Scatter
for turma, cor in [("A", "#2E75B6"), ("B", "#F5A623"), ("C", "#375623")]:
    subset = df_vis[df_vis["turma"] == turma]
    fig.add_trace(
        go.Scatter(
            x=subset["horas"], y=subset["nota"],
            mode="markers", name=f"Turma {turma}",
            marker=dict(color=cor, size=6, opacity=0.6)
        ),
        row=1, col=2
    )

# Plot 3: Barras empilhadas
contagem = df_vis.groupby(["turma", "aprovado"]).size().reset_index(name="count")
for status, cor in [("Aprovado", "#375623"), ("Reprovado", "#C00000")]:
    subset = contagem[contagem["aprovado"] == status]
    fig.add_trace(
        go.Bar(x=subset["turma"], y=subset["count"],
               name=status, marker_color=cor,
               showlegend=False),
        row=2, col=1
    )

# Plot 4: Linha temporal
df_temp_agg = df_temp.groupby(["data", "aluno"])["nota"].mean().reset_index()
for aluno, cor in [("Ana", "#2E75B6"), ("Bruno", "#C00000"), ("Carla", "#375623")]:
    subset = df_temp_agg[df_temp_agg["aluno"] == aluno]
    fig.add_trace(
        go.Scatter(x=subset["data"], y=subset["nota"],
                   mode="lines+markers", name=aluno,
                   line=dict(color=cor),
                   showlegend=False),
        row=2, col=2
    )

# Layout geral
fig.update_layout(
    title_text="Dashboard de Desempenho Escolar",
    title_font_size=18,
    height=700,
    template="plotly_white",
    barmode="stack",
    legend=dict(
        orientation="h",
        yanchor="bottom", y=1.02,
        xanchor="right",  x=1
    )
)

fig.write_html("dashboard.html")
fig.show()

Gráficos Especializados

# Mapa de calor (Heatmap)
correlacoes = df_vis[["nota", "horas", "faltas"]].corr()

fig = go.Figure(data=go.Heatmap(
    z=correlacoes.values,
    x=correlacoes.columns,
    y=correlacoes.index,
    colorscale="RdBu",
    zmid=0,
    text=correlacoes.round(2).values,
    texttemplate="%{text}",
))
fig.update_layout(title="Correlação entre Variáveis", template="plotly_white")
fig.write_html("heatmap.html")


# Sunburst — hierarquia
df_hier = df_vis.groupby(["turma", "aprovado"]).size().reset_index(name="count")
fig = px.sunburst(
    df_hier,
    path=["turma", "aprovado"],
    values="count",
    color="aprovado",
    color_discrete_map={"Aprovado": "#375623", "Reprovado": "#C00000"},
    title="Aprovação por Turma"
)
fig.write_html("sunburst.html")


# Treemap
fig = px.treemap(
    df_hier,
    path=["turma", "aprovado"],
    values="count",
    color="aprovado",
    title="Treemap — Distribuição por Turma"
)
fig.write_html("treemap.html")


# Gráfico de barras animado
df_animado = df_vis.groupby(["semestre", "turma"])["nota"].mean().reset_index()
fig = px.bar(
    df_animado,
    x="turma", y="nota",
    animation_frame="semestre",
    color="turma",
    range_y=[0, 10],
    title="Evolução da Média por Semestre"
)
fig.write_html("animado.html")

Dash: Dashboard Web Interativo

from dash import Dash, dcc, html, Input, Output
import plotly.express as px
import pandas as pd
import numpy as np

# O Dash roda como script próprio: os dados precisam existir aqui
np.random.seed(42)
df_vis = pd.DataFrame({
    "aluno":     np.random.choice(["Ana", "Bruno", "Carla", "Diego"], 200),
    "nota":      np.random.normal(7.0, 1.5, 200).clip(0, 10),
    "horas":     np.random.uniform(1, 10, 200),
    "faltas":    np.random.randint(0, 15, 200),
    "turma":     np.random.choice(["A", "B", "C"], 200),
    "semestre":  np.random.choice(["S1", "S2"], 200),
})
df_vis["aprovado"] = (df_vis["nota"] >= 6).map({True: "Aprovado", False: "Reprovado"})

app = Dash(__name__)

# Layout do dashboard
app.layout = html.Div([
    html.H1("Dashboard Escolar",
            style={"textAlign": "center", "color": "#1F4E79",
                   "fontFamily": "Arial, sans-serif"}),

    html.Div([
        html.Div([
            html.Label("Turma:"),
            dcc.Dropdown(
                id="filtro-turma",
                options=[{"label": f"Turma {t}", "value": t}
                         for t in ["A", "B", "C"]],
                multi=True,
                value=["A", "B", "C"]
            )
        ], style={"width": "30%", "display": "inline-block", "padding": "10px"}),

        html.Div([
            html.Label("Nota mínima:"),
            dcc.Slider(
                id="filtro-nota",
                min=0, max=10, step=0.5,
                value=0,
                marks={i: str(i) for i in range(11)}
            )
        ], style={"width": "60%", "display": "inline-block", "padding": "10px"}),
    ]),

    html.Div([
        dcc.Graph(id="grafico-distribuicao", style={"width": "50%",
                                                     "display": "inline-block"}),
        dcc.Graph(id="grafico-scatter",      style={"width": "50%",
                                                     "display": "inline-block"}),
    ]),

    html.Div(id="resumo-stats",
             style={"padding": "20px", "fontFamily": "Arial, sans-serif",
                    "background": "#f4f4f4", "borderRadius": "8px",
                    "margin": "10px"}),
])


@app.callback(
    [Output("grafico-distribuicao", "figure"),
     Output("grafico-scatter",      "figure"),
     Output("resumo-stats",         "children")],
    [Input("filtro-turma", "value"),
     Input("filtro-nota",  "value")]
)
def atualizar_dashboard(turmas_sel, nota_min):
    filtrado = df_vis[
        (df_vis["turma"].isin(turmas_sel or [])) &
        (df_vis["nota"] >= nota_min)
    ]

    # Gráfico 1: Distribuição
    fig1 = px.histogram(
        filtrado, x="nota", color="aprovado", nbins=20,
        title="Distribuição de Notas",
        color_discrete_map={"Aprovado": "#375623", "Reprovado": "#C00000"},
        template="plotly_white"
    )

    # Gráfico 2: Scatter
    fig2 = px.scatter(
        filtrado, x="horas", y="nota",
        color="turma", size="faltas",
        title="Horas de Estudo vs Nota",
        template="plotly_white"
    )

    # Estatísticas
    if len(filtrado) > 0:
        media      = filtrado["nota"].mean()
        aprovados  = (filtrado["nota"] >= 6).sum()
        pct_aprov  = aprovados / len(filtrado) * 100
        resumo = [
            html.Strong("Estatísticas do Filtro: "),
            html.Span(f"Total: {len(filtrado)} registros | "),
            html.Span(f"Média: {media:.2f} | "),
            html.Span(f"Aprovados: {aprovados} ({pct_aprov:.1f}%)")
        ]
    else:
        resumo = [html.Span("Nenhum dado para o filtro selecionado.")]

    return fig1, fig2, resumo


if __name__ == "__main__":
    app.run(debug=True, port=8050)

O script do Dash é independente dos anteriores: a primeira versão deste artigo usava df_vis sem defini-lo, e a página abria normalmente — o NameError só aparecia quando o primeiro callback rodava, como um erro dentro do painel. O debug=True liga o recarregamento automático e o painel de erros no navegador, útil para desenvolver e perigoso em produção, onde o app deve rodar atrás de um servidor WSGI como o gunicorn (app.server é o Flask por baixo).

O pandas avançado é, em boa parte, SQL escrito em Python: merge faz os joins, pivot_table e melt trocam o formato da tabela, groupby com agg, transform e filter resume, espalha ou descarta grupos, e rolling, expanding e shift fazem o papel das funções de janela. O Plotly pega o resultado e o transforma em gráfico interativo — com o Express para o caso comum, com Graph Objects e make_subplots quando é preciso controlar cada traço — e o Dash põe esses gráficos numa aplicação web com filtros e callbacks.

Os defeitos que este artigo tinha ensinam a desconfiar do resultado que parece certo. Um ranking que sai sempre 1, uma tabela que mostra nota zero ao lado de média 7, uma otimização de memória que pula a coluna mais pesada porque o pandas 3 mudou o tipo do texto: nada disso levanta exceção. Antes de confiar numa transformação, confira o formato da saída — quantas linhas, quantos valores distintos, que tipos — como já era a regra ao ler planilhas em Automação de Planilhas e PDFs.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um relatório junta alunos e turmas com pd.merge(alunos, turmas, left_on="turma_id", right_on="id"). São 5 alunos, e o resultado tem 7 linhas: Ana e Bruno aparecem duas vezes, uma em "Turma A" e outra em "Turma A (2025)". Ninguém mexeu no código. O que mudou, e como impedir que isso passe em silêncio?

Ver resposta

✓ Resposta: Mudaram os dados: alguém cadastrou uma segunda turma com o mesmo id 1. O merge do pandas funciona como o JOIN do SQL — cada linha da esquerda casa com todas as linhas da direita que têm a mesma chave. Com duas turmas de id 1, os dois alunos dessa turma viram quatro linhas, e 5 viram 7. Qualquer soma ou contagem feita depois sai inflada, sem erro nenhum. A defesa é declarar a cardinalidade esperada: pd.merge(..., validate="many_to_one") diz que vários alunos apontam para uma turma só. Medido com os dados acima, ele levanta MergeError: Merge keys are not unique in right dataset; not a many-to-one merge e lista as chaves duplicadas. Outra conferência barata é comparar len() antes e depois: num merge que só acrescenta colunas, o número de linhas não pode crescer.

Exercício 2

A coordenadora recebe a pivot table do artigo, com as notas de Mat e Port por aluno e a linha e a coluna "Média". Ela tira a média da coluna "Média" dos cinco alunos (8,75; 7,00; 7,25; 5,50; 9,00) e chega a 7,50, mas a tabela informa 7,64 no canto. Qual das duas está errada?

Ver resposta

✓ Resposta: Nenhuma; elas respondem a perguntas diferentes. As margens do pivot_table são calculadas sobre os dados originais, não sobre a tabela já agregada: 7,64 é a média das 7 notas lançadas (53,5 ÷ 7 = 7,642…). Os 7,50 são a média das médias dos alunos, em que Ana, com duas notas, pesa o mesmo que Bruno, com uma. Qual usar depende da pergunta — "qual a nota média lançada" ou "qual o aluno médio" — e o relatório deveria dizer. É também por isso que o fill_value=0 da primeira versão do artigo enganava: a tabela mostrava Bruno com 0,00 em Port e média 7,00 na mesma linha, porque o zero foi posto depois do cálculo. Sem o fill_value, a célula sai NaN, que é a verdade: não há nota.

Exercício 3

Um analista cria a coluna rank_semana com df_temp.groupby("data")["nota"].rank(method="dense", ascending=False) para saber qual aluno foi o melhor de cada semana. Todos os valores saem 1. O código está certo?

Ver resposta

✓ Resposta: O código está certo e a pergunta é que não cabe nos dados. O dataset tem 52 linhas e 52 datas distintas — cada semana tem uma nota de um único aluno. Em grupos de uma linha, todo mundo é o primeiro. Para ranquear dentro da semana, cada semana precisaria de notas de vários alunos. O que esses dados permitem é ranquear dentro do aluno: groupby("aluno")["nota"].rank(...) diz qual foi a melhor semana de cada um. Medido: para Ana, com 16 semanas, o rank vai de 1 a 14 — com method="dense", notas empatadas dividem a posição e não abrem buraco. A conferência que teria pegado o erro é df["rank_semana"].nunique(), que devolve 1. Antes de agrupar, vale sempre olhar quantas linhas cada grupo tem: df.groupby("data").size().max().

Exercício 4

A função otimizar_dataframe que um colega escreveu em 2023 reduzia em mais de 80% a memória dos DataFrames da equipe. Depois da atualização para o pandas 3.0, com os mesmos dados, a redução caiu para 20%. Nenhum erro, nenhum aviso. Onde está a diferença?

Ver resposta

✓ Resposta: Na coluna de texto. Até o pandas 2, texto era carregado como object; no 3.0 o padrão passou a ser o tipo str. A função testava tipo == "object", que agora dá False, e a coluna turma simplesmente não é convertida. Ela é justamente a mais pesada: medido com 100 mil linhas, ocupa 5.000.000 bytes como texto e 100.200 como category. Sem ela, o total vai de 7,15 MB para 5,72 MB (20%); com ela, para 1,05 MB (85%). A correção é usar pd.api.types.is_string_dtype(tipo), que reconhece os dois tipos. A lição vale para qualquer código que compara dtype com string: as funções de pd.api.types acompanham as mudanças da biblioteca; a comparação literal quebra em silêncio.

Exercício 5

Animado com a redução de memória, alguém aplica a otimizar_dataframe na tabela de contratos da empresa. Um contrato de R$ 1.234.567,89 passa a aparecer como R$ 1.234.567,88 no relatório. De onde saiu o centavo perdido?

Ver resposta

✓ Resposta: Da conversão de float64 para float32. O float32 guarda cerca de 7 dígitos significativos (np.finfo(np.float32).precision devolve 6), e 1234567.89 tem 9. Medido: np.float32(1234567.89) vale 1234567.875; formatado com f"{x:.2f}", vira 1234567.88, e truncado em duas casas, 1234567.87. Para notas de 0 a 10 a perda é irrelevante; para dinheiro, coordenadas geográficas ou identificadores numéricos longos, é defeito. Valor monetário não deveria estar em ponto flutuante de nenhuma largura: o caminho é guardar centavos como inteiro (int64) ou usar Decimal. Uma otimização automática de tipos precisa de uma lista de colunas que ela não pode tocar.

Comentários

Mais em Python

A História do Python e os Primeiros Passos
A História do Python e os Primeiros Passos

De um projeto de férias de Natal em 1989 à linguagem que hoje sai em versão…

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…

Templates HTML com Jinja2 e Deploy com Docker
Templates HTML com Jinja2 e Deploy com Docker

Jinja2 no Flask e no FastAPI, e o deploy com Docker e Compose: os filtros que…