Capítulo 41, Projetos
Projeto Básico: desempenho de estudantes
O projeto final do curso original, agora completo e com as soluções. Cinco arrays, duzentos estudantes e três níveis de pergunta. Faça depois do capítulo 18.
Os arquivos deste capítulo estão em projetos/estudantes/.
O problema
Todos os capítulos anteriores usaram um array de cada vez. Dados reais são vários arrays, cada um descrevendo um atributo das mesmas pessoas: o índice i de cada array é sempre o mesmo estudante. Aqui são cinco, todos de tamanho 200: o identificador, a nota, a presença, as horas de estudo por semana e as tarefas entregues.
| Nível | O que se pede | Técnica |
|---|---|---|
| 1 | Maior, menor, média, mediana, desvio padrão e variância das notas | Agregações (capítulo 14) |
| 2 | Quem tirou mais de 90, menos de 40, tem presença acima de 90%, estuda mais de 5 horas, entregou mais de 8 tarefas | Máscaras (capítulo 9) |
| 3 | Combinações: nota alta e presença alta, nota baixa e presença baixa, presença alta mas nota baixa, pouco estudo mas nota alta | & e \| (capítulo 9) |
Ao projeto original eu acrescentei o que faz a análise virar uma conclusão: a correlação de cada variável com a nota (capítulo 22) e o perfil de um grupo, para responder à pergunta que o nível 3 deixa em aberto: "o que esses estudantes têm em comum?".
Como eu desenharia
Os cinco arrays vão dentro de uma dataclass imutável, a Turma, que garante no construtor o que o enunciado só supõe: que todos têm o mesmo tamanho. Se alguém criar uma Turma com notas de 200 estudantes e presenças de 199, o erro aparece na hora, com uma mensagem clara, e não três funções adiante, como um resultado errado.
Os dados são sintéticos e têm estrutura: a nota é construída a partir da presença, das horas de estudo e das tarefas, mais um ruído. Por isso os padrões que a análise encontra são reais. E uma decisão que vale registrar: a fórmula original do curso deixava a nota média perto de 87, com quase todos os grupos do nível 3 vazios (ninguém abaixo de 40, ninguém com presença alta e nota baixa). Uma análise cuja resposta é sempre zero não ensina nada, então eu ajustei os coeficientes para a nota média ficar perto de 63, com dispersão suficiente para os grupos existirem. Um teste guarda isso.
O código
Os dados e o gerador. Note que cada array tem o seu tipo (float64 ou int64), declarado nas anotações:
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
Reais = npt.NDArray[np.float64]
Inteiros = npt.NDArray[np.int64]
@dataclass(frozen=True)
class Turma:
"""Cinco arrays do mesmo tamanho: a posição i descreve sempre o mesmo estudante."""
ids: Inteiros
notas: Reais
presenca: Reais
horas_estudo: Reais
tarefas: Inteiros
def __post_init__(self) -> None:
tamanhos = {len(self.ids), len(self.notas), len(self.presenca)}
tamanhos |= {len(self.horas_estudo), len(self.tarefas)}
if len(tamanhos) != 1:
raise ValueError("todos os arrays da turma precisam ter o mesmo tamanho")
def __len__(self) -> int:
return len(self.ids)
def gerar_turma(n: int = 200, semente: int = 42) -> Turma:
"""Gera uma turma sintética em que a nota **depende** das outras variáveis.
A nota é construída a partir da presença, das horas de estudo e das tarefas, mais um ruído,
então os padrões que a análise encontra são reais, e não coincidência. Os coeficientes
foram escolhidos para a nota média ficar perto de 63, com bastante dispersão: assim todos
os grupos que o projeto pede (notas acima de 90, abaixo de 40, presença alta com nota
baixa...) têm estudantes, e nenhuma pergunta termina com resposta zero.
"""
rng = np.random.default_rng(semente)
ids = np.arange(1, n + 1)
presenca = np.clip(rng.normal(85, 10, n), 50, 100).round(1)
horas_estudo = np.clip(rng.normal(4, 2, n), 0, 10).round(1)
tarefas = rng.integers(0, 11, n)
ruido = rng.normal(0, 10, n)
notas = np.clip(18 + presenca * 0.25 + horas_estudo * 4.5 + tarefas * 1.2 + ruido, 0, 100)
notas = notas.round(1)
return Turma(ids, notas, presenca, horas_estudo, tarefas)
As três análises são uma função por nível. Cada uma devolve dados (números e ids), e não texto: formatar é outro trabalho, feito no relatório. É a separação de sempre entre calcular e mostrar:
import numpy as np
from estudantes.dados import Inteiros, Turma
def nivel_1(t: Turma) -> dict[str, float]:
"""Estatísticas descritivas das notas."""
return {
"maior": float(t.notas.max()),
"menor": float(t.notas.min()),
"media": float(t.notas.mean()),
"mediana": float(np.median(t.notas)),
"desvio_padrao": float(t.notas.std()),
"variancia": float(t.notas.var()),
}
def nivel_2(t: Turma) -> dict[str, Inteiros]:
"""Filtros por uma condição. Devolve os ids dos estudantes de cada grupo."""
return {
"nota_acima_de_90": t.ids[t.notas > 90],
"nota_abaixo_de_40": t.ids[t.notas < 40],
"presenca_acima_de_90": t.ids[t.presenca > 90],
"estuda_mais_de_5h": t.ids[t.horas_estudo > 5],
"mais_de_8_tarefas": t.ids[t.tarefas > 8],
}
def nivel_3(t: Turma) -> dict[str, Inteiros]:
"""Filtros por várias condições. Cada condição vai entre parênteses, e o operador é & ou |."""
return {
"nota_alta_e_presenca_alta": t.ids[(t.notas > 80) & (t.presenca > 90)],
"nota_baixa_e_presenca_baixa": t.ids[(t.notas < 40) & (t.presenca < 75)],
"presenca_alta_nota_baixa": t.ids[(t.presenca > 90) & (t.notas < 60)],
"pouco_estudo_nota_alta": t.ids[(t.horas_estudo < 2) & (t.notas > 80)],
}
def correlacoes(t: Turma) -> dict[str, float]:
"""Correlação de cada variável com a nota (a primeira linha da matriz de correlação)."""
variaveis = {
"presenca": t.presenca,
"horas_estudo": t.horas_estudo,
"tarefas": t.tarefas.astype(np.float64),
}
matriz = np.corrcoef(np.vstack([t.notas, *variaveis.values()]))
return {nome: float(matriz[0, i + 1]) for i, nome in enumerate(variaveis)}
def perfil_do_grupo(t: Turma, ids: Inteiros) -> dict[str, float]:
"""Média de cada atributo entre os estudantes do grupo."""
mascara = np.isin(t.ids, ids)
if not mascara.any():
return {}
return {
"tamanho": float(mascara.sum()),
"nota_media": float(t.notas[mascara].mean()),
"presenca_media": float(t.presenca[mascara].mean()),
"horas_estudo_medias": float(t.horas_estudo[mascara].mean()),
"tarefas_medias": float(t.tarefas[mascara].mean()),
}
def melhores(t: Turma, k: int = 5) -> Inteiros:
"""Ids dos k estudantes com as maiores notas, da maior para a menor."""
return t.ids[np.argsort(-t.notas, kind="stable")[:k]]
def estudante(t: Turma, estudante_id: int) -> dict[str, float]:
"""Os dados de um estudante pelo id (os ids vão de 1 a n)."""
posicoes = np.flatnonzero(t.ids == estudante_id)
if posicoes.size == 0:
raise KeyError(f"não existe o estudante {estudante_id}")
i = int(posicoes[0])
return {
"id": float(t.ids[i]),
"nota": float(t.notas[i]),
"presenca": float(t.presenca[i]),
"horas_estudo": float(t.horas_estudo[i]),
"tarefas": float(t.tarefas[i]),
}
Repare em dois detalhes. Nas condições combinadas, cada comparação vai entre parênteses e o operador é &, não and (capítulo 9). E o np.isin do perfil_do_grupo transforma uma lista de ids em uma máscara, que seleciona as linhas do grupo em todos os arrays de uma vez.
O relatório monta o texto e a CLI expõe três comandos (relatorio, aluno e exportar):
from estudantes.analise import (
correlacoes,
melhores,
nivel_1,
nivel_2,
nivel_3,
perfil_do_grupo,
)
from estudantes.dados import Turma
def montar_relatorio(t: Turma) -> str:
linhas = [f"Turma com {len(t)} estudantes", "", "Nível 1: estatísticas das notas"]
for nome, valor in nivel_1(t).items():
linhas.append(f" {nome:<14}{valor:>8.2f}")
linhas += ["", "Nível 2: grupos por uma condição"]
for nome, ids in nivel_2(t).items():
linhas.append(f" {nome:<28}{len(ids):>4} estudantes")
linhas += ["", "Nível 3: grupos por várias condições"]
grupos = nivel_3(t)
for nome, ids in grupos.items():
linhas.append(f" {nome:<28}{len(ids):>4} estudantes")
linhas += ["", "Correlação de cada variável com a nota"]
for nome, valor in correlacoes(t).items():
linhas.append(f" {nome:<14}{valor:>7.2f}")
perfil = perfil_do_grupo(t, grupos["presenca_alta_nota_baixa"])
if perfil:
linhas += ["", "Perfil de quem tem presença alta e nota baixa"]
linhas.append(f" horas de estudo médias {perfil['horas_estudo_medias']:.1f}")
linhas.append(f" tarefas médias {perfil['tarefas_medias']:.1f}")
linhas += ["", "Os 5 melhores (ids): " + ", ".join(str(int(i)) for i in melhores(t, 5))]
return "\n".join(linhas)
import argparse
from pathlib import Path
import numpy as np
from estudantes.analise import estudante
from estudantes.dados import gerar_turma
from estudantes.relatorio import montar_relatorio
def criar_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="estudantes", description="Análise de uma turma")
parser.add_argument("--n", type=int, default=200, help="número de estudantes")
parser.add_argument("--semente", type=int, default=42, help="semente do gerador")
sub = parser.add_subparsers(dest="comando", required=True)
sub.add_parser("relatorio", help="mostra o relatório completo")
aluno = sub.add_parser("aluno", help="mostra os dados de um estudante")
aluno.add_argument("id", type=int)
exportar = sub.add_parser("exportar", help="grava a turma em um arquivo CSV")
exportar.add_argument("arquivo", type=Path)
return parser
def main(argv: list[str] | None = None) -> int:
args = criar_parser().parse_args(argv)
turma = gerar_turma(args.n, args.semente)
if args.comando == "relatorio":
print(montar_relatorio(turma))
elif args.comando == "aluno":
try:
dados = estudante(turma, args.id)
except KeyError as erro:
print(f"Erro: {erro.args[0]}")
return 1
for chave, valor in dados.items():
print(f"{chave:<14}{valor:g}")
else:
tabela = np.column_stack(
[turma.ids, turma.notas, turma.presenca, turma.horas_estudo, turma.tarefas]
)
np.savetxt(
args.arquivo,
tabela,
delimiter=",",
fmt="%g",
header="id,nota,presenca,horas_estudo,tarefas",
comments="",
)
print(f"{len(turma)} estudantes gravados em {args.arquivo}")
return 0
Os testes
O teste que eu mais gosto compara a versão vetorizada com um laço obviamente correto. Se as duas contagens batem, a máscara faz o que eu acho que faz. Os demais verificam relações que precisam valer (o nível 3 é subconjunto do nível 2, a mediana está entre o mínimo e o máximo) e as exceções, como a turma com arrays de tamanhos diferentes:
import numpy as np
import pytest
from estudantes.dados import Turma, gerar_turma
def test_tamanho_e_intervalos() -> None:
t = gerar_turma(200)
assert len(t) == 200
assert t.ids[0] == 1 and t.ids[-1] == 200
assert t.presenca.min() >= 50 and t.presenca.max() <= 100
assert t.horas_estudo.min() >= 0 and t.horas_estudo.max() <= 10
assert t.tarefas.min() >= 0 and t.tarefas.max() <= 10
assert t.notas.min() >= 0 and t.notas.max() <= 100
def test_mesma_semente_gera_a_mesma_turma() -> None:
a, b = gerar_turma(50, semente=1), gerar_turma(50, semente=1)
assert np.array_equal(a.notas, b.notas)
assert not np.array_equal(a.notas, gerar_turma(50, semente=2).notas)
def test_arrays_de_tamanhos_diferentes_sao_recusados() -> None:
t = gerar_turma(10)
with pytest.raises(ValueError, match="mesmo tamanho"):
Turma(t.ids, t.notas[:5], t.presenca, t.horas_estudo, t.tarefas)
import numpy as np
from estudantes.analise import (
correlacoes,
estudante,
melhores,
nivel_1,
nivel_2,
nivel_3,
perfil_do_grupo,
)
from estudantes.dados import gerar_turma
T = gerar_turma(200)
def test_nivel_1_e_coerente() -> None:
r = nivel_1(T)
assert r["menor"] <= r["mediana"] <= r["maior"]
assert r["menor"] <= r["media"] <= r["maior"]
assert np.isclose(r["desvio_padrao"] ** 2, r["variancia"])
def test_nivel_2_confere_com_contagem_por_laco() -> None:
"""A versão vetorizada precisa dar o mesmo resultado de um laço obviamente correto."""
grupos = nivel_2(T)
por_laco = [int(T.ids[i]) for i in range(len(T)) if T.notas[i] > 90]
assert grupos["nota_acima_de_90"].tolist() == por_laco
por_laco = [int(T.ids[i]) for i in range(len(T)) if T.tarefas[i] > 8]
assert grupos["mais_de_8_tarefas"].tolist() == por_laco
def test_nivel_3_e_subconjunto_do_nivel_2() -> None:
n2, n3 = nivel_2(T), nivel_3(T)
assert set(n3["nota_alta_e_presenca_alta"]) <= set(n2["presenca_acima_de_90"])
assert set(n3["presenca_alta_nota_baixa"]) <= set(n2["presenca_acima_de_90"])
assert set(n3["nota_baixa_e_presenca_baixa"]) <= set(n2["nota_abaixo_de_40"])
def test_a_nota_correlaciona_mais_com_as_horas_de_estudo() -> None:
c = correlacoes(T)
assert c["horas_estudo"] > c["tarefas"] > c["presenca"] > 0
def test_os_grupos_pedidos_tem_estudantes() -> None:
"""Um projeto cuja resposta é sempre zero não ensina nada: a turma precisa ser variada."""
excecao = "pouco_estudo_nota_alta"
for nome, grupo in {**nivel_2(T), **nivel_3(T)}.items():
if nome != excecao:
assert len(grupo) > 0, nome
def test_estudar_pouco_e_tirar_nota_alta_e_raro() -> None:
"""Esse grupo pode ser vazio: as horas de estudo são o que mais pesa na nota."""
n3 = nivel_3(T)
assert len(n3["pouco_estudo_nota_alta"]) < len(n3["presenca_alta_nota_baixa"])
def test_perfil_do_grupo() -> None:
ids = nivel_2(T)["nota_acima_de_90"]
perfil = perfil_do_grupo(T, ids)
assert perfil["tamanho"] == len(ids)
assert perfil["nota_media"] > 90
assert perfil_do_grupo(T, np.array([], dtype=np.int64)) == {}
def test_melhores_e_busca_por_id() -> None:
topo = melhores(T, 3)
notas_do_topo = [estudante(T, int(i))["nota"] for i in topo]
assert notas_do_topo == sorted(notas_do_topo, reverse=True)
assert notas_do_topo[0] == T.notas.max()
assert estudante(T, 1)["id"] == 1
from pathlib import Path
import numpy as np
import pytest
from estudantes.cli import main
def test_relatorio(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["relatorio"]) == 0
saida = capsys.readouterr().out
assert "Turma com 200 estudantes" in saida
assert "Nível 3" in saida
def test_aluno_inexistente_devolve_1(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["aluno", "9999"]) == 1
assert "não existe o estudante 9999" in capsys.readouterr().out
def test_exportar_csv(tmp_path: Path) -> None:
destino = tmp_path / "turma.csv"
assert main(["--n", "30", "exportar", str(destino)]) == 0
lido = np.loadtxt(destino, delimiter=",", skiprows=1)
assert lido.shape == (30, 5)
Rodar
cd projetos/estudantes
uv sync
uv run pytest
uv run estudantes relatorio
.............. [100%]
14 passed
Turma com 200 estudantes
Nível 1: estatísticas das notas
maior 98.20
menor 26.80
media 62.21
mediana 63.15
desvio_padrao 14.22
variancia 202.17
Nível 2: grupos por uma condição
nota_acima_de_90 5 estudantes
nota_abaixo_de_40 13 estudantes
presenca_acima_de_90 51 estudantes
estuda_mais_de_5h 57 estudantes
mais_de_8_tarefas 34 estudantes
Nível 3: grupos por várias condições
nota_alta_e_presenca_alta 6 estudantes
nota_baixa_e_presenca_baixa 3 estudantes
presenca_alta_nota_baixa 19 estudantes
pouco_estudo_nota_alta 0 estudantes
Correlação de cada variável com a nota
presenca 0.18
horas_estudo 0.58
tarefas 0.23
Perfil de quem tem presença alta e nota baixa
horas de estudo médias 2.8
tarefas médias 3.7
Os 5 melhores (ids): 28, 182, 125, 176, 74
Consultando um estudante, como a "ferramenta de busca" do curso original:
uv run estudantes aluno 17
id 17
nota 71
presenca 88.7
horas_estudo 5.1
tarefas 9
Lendo o resultado
Um relatório só vale se alguém o interpreta. Três conclusões, e o que as sustenta:
- As horas de estudo pesam mais do que a presença. A correlação com a nota é de 0,58 para as horas e de 0,18 para a presença. Isso é coerente com a forma como os dados foram gerados (as horas têm o maior coeficiente e a maior variação), e é o tipo de coisa que a análise recupera a partir dos dados.
- Presença alta não garante nota alta. Dos 51 estudantes com presença acima de 90%, 19 têm nota abaixo de 60. O perfil deles mostra o motivo: estudam, em média, 2,8 horas por semana, contra cerca de 4 na turma toda.
- O zero também é uma resposta. Ninguém estuda menos de 2 horas e tira mais de 80. Em uma turma em que o estudo é o fator dominante, é o esperado, e o teste
test_estudar_pouco_e_tirar_nota_alta_e_raroregistra isso como uma propriedade, e não como um erro.
Desafios
- Quartis. Divida a turma em quatro faixas de nota com
np.percentileenp.digitizee mostre a média de horas de estudo em cada faixa. - Visualizar. Com o capítulo 39, desenhe a nota contra as horas de estudo (
scatter) e o histograma das notas. - Refazer no pandas. Reescreva a análise com um
DataFrame(capítulo 38) e compare: o que ficou mais curto, e o que ficou mais lento? - Prever a nota. Ajuste uma regressão linear (capítulo 40) das notas contra as três variáveis e veja se os coeficientes recuperam os que foram usados para gerar os dados.