Chapter 41, Projects
Basic Project: student performance
The final project of the original course, now complete and with the solutions. Five arrays, two hundred students, and three levels of questions. Do this after chapter 18.
The files for this chapter are in projetos/estudantes/.
The problem
All previous chapters used one array at a time. Real data consists of several arrays, each describing an attribute of the same people: the index i of each array is always the same student. Here there are five, all of size 200: the identifier, the grade, the attendance, the hours of study per week, and the assignments submitted.
| Level | What is asked | Technique |
|---|---|---|
| 1 | Maximum, minimum, average, median, standard deviation, and variance of the grades | Aggregations (chapter 14) |
| 2 | Who scored more than 90, less than 40, has attendance above 90%, studies more than 5 hours, submitted more than 8 assignments | Masks (chapter 9) |
| 3 | Combinations: high grade and high attendance, low grade and low attendance, high attendance but low grade, little study but high grade | & and \| (chapter 9) |
To the original project, I added what makes the analysis turn into a conclusion: the correlation of each variable with the grade (chapter 22) and the profile of a group, to answer the question that level 3 leaves open: "what do these students have in common?".
How I would draw
The five arrays go inside an dataclass immutable, the Turma, which ensures in the constructor what the statement only assumes: that they all have the same size. If someone creates an Turma with grades of 200 students and attendance of 199, the error appears right away, with a clear message, and not three functions later, as a wrong result.
The data is synthetic and has structure: the grade is constructed from attendance, study hours, and assignments, plus some noise. That’s why the patterns that the analysis finds are real. And a decision worth noting: the original course formula left the average grade close to 87, with almost all groups in level 3 empty (no one below 40, no one with high attendance and low grade). An analysis whose answer is always zero teaches nothing, so I adjusted the coefficients for the average grade to be around 63, with enough dispersion for the groups to exist. A test keeps this.
The code
The data and the generator. Note that each array has its type (float64 or int64), declared in the annotations:
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)
The three analyses are one function per level. Each one returns data (numbers and ids), not text: formatting is another job, done in the report. It’s the usual separation between calculating and showing:
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]),
}
Notice two details. In the combined conditions, each comparison goes in parentheses and the operator is &, not and (chapter 9). And the np.isin of the perfil_do_grupo transforms a list of ids into a mask, which selects the rows of the group in all arrays at once.
The report assembles the text and the CLI exposes three commands (relatorio, aluno and 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
The tests
The test I like the most compares the vectorized version with an obviously correct loop. If the two counts match, the mask does what I think it does. The others check relationships that need to hold (level 3 is a subset of level 2, the median is between the minimum and maximum) and the exceptions, like the class with arrays of different sizes:
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)
Run
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
Consulting a student, like the "search tool" from the original course:
uv run estudantes aluno 17
id 17
nota 71
presenca 88.7
horas_estudo 5.1
tarefas 9
Reading the result
A report is only valuable if someone interprets it. Three conclusions, and what supports them:
- Study hours weigh more than attendance. The correlation with the grade is 0.58 for hours and 0.18 for attendance. This is consistent with how the data was generated (hours have the highest coefficient and the greatest variation), and it's the kind of thing that the analysis with good accuracy. It is the same calculation that chapter 40 compares with scikit-learn. derives from the data.
- High attendance does not guarantee a high grade. Of the 51 students with attendance above 90%, 19 have grades below 60. Their profile shows the reason: they study, on average, 2.8 hours per week, compared to about 4 in the whole class.
- Zero is also an answer. No one studies less than 2 hours and scores more than 80. In a class where study is the dominant factor, this is expected, and the test
test_estudar_pouco_e_tirar_nota_alta_e_rarorecords this as a property, not as an error.
Challenges
- Quartiles. Divide the class into four grade ranges with
np.percentileandnp.digitizeand show the average study hours in each range. - Visualize. With chapter 39, plot the grade against study hours (
scatter) and the histogram of grades. - Redo in pandas. Rewrite the analysis with a
DataFrame(chapter 38) and compare: what became shorter, and what became slower? - Predict the grade. Fit a linear regression (chapter 40) of the grades against the three variables and see if the coefficients recover those that were used to generate the data.