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

Banco de Dados com SQLite e SQLAlchemy
Banco de Dados com SQLite e SQLAlchemy

SQLite com sqlite3 e SQLAlchemy 2.0 em Python, do CRUD ao repositório e às…

WebSockets e Comunicação em Tempo Real em Python
WebSockets e Comunicação em Tempo Real em Python

WebSockets e SSE com FastAPI, do eco ao chat e às notificações, com as falhas…

Algoritmos de Ordenação e Busca
Algoritmos de Ordenação e Busca

Os algoritmos clássicos de ordenação e busca, com o que cada um ensina a…