Skip to content

    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.

    LevelWhat is askedTechnique
    1Maximum, minimum, average, median, standard deviation, and variance of the gradesAggregations (chapter 14)
    2Who scored more than 90, less than 40, has attendance above 90%, studies more than 5 hours, submitted more than 8 assignmentsMasks (chapter 9)
    3Combinations: 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:

    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)
    

    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:

    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]),
        }
    

    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):

    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
    

    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:

    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)
    

    Run

    Terminal
    cd projetos/estudantes
    uv sync
    uv run pytest
    uv run estudantes relatorio
    
    Output
    ..............                                                           [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
    

    Consulting a student, like the "search tool" from the original course:

    Terminal
    uv run estudantes aluno 17
    
    Output
    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_raro records this as a property, not as an error.

    Challenges

    1. Quartiles. Divide the class into four grade ranges with np.percentile and np.digitize and show the average study hours in each range.
    2. Visualize. With chapter 39, plot the grade against study hours (scatter) and the histogram of grades.
    3. Redo in pandas. Rewrite the analysis with a DataFrame (chapter 38) and compare: what became shorter, and what became slower?
    4. 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.