Pular para o conteúdo

    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ívelO que se pedeTécnica
    1Maior, menor, média, mediana, desvio padrão e variância das notasAgregações (capítulo 14)
    2Quem tirou mais de 90, menos de 40, tem presença acima de 90%, estuda mais de 5 horas, entregou mais de 8 tarefasMáscaras (capítulo 9)
    3Combinaçõ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:

    projetos/estudantes/src/estudantes/dados.py
    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:

    projetos/estudantes/src/estudantes/analise.py
    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):

    projetos/estudantes/src/estudantes/relatorio.py
    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)
    
    projetos/estudantes/src/estudantes/cli.py
    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:

    projetos/estudantes/tests/test_dados.py
    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)
    
    projetos/estudantes/tests/test_analise.py
    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
    
    projetos/estudantes/tests/test_cli.py
    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

    Terminal
    cd projetos/estudantes
    uv sync
    uv run pytest
    uv run estudantes relatorio
    
    Saída
    ..............                                                           [100%]
    14 passed
    
    O relatório (execução real, semente 42)
    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:

    Terminal
    uv run estudantes aluno 17
    
    Saída
    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_raro registra isso como uma propriedade, e não como um erro.

    Desafios

    1. Quartis. Divida a turma em quatro faixas de nota com np.percentile e np.digitize e mostre a média de horas de estudo em cada faixa.
    2. Visualizar. Com o capítulo 39, desenhe a nota contra as horas de estudo (scatter) e o histograma das notas.
    3. 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?
    4. 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.