Chapter 38, Projects
Advanced Project: a data pipeline
Real dirty data, and one rule: no row disappears without leaving a trace. The pipeline validates, sends the bad rows to a quarantine with the reason, types the rest, and aggregates **in chunks**, with a result identical to reading everything at once. Do this after chapter 35.
The problem
A system exports millions of hospital synthetic attendances (there are no real people or clinical data here) and the file arrives with issues: high You will see, in much material, of hospitalization, negative values, ages of 150 years, repeated ids, dates that are not dates, non-existent agreements. The analysis needs reliable numbers and a record of what was rejected, in a file that does not comfortably fit in memory.
| Requirement | How the project meets | Chapter |
|---|---|---|
| Discovering what is wrong, without discarding anything | A boolean column per rule (marcar_problemas) | 33 |
| Do not lose rows | Clean and quarantine add up the total | 33 |
| Use less memory | Right types (category, smaller integers) | 29 |
| Read large files | Chunked reading, with state between them | 32 |
| Ensure that the chunk does not change the result | Test: chunks equal to read everything | 32, 33 |
Code without loops and without apply | Operations on columns and pipe | 28, 30 |
The decisions I made
The validation marks, it does not discard. The marcar_problemas returns a table with a boolean column per rule and one row per service. Who decides what to do with the marking is the next step. This allows counting each problem, and a row with multiple problems appears in all its columns.
The quarantine keeps the reason. The rejected rows go to a file with one column motivos (alta_antes_da_internacao,valor_negativo), and clean + quarantine = total, always. There is a test for that.
A repeated id between chunks is only detected with state. If the same id_atendimento appears in chunks 1 and 5, each chunk, alone, sees a unique id. The project carries the set of ids already seen from one chunk to another, and a test proves that the result in chunks is identical to reading everything.
The reasons come from the totals. The percentage paid is soma do pago / soma do cobrado, calculated after summing the chunks, and never an average of averages (chapter 32).
One thing to note about the valor_pago absent: pending payment it is not an error, and the line is maintained. But the charge of this line does not enter the denominator of the paid percentage (cobrado_com_pagamento), otherwise the percentage would be artificially low.
The code
The generator creates the data with 7 injected problems, in randomly selected lines with a fixed seed, for the project to be reproducible and for each rule to have something to find:
import numpy as np
import pandas as pd
HOSPITAIS = [f"Hospital {letra}" for letra in "ABCDEFGHIJKL"]
CONVENIOS = ["Alfa Saúde", "Beta Plano", "Gama Vida", "Delta Med", "Particular", "SUS"]
ESPECIALIDADES = [
"Cardiologia", "Ortopedia", "Pediatria", "Clínica Geral",
"Neurologia", "Oncologia", "Obstetrícia", "Cirurgia",
] # fmt: skip
PROBLEMAS_INJETADOS = (
"data_invalida",
"alta_antes",
"valor_negativo",
"pago_maior",
"id_repetido",
"idade_absurda",
"convenio_fantasma",
)
def gerar_atendimentos(n: int, semente: int = 11) -> pd.DataFrame:
"""Atendimentos sintéticos, com cerca de 1% de cada problema, em linhas sorteadas e fixas.
Os convênios também aparecem com caixa e espaços diferentes (isso é sujeira, não erro).
"""
rng = np.random.default_rng(semente)
internacao = np.datetime64("2024-01-01") + rng.integers(0, 730, n).astype("timedelta64[D]")
permanencia = rng.integers(1, 15, n)
alta = internacao + permanencia.astype("timedelta64[D]")
cobrado = (permanencia * rng.uniform(800, 2500, n) + rng.uniform(200, 3000, n)).round(2)
pago = (cobrado * rng.uniform(0.7, 1.0, n)).round(2)
pago[rng.choice(n, max(1, n // 20), replace=False)] = np.nan # pagamento ainda pendente
convenio = np.array(CONVENIOS)[rng.integers(0, len(CONVENIOS), n)].astype(object)
sujos = rng.choice(n, max(1, n // 10), replace=False)
convenio[sujos] = [c.lower() + " " for c in convenio[sujos]]
df = pd.DataFrame(
{
"id_atendimento": np.arange(1, n + 1),
"id_paciente": rng.integers(1, n // 3 + 2, n),
"hospital": np.array(HOSPITAIS)[rng.integers(0, len(HOSPITAIS), n)],
"convenio": convenio,
"especialidade": np.array(ESPECIALIDADES)[rng.integers(0, len(ESPECIALIDADES), n)],
"data_internacao": internacao.astype(str),
"data_alta": alta.astype(str),
"valor_cobrado": cobrado,
"valor_pago": pago,
"idade": rng.integers(0, 95, n),
}
)
k = max(1, n // 100)
sorteadas = rng.permutation(n)
fatias = {nome: sorteadas[i * k : (i + 1) * k] for i, nome in enumerate(PROBLEMAS_INJETADOS)}
df.loc[fatias["data_invalida"], "data_alta"] = "sem data"
df.loc[fatias["alta_antes"], ["data_internacao", "data_alta"]] = df.loc[
fatias["alta_antes"], ["data_alta", "data_internacao"]
].to_numpy()
df.loc[fatias["valor_negativo"], "valor_cobrado"] = -df.loc[
fatias["valor_negativo"], "valor_cobrado"
]
df.loc[fatias["pago_maior"], "valor_pago"] = df.loc[fatias["pago_maior"], "valor_cobrado"] * 1.5
df.loc[fatias["idade_absurda"], "idade"] = 150
df.loc[fatias["convenio_fantasma"], "convenio"] = "Convênio Fantasma"
repetidas = fatias["id_repetido"]
df.loc[repetidas, "id_atendimento"] = (
df.loc[repetidas, "id_atendimento"].to_numpy() % max(1, n // 2) + 1
)
return df
The validation is a single vectorized function. The ids_vistos is what allows detecting repetitions between pieces:
from collections.abc import Collection
import pandas as pd
from saude.gerar import CONVENIOS
_MAPA = {c.lower(): c for c in CONVENIOS}
def normalizar_convenio(serie: pd.Series) -> pd.Series:
"""Nome correto do convênio. O que não está na lista vira ausente (e é detectado)."""
return serie.str.strip().str.lower().map(_MAPA)
def marcar_problemas(df: pd.DataFrame, ids_vistos: Collection[int] = frozenset()) -> pd.DataFrame:
"""Uma coluna booleana por regra, uma linha por atendimento. Nada é descartado aqui.
`ids_vistos` são os ids de pedaços anteriores: um id repetido entre pedaços só é detectado
se o estado for levado de um pedaço para o outro.
"""
internacao = pd.to_datetime(df["data_internacao"], errors="coerce")
alta = pd.to_datetime(df["data_alta"], errors="coerce")
return pd.DataFrame(
{
"data_invalida": internacao.isna() | alta.isna(),
"alta_antes_da_internacao": alta < internacao,
"valor_negativo": (df["valor_cobrado"] < 0) | (df["valor_pago"] < 0),
"pago_maior_que_cobrado": df["valor_pago"] > df["valor_cobrado"],
"id_repetido": df["id_atendimento"].duplicated()
| df["id_atendimento"].isin(ids_vistos),
"idade_invalida": ~df["idade"].between(0, 120),
"convenio_desconhecido": normalizar_convenio(df["convenio"]).isna(),
},
index=df.index,
)
def contar_problemas(problemas: pd.DataFrame) -> dict[str, int]:
return {motivo: int(n) for motivo, n in problemas.sum().items()}
The cleaning divides into two tables and types only what passed. Note the Resultado: three things that always travel together:
from collections.abc import Collection
from dataclasses import dataclass
import pandas as pd
from saude.validacao import contar_problemas, marcar_problemas, normalizar_convenio
@dataclass(frozen=True)
class Resultado:
limpos: pd.DataFrame
quarentena: pd.DataFrame
contagem: dict[str, int]
def tipar(df: pd.DataFrame) -> pd.DataFrame:
"""Tipos certos e colunas derivadas. Só vale para linhas já validadas."""
return (
df.assign(
data_internacao=pd.to_datetime(df["data_internacao"]),
data_alta=pd.to_datetime(df["data_alta"]),
convenio=normalizar_convenio(df["convenio"]),
)
.assign(
dias_internacao=lambda d: (d["data_alta"] - d["data_internacao"]).dt.days,
taxa_glosa=lambda d: 1 - d["valor_pago"] / d["valor_cobrado"],
mes=lambda d: d["data_internacao"].dt.to_period("M").astype(str),
)
.astype(
{
"hospital": "category",
"convenio": "category",
"especialidade": "category",
"id_atendimento": "int32",
"id_paciente": "int32",
"idade": "int16",
"dias_internacao": "int16",
}
)
)
def separar(df: pd.DataFrame, ids_vistos: Collection[int] = frozenset()) -> Resultado:
"""Divide em limpos e quarentena: nenhuma linha some, cada uma vai para um dos lados."""
problemas = marcar_problemas(df, ids_vistos)
invalido = problemas.any(axis=1)
rotulos = problemas.columns.to_numpy()
motivos = [",".join(rotulos[linha]) for linha in problemas[invalido].to_numpy()]
quarentena = df[invalido].assign(motivos=motivos)
return Resultado(tipar(df[~invalido]), quarentena, contar_problemas(problemas))
def memoria_mb(df: pd.DataFrame) -> float:
return float(df.memory_usage(deep=True).sum() / 1024 / 1024)
The aggregation separates what can be summed between pieces (somar) from what only makes sense afterwards (finalizar):
from collections import Counter
from pathlib import Path
import pandas as pd
from saude.limpeza import separar
CHAVES = ["hospital", "mes"]
SOMAS = ["atendimentos", "dias", "cobrado", "pago", "cobrado_com_pagamento"]
def somar(limpos: pd.DataFrame) -> pd.DataFrame:
"""Somas e contagens por hospital e mês: o que pode ser combinado entre pedaços."""
base = limpos.assign(
hospital=limpos["hospital"].astype(str),
cobrado_com_pagamento=limpos["valor_cobrado"].where(limpos["valor_pago"].notna()),
)
return (
base.groupby(CHAVES, observed=True)
.agg(
atendimentos=("id_atendimento", "count"),
dias=("dias_internacao", "sum"),
cobrado=("valor_cobrado", "sum"),
pago=("valor_pago", "sum"),
cobrado_com_pagamento=("cobrado_com_pagamento", "sum"),
)
.reset_index()
)
def finalizar(somas: pd.DataFrame) -> pd.DataFrame:
"""As razões saem dos totais, nunca de médias de médias."""
return (
somas.groupby(CHAVES, as_index=False)[SOMAS]
.sum()
.assign(
permanencia_media=lambda d: d["dias"] / d["atendimentos"],
percentual_pago=lambda d: d["pago"] / d["cobrado_com_pagamento"],
)
.sort_values(CHAVES)
.reset_index(drop=True)
)
def agregar(limpos: pd.DataFrame) -> pd.DataFrame:
return finalizar(somar(limpos))
def agregar_em_pedacos(
caminho: Path, tamanho: int, quarentena_csv: Path | None = None
) -> tuple[pd.DataFrame, dict[str, int], int]:
"""Lê o arquivo em pedaços, levando o conjunto de ids vistos de um pedaço ao outro."""
vistos: set[int] = set()
parciais = []
contagem: Counter[str] = Counter()
em_quarentena = 0
primeiro_pedaco = True
for pedaco in pd.read_csv(caminho, chunksize=tamanho):
resultado = separar(pedaco, frozenset(vistos))
vistos.update(pedaco["id_atendimento"].tolist())
parciais.append(somar(resultado.limpos))
contagem.update(resultado.contagem)
em_quarentena += len(resultado.quarentena)
if quarentena_csv is not None:
resultado.quarentena.to_csv(
quarentena_csv,
mode="w" if primeiro_pedaco else "a",
header=primeiro_pedaco,
index=False,
)
primeiro_pedaco = False
return finalizar(pd.concat(parciais, ignore_index=True)), dict(contagem), em_quarentena
import argparse
import time
from pathlib import Path
import pandas as pd
from saude.agregacao import agregar_em_pedacos
from saude.gerar import gerar_atendimentos
from saude.limpeza import memoria_mb, separar
def criar_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="saude", description="Pipeline de atendimentos")
sub = parser.add_subparsers(dest="comando", required=True)
gerar = sub.add_parser("gerar", help="gera um arquivo de atendimentos sintéticos")
gerar.add_argument("--linhas", type=int, default=200_000)
gerar.add_argument("--semente", type=int, default=11)
gerar.add_argument("--saida", type=Path, required=True)
processar = sub.add_parser("processar", help="valida, separa a quarentena e agrega")
processar.add_argument("arquivo", type=Path)
processar.add_argument("--saida", type=Path, required=True)
processar.add_argument("--tamanho", type=int, default=50_000, help="linhas por pedaço")
return parser
def main(argv: list[str] | None = None) -> int:
args = criar_parser().parse_args(argv)
if args.comando == "gerar":
args.saida.parent.mkdir(parents=True, exist_ok=True)
gerar_atendimentos(args.linhas, args.semente).to_csv(args.saida, index=False)
print(f"{args.linhas} atendimentos gravados em {args.saida}")
return 0
args.saida.mkdir(parents=True, exist_ok=True)
inicio = time.perf_counter()
try:
resumo, contagem, quarentena = agregar_em_pedacos(
args.arquivo, args.tamanho, args.saida / "quarentena.csv"
)
amostra = pd.read_csv(args.arquivo, nrows=args.tamanho)
except (OSError, ValueError, KeyError) as erro:
print(f"Erro: {erro}")
return 1
resumo.to_parquet(args.saida / "resumo_hospital_mes.parquet", index=False)
validas = int(resumo["atendimentos"].sum())
print(
f"linhas lidas: {validas + quarentena} | válidas: {validas} | em quarentena: {quarentena}"
)
print("linhas com cada problema (uma linha pode ter vários):")
for motivo, n in contagem.items():
print(f" {motivo:<26}{n:>6}")
print(f"resumo: {len(resumo)} combinações de hospital e mês")
crus = amostra.head(1000)
tipados = separar(crus).limpos
print(
f"memória ({len(tipados)} linhas válidas): "
f"{memoria_mb(crus.loc[tipados.index]):.2f} MB crus, {memoria_mb(tipados):.2f} MB tipados"
)
print(f"tempo: {time.perf_counter() - inicio:.1f} s")
return 0
The tests
The tests are what makes this project reliable. There are three families. One rule, one case written by hand, with the known answer. Properties of the generated set: no line is lost, what remains does not violate any rule, the input is not altered. And the central test, which runs the pipeline in pieces of three different sizes and checks that the result is identical to reading everything, with assert_frame_equal:
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
from saude.gerar import PROBLEMAS_INJETADOS, gerar_atendimentos
from saude.limpeza import memoria_mb, separar
from saude.validacao import contar_problemas, marcar_problemas, normalizar_convenio
def linha(**mudancas):
base = {
"id_atendimento": 1, "id_paciente": 7, "hospital": "Hospital A", "convenio": "SUS",
"especialidade": "Cirurgia", "data_internacao": "2025-01-10", "data_alta": "2025-01-15",
"valor_cobrado": 1000.0, "valor_pago": 900.0, "idade": 40,
} # fmt: skip
return {**base, **mudancas}
def test_a_geracao_e_deterministica_e_tem_cada_problema() -> None:
a, b = gerar_atendimentos(3000), gerar_atendimentos(3000)
assert_frame_equal(a, b)
contagem = contar_problemas(marcar_problemas(a))
assert all(n > 0 for n in contagem.values()), contagem
assert len(PROBLEMAS_INJETADOS) == 7
def test_cada_regra_pega_o_seu_caso() -> None:
df = pd.DataFrame(
[
linha(id_atendimento=1),
linha(id_atendimento=2, data_alta="2025-01-05"),
linha(id_atendimento=3, valor_cobrado=-5.0),
linha(id_atendimento=4, valor_pago=2000.0),
linha(id_atendimento=1),
linha(id_atendimento=6, idade=150),
linha(id_atendimento=7, convenio="Inventado"),
linha(id_atendimento=8, data_internacao="ontem"),
]
)
p = marcar_problemas(df)
assert p["alta_antes_da_internacao"].tolist() == [False, True] + [False] * 6
assert p["valor_negativo"].iloc[2] and p["pago_maior_que_cobrado"].iloc[3]
assert p["id_repetido"].tolist() == [False, False, False, False, True, False, False, False]
assert p["idade_invalida"].iloc[5] and p["convenio_desconhecido"].iloc[6]
assert p["data_invalida"].iloc[7]
assert p.iloc[0].sum() == 0
def test_id_repetido_entre_pedacos_exige_o_estado() -> None:
df = pd.DataFrame([linha(id_atendimento=5)])
assert not marcar_problemas(df)["id_repetido"].iloc[0]
assert marcar_problemas(df, ids_vistos={5})["id_repetido"].iloc[0]
def test_convenio_normaliza_caixa_e_espacos() -> None:
assert normalizar_convenio(pd.Series([" sus ", "alfa saúde ", "xyz"])).tolist()[:2] == [
"SUS",
"Alfa Saúde",
]
assert normalizar_convenio(pd.Series(["xyz"])).isna().all()
@pytest.fixture(scope="module")
def gerado() -> pd.DataFrame:
return gerar_atendimentos(20_000)
def test_nenhuma_linha_se_perde(gerado) -> None:
r = separar(gerado)
assert len(r.limpos) + len(r.quarentena) == len(gerado)
assert set(r.limpos.index).isdisjoint(r.quarentena.index)
assert r.quarentena["motivos"].str.len().gt(0).all()
def test_o_que_sobra_nao_viola_nenhuma_regra(gerado) -> None:
r = separar(gerado)
depois = marcar_problemas(
r.limpos.assign(
data_internacao=r.limpos["data_internacao"].astype(str),
data_alta=r.limpos["data_alta"].astype(str),
convenio=r.limpos["convenio"].astype(str),
)
)
assert not depois.any().any()
def test_separar_nao_altera_a_entrada(gerado) -> None:
copia = gerado.copy()
separar(gerado)
assert_frame_equal(gerado, copia)
def test_tipos_e_colunas_derivadas(gerado) -> None:
limpos = separar(gerado).limpos
assert (
str(limpos["hospital"].dtype) == "category"
and str(limpos["id_atendimento"].dtype) == "int32"
)
assert (limpos["dias_internacao"] >= 0).all()
assert limpos["taxa_glosa"].dropna().between(0, 0.31).all()
def test_tipar_reduz_a_memoria(gerado) -> None:
limpos = separar(gerado).limpos
assert memoria_mb(limpos) < 0.7 * memoria_mb(gerado.loc[limpos.index])
from pathlib import Path
import pandas as pd
import pytest
from pandas.testing import assert_frame_equal
from saude.agregacao import agregar, agregar_em_pedacos
from saude.cli import main
from saude.gerar import gerar_atendimentos
from saude.limpeza import separar
@pytest.fixture(scope="module")
def arquivo(tmp_path_factory) -> Path:
caminho = tmp_path_factory.mktemp("dados") / "atendimentos.csv"
gerar_atendimentos(9000).to_csv(caminho, index=False)
return caminho
@pytest.mark.parametrize("tamanho", [1000, 2500, 20_000])
def test_em_pedacos_e_igual_a_ler_tudo(arquivo: Path, tamanho: int) -> None:
completo = agregar(separar(pd.read_csv(arquivo)).limpos)
em_pedacos, _, _ = agregar_em_pedacos(arquivo, tamanho)
assert_frame_equal(completo, em_pedacos)
def test_a_quarentena_tambem_bate_entre_os_dois_caminhos(arquivo: Path) -> None:
completo = separar(pd.read_csv(arquivo))
resumo, contagem, em_quarentena = agregar_em_pedacos(arquivo, 1500)
assert em_quarentena == len(completo.quarentena)
assert int(resumo["atendimentos"].sum()) == len(completo.limpos)
assert contagem == completo.contagem
def test_agregacao_com_resposta_conhecida() -> None:
base = pd.DataFrame(
{
"id_atendimento": [1, 2, 3],
"hospital": ["Hospital A", "Hospital A", "Hospital B"],
"convenio": ["SUS", "SUS", "SUS"],
"especialidade": ["Cirurgia"] * 3,
"id_paciente": [1, 2, 3],
"data_internacao": ["2025-01-10", "2025-01-20", "2025-01-12"],
"data_alta": ["2025-01-12", "2025-01-25", "2025-01-13"],
"valor_cobrado": [100.0, 200.0, 50.0],
"valor_pago": [100.0, None, 25.0],
"idade": [30, 40, 50],
}
)
resumo = agregar(separar(base).limpos).set_index("hospital")
assert resumo.loc["Hospital A", "atendimentos"] == 2
assert resumo.loc["Hospital A", "permanencia_media"] == pytest.approx((2 + 5) / 2)
assert resumo.loc["Hospital A", "percentual_pago"] == pytest.approx(1.0)
assert resumo.loc["Hospital B", "percentual_pago"] == pytest.approx(0.5)
def test_pipeline_de_ponta_a_ponta(tmp_path: Path, capsys) -> None:
entrada = tmp_path / "a.csv"
assert main(["gerar", "--linhas", "4000", "--saida", str(entrada)]) == 0
destino = tmp_path / "res"
assert main(["processar", str(entrada), "--saida", str(destino), "--tamanho", "1500"]) == 0
saida = capsys.readouterr().out
assert "linhas lidas: 4000" in saida
assert len(pd.read_parquet(destino / "resumo_hospital_mes.parquet")) > 0
assert len(pd.read_csv(destino / "quarentena.csv")) > 0
def test_arquivo_inexistente_devolve_1(tmp_path: Path, capsys) -> None:
assert main(["processar", str(tmp_path / "nada.csv"), "--saida", str(tmp_path / "r")]) == 1
assert "Erro" in capsys.readouterr().out
Run
cd projetos/saude
uv sync
uv run pytest
uv run saude gerar --linhas 200000 --saida saida/atendimentos.csv
uv run saude processar saida/atendimentos.csv --saida saida/resultado
................ [100%]
16 passed
200000 atendimentos gravados em saida/atendimentos.csv
linhas lidas: 200000 | válidas: 186081 | em quarentena: 13919
linhas com cada problema (uma linha pode ter vários):
data_invalida 2000
alta_antes_da_internacao 2000
valor_negativo 2000
pago_maior_que_cobrado 3893
id_repetido 1979
idade_invalida 2000
convenio_desconhecido 2000
resumo: 288 combinações de hospital e mês
memória (936 linhas válidas): 0.12 MB crus, 0.07 MB tipados
tempo: 1.0 s
Reading the result
- The math adds up: 186,081 valid + 13,919 in quarantine = 200,000. No line has disappeared.
- The problems match what was injected. Five of the seven appear exactly 2,000 times (1% of 200 thousand). The other two have explanations, and I measured each one. The
pago_maior_que_cobradogave 3,893: there are 2,000 injected plus 1,893 charged value attendances negative, where any positive payment is "greater than charged". This is the case of a line with more than one problem. Andid_repetidogave 1,979: it is exactly the difference between the 200,000 lines and the 198,021 distinct ids. There are 21 missing for 2,000 because, in some cases, the id drawn for repetition belonged to another line also injected, which stopped using it, and then no duplicate was formed (by chance, about 20 such cases are expected). - 288 combinations are 12 hospitals for 24 months (two years of data).
- Memory dropped about 40% in the sample of one thousand lines. In such a small sample, the gain is modest, and it grows with the number of lines (chapter 29). The number that matters is that of the entire file, and I only measured the sample.
- The 1.0 seconds include reading the file in 4 chunks of 50 thousand lines, validating, typing, and aggregating. It is the order of magnitude of this machine, with synthetic data: it is not a performance promise for another scenario.
What this project is not
The data are synthetic, generated with simple rules. Real hospital data has sensitive information, subject to LGPD, and requires privacy and security decisions that this exercise does not cover. The goal here is the engineering of the pipeline: validate, not lose lines, control memory, and prove that the result in chunks is the same.
Challenges
- Percentiles. Add to the summary the median number of hospitalization days per hospital. Think: can this be solved in chunks? (chapter 32).
- Partitioned Parquet. Write the summary in Parquet and check that the types survive the round trip.
- One more rule. A hospitalization of more than 60 days should go to quarantine. Add the rule, the field in the generator, and the tests.
- Measure. Run the
processarwith--tamanhofor 10 thousand, 50 thousand, and 200 thousand lines, and compare the peak memory and the time (tracemalloc). Is there an ideal size?