nowcasting-sus 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nowcasting_sus/__init__.py +90 -0
- nowcasting_sus/cli.py +187 -0
- nowcasting_sus/data.py +321 -0
- nowcasting_sus/models.py +512 -0
- nowcasting_sus/plot.py +371 -0
- nowcasting_sus/report.py +200 -0
- nowcasting_sus-1.1.0.dist-info/METADATA +228 -0
- nowcasting_sus-1.1.0.dist-info/RECORD +12 -0
- nowcasting_sus-1.1.0.dist-info/WHEEL +5 -0
- nowcasting_sus-1.1.0.dist-info/entry_points.txt +2 -0
- nowcasting_sus-1.1.0.dist-info/licenses/LICENSE +21 -0
- nowcasting_sus-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Nowcasting SUS — correção de atraso de notificação para dados do SINAN.
|
|
2
|
+
|
|
3
|
+
Nowcasting bayesiano que estima o número real de casos em tempo real,
|
|
4
|
+
corrigindo o atraso entre a data de início dos sintomas e a notificação.
|
|
5
|
+
|
|
6
|
+
Módulos disponíveis:
|
|
7
|
+
- data: carga e preparação de dados SINAN
|
|
8
|
+
- models: modelos PyMC (base e com efeito dia da semana)
|
|
9
|
+
- plot: visualização dos resultados
|
|
10
|
+
- report: geração de boletim epidemiológico
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import version as _version
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"load_sinan",
|
|
23
|
+
"prepare_matrix",
|
|
24
|
+
"NowcastingModel",
|
|
25
|
+
"NowcastingModelDOW",
|
|
26
|
+
"plot_nowcasting",
|
|
27
|
+
"plot_panel",
|
|
28
|
+
"plot_dow_effect",
|
|
29
|
+
"generate_report",
|
|
30
|
+
"setup_logging",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = _version("nowcasting-sus")
|
|
36
|
+
except Exception:
|
|
37
|
+
__version__ = "1.0.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def setup_logging(
|
|
41
|
+
level: int | str | None = None,
|
|
42
|
+
fmt: str | None = None,
|
|
43
|
+
) -> logging.Logger:
|
|
44
|
+
"""Configura o logger do pacote ``nowcasting_sus``.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
level : int or str, optional
|
|
49
|
+
Nível de logging. Pode ser ``logging.DEBUG``, ``"DEBUG"``, ``"INFO"``, etc.
|
|
50
|
+
Se ``None``, usa a variável de ambiente ``NOWCASTING_LOG_LEVEL`` ou ``INFO``.
|
|
51
|
+
fmt : str, optional
|
|
52
|
+
Formato da mensagem. Se ``None``, usa o formato padrão.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
logging.Logger
|
|
57
|
+
Logger configurado.
|
|
58
|
+
"""
|
|
59
|
+
logger = logging.getLogger("nowcasting_sus")
|
|
60
|
+
|
|
61
|
+
# Só adiciona handler se ainda não houver
|
|
62
|
+
if logger.handlers:
|
|
63
|
+
return logger
|
|
64
|
+
|
|
65
|
+
if level is None:
|
|
66
|
+
level = os.environ.get("NOWCASTING_LOG_LEVEL", "INFO").upper()
|
|
67
|
+
|
|
68
|
+
if isinstance(level, str):
|
|
69
|
+
level = getattr(logging, level.upper(), logging.INFO)
|
|
70
|
+
|
|
71
|
+
logger.setLevel(level)
|
|
72
|
+
|
|
73
|
+
if fmt is None:
|
|
74
|
+
fmt = "[%(asctime)s] %(levelname)-8s %(name)s: %(message)s"
|
|
75
|
+
|
|
76
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
77
|
+
handler.setLevel(level)
|
|
78
|
+
handler.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%d %H:%M:%S"))
|
|
79
|
+
logger.addHandler(handler)
|
|
80
|
+
|
|
81
|
+
return logger
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Configura logging na importação do pacote
|
|
85
|
+
_logger = setup_logging()
|
|
86
|
+
|
|
87
|
+
from nowcasting_sus.data import load_sinan, prepare_matrix # noqa: E402
|
|
88
|
+
from nowcasting_sus.models import NowcastingModel, NowcastingModelDOW # noqa: E402
|
|
89
|
+
from nowcasting_sus.plot import plot_nowcasting, plot_panel, plot_dow_effect # noqa: E402
|
|
90
|
+
from nowcasting_sus.report import generate_report # noqa: E402
|
nowcasting_sus/cli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""CLI para gerar relatório nowcasting com suporte a gráficos e modelo DOW."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("nowcasting_sus.cli")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
"""Ponto de entrada da CLI: ``nowcasting-report``.
|
|
13
|
+
|
|
14
|
+
Uso::
|
|
15
|
+
|
|
16
|
+
nowcasting-report dados.csv --agravo "Dengue" --grafico grafico.png
|
|
17
|
+
nowcasting-report dados.csv --modelo-dow --chains 6 --tune 2000
|
|
18
|
+
nowcasting-report dados.csv --dpi 300 --tema ggplot --formato svg
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
from nowcasting_sus.data import load_sinan, prepare_matrix
|
|
23
|
+
from nowcasting_sus.models import NowcastingModel, NowcastingModelDOW
|
|
24
|
+
from nowcasting_sus.plot import plot_nowcasting, plot_panel
|
|
25
|
+
from nowcasting_sus.report import generate_report
|
|
26
|
+
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
description="Nowcasting SUS — relatório epidemiológico com nowcasting bayesiano",
|
|
29
|
+
epilog="Exemplo: nowcasting-report dados.csv --agravo Dengue --grafico graf.png",
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Argumento posicional obrigatório
|
|
34
|
+
parser.add_argument("csv", help="Caminho do CSV SINAN (colunas DT_SIN_PRI e DT_NOTIFIC)")
|
|
35
|
+
|
|
36
|
+
# Metadados epidemiológicos
|
|
37
|
+
parser.add_argument("--agravo", default="Chikungunya", help="Nome do agravo (default: Chikungunya)")
|
|
38
|
+
parser.add_argument("--uf", default="Bahia", help="Unidade federativa (default: Bahia)")
|
|
39
|
+
parser.add_argument("--cid", default="A92.0", help="Código CID-10 (default: A92.0)")
|
|
40
|
+
|
|
41
|
+
# Parâmetros de dados
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--max-delay", type=int, default=30, metavar="DIAS",
|
|
44
|
+
help="Atraso máximo em dias (default: 30, range: 1-365)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--min-year", type=int, default=None, metavar="ANO",
|
|
48
|
+
help="Ano mínimo para filtrar registros (ex: 2024)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--date-onset", default="DT_SIN_PRI", metavar="COLUNA",
|
|
52
|
+
help="Nome da coluna com data de início dos sintomas (default: DT_SIN_PRI)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--date-notif", default="DT_NOTIFIC", metavar="COLUNA",
|
|
56
|
+
help="Nome da coluna com data de notificação (default: DT_NOTIFIC)",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Parâmetros do modelo
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--modelo-dow", action="store_true",
|
|
62
|
+
help="Usar modelo com efeito de dia da semana (DOW)",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--chains", type=int, default=4, metavar="N",
|
|
66
|
+
help="Número de cadeias MCMC (default: 4)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--tune", type=int, default=1000, metavar="N",
|
|
70
|
+
help="Iterações de tuning (default: 1000)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--draws", type=int, default=1000, metavar="N",
|
|
74
|
+
help="Iterações amostradas por cadeia (default: 1000)",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--seed", type=int, default=42, metavar="N",
|
|
78
|
+
help="Semente aleatória (default: 42)",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Parâmetros de saída
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--output", "-o", default="./boletim_nowcasting.html",
|
|
84
|
+
metavar="ARQUIVO",
|
|
85
|
+
help="Caminho do relatório HTML (default: ./boletim_nowcasting.html)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--grafico", default=None, metavar="ARQUIVO",
|
|
89
|
+
help="Caminho para salvar gráfico nowcasting (PNG/SVG/PDF)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--painel", default=None, metavar="ARQUIVO",
|
|
93
|
+
help="Caminho para salvar painel diagnóstico (PNG/SVG/PDF)",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--dpi", type=int, default=150, metavar="N",
|
|
97
|
+
help="Resolução dos gráficos em DPI (default: 150)",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--tema", default="default", metavar="NOME",
|
|
101
|
+
help="Tema matplotlib ou interno (default, clean, dark, ggplot, seaborn-v0_8, etc.)",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--formato", default="png", metavar="EXT",
|
|
105
|
+
help="Formato dos gráficos: png, svg, pdf (default: png)",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
args = parser.parse_args()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# 1. Carregar dados
|
|
112
|
+
logger.info("Carregando dados: %s", args.csv)
|
|
113
|
+
df = load_sinan(
|
|
114
|
+
args.csv,
|
|
115
|
+
date_onset=args.date_onset,
|
|
116
|
+
date_notif=args.date_notif,
|
|
117
|
+
max_delay=args.max_delay,
|
|
118
|
+
min_year=args.min_year,
|
|
119
|
+
)
|
|
120
|
+
nmat, dates, obs_t, obs_d, counts = prepare_matrix(
|
|
121
|
+
df, max_delay=args.max_delay,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
T, D = nmat.shape
|
|
125
|
+
|
|
126
|
+
# 2. Ajustar modelo
|
|
127
|
+
if args.modelo_dow:
|
|
128
|
+
import pandas as pd
|
|
129
|
+
dow = pd.to_datetime(dates).dayofweek.values # type: ignore[attr-defined]
|
|
130
|
+
modelo = NowcastingModelDOW()
|
|
131
|
+
logger.info("Ajustando modelo DOW (%d cadeias)...", args.chains)
|
|
132
|
+
idata = modelo.fit(
|
|
133
|
+
obs_t, obs_d, counts, T=T, D=D,
|
|
134
|
+
dow=dow,
|
|
135
|
+
draws=args.draws, tune=args.tune,
|
|
136
|
+
chains=args.chains, random_seed=args.seed,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
modelo = NowcastingModel()
|
|
140
|
+
logger.info("Ajustando modelo base (%d cadeias)...", args.chains)
|
|
141
|
+
idata = modelo.fit(
|
|
142
|
+
obs_t, obs_d, counts, T=T, D=D,
|
|
143
|
+
draws=args.draws, tune=args.tune,
|
|
144
|
+
chains=args.chains, random_seed=args.seed,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
median, low, high = modelo.get_nowcast_ci()
|
|
148
|
+
|
|
149
|
+
# 3. Gerar relatório HTML
|
|
150
|
+
logger.info("Gerando relatório HTML...")
|
|
151
|
+
generate_report(
|
|
152
|
+
nmat, median, low, high, dates,
|
|
153
|
+
agravo=args.agravo, uf=args.uf, cid=args.cid,
|
|
154
|
+
save_to=args.output,
|
|
155
|
+
)
|
|
156
|
+
print(f"[ok] Relatório: {args.output}")
|
|
157
|
+
|
|
158
|
+
# 4. Gráfico nowcasting (opcional)
|
|
159
|
+
if args.grafico:
|
|
160
|
+
fig = plot_nowcasting(
|
|
161
|
+
nmat, median, low, high, dates,
|
|
162
|
+
title=f"Nowcasting — {args.agravo}/{args.uf}",
|
|
163
|
+
save_to=args.grafico,
|
|
164
|
+
dpi=args.dpi, tema=args.tema, formato=args.formato,
|
|
165
|
+
)
|
|
166
|
+
print(f"[ok] Gráfico: {args.grafico}")
|
|
167
|
+
|
|
168
|
+
# 5. Painel diagnóstico (opcional)
|
|
169
|
+
if args.painel:
|
|
170
|
+
delay_p = idata.posterior["delay_p"].mean(dim=["chain", "draw"]).values
|
|
171
|
+
dow_eff = None
|
|
172
|
+
if args.modelo_dow:
|
|
173
|
+
dow_eff = modelo.get_dow_effect()
|
|
174
|
+
plot_panel(
|
|
175
|
+
nmat, median, low, high, delay_p, dates,
|
|
176
|
+
dow_effect=dow_eff,
|
|
177
|
+
save_to=args.painel,
|
|
178
|
+
dpi=args.dpi, tema=args.tema, formato=args.formato,
|
|
179
|
+
)
|
|
180
|
+
print(f"[ok] Painel: {args.painel}")
|
|
181
|
+
|
|
182
|
+
except KeyboardInterrupt:
|
|
183
|
+
logger.warning("Execução interrompida pelo usuário.")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.error("Erro na execução: %s", exc, exc_info=True)
|
|
187
|
+
sys.exit(1)
|
nowcasting_sus/data.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Carga e preparação de dados SINAN para nowcasting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = ["load_sinan", "prepare_matrix"]
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("nowcasting_sus.data")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _validate_types(
|
|
18
|
+
filepath: str,
|
|
19
|
+
date_onset: str,
|
|
20
|
+
date_notif: str,
|
|
21
|
+
max_delay: int,
|
|
22
|
+
min_year: int | None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Valida tipos e ranges dos parâmetros de ``load_sinan``.
|
|
25
|
+
|
|
26
|
+
Raises
|
|
27
|
+
------
|
|
28
|
+
TypeError
|
|
29
|
+
Se algum parâmetro tiver tipo inesperado.
|
|
30
|
+
ValueError
|
|
31
|
+
Se algum parâmetro estiver fora do range válido.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(filepath, str):
|
|
34
|
+
raise TypeError(
|
|
35
|
+
f"filepath deve ser str, recebeu {type(filepath).__name__}: {filepath!r}"
|
|
36
|
+
)
|
|
37
|
+
if not isinstance(date_onset, str):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"date_onset deve ser str, recebeu {type(date_onset).__name__}: {date_onset!r}"
|
|
40
|
+
)
|
|
41
|
+
if not isinstance(date_notif, str):
|
|
42
|
+
raise TypeError(
|
|
43
|
+
f"date_notif deve ser str, recebeu {type(date_notif).__name__}: {date_notif!r}"
|
|
44
|
+
)
|
|
45
|
+
if not isinstance(max_delay, (int, np.integer)):
|
|
46
|
+
raise TypeError(
|
|
47
|
+
f"max_delay deve ser int, recebeu {type(max_delay).__name__}: {max_delay!r}"
|
|
48
|
+
)
|
|
49
|
+
if max_delay < 1 or max_delay > 365:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"max_delay deve estar entre 1 e 365, recebeu {max_delay}"
|
|
52
|
+
)
|
|
53
|
+
if min_year is not None:
|
|
54
|
+
if not isinstance(min_year, (int, np.integer)):
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"min_year deve ser int | None, recebeu {type(min_year).__name__}: {min_year!r}"
|
|
57
|
+
)
|
|
58
|
+
if min_year < 1900 or min_year > 2100:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"min_year deve estar entre 1900 e 2100, recebeu {min_year}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_sinan(
|
|
65
|
+
filepath: str,
|
|
66
|
+
date_onset: str = "DT_SIN_PRI",
|
|
67
|
+
date_notif: str = "DT_NOTIFIC",
|
|
68
|
+
max_delay: int = 30,
|
|
69
|
+
min_year: Optional[int] = None,
|
|
70
|
+
) -> pd.DataFrame:
|
|
71
|
+
"""Carrega e valida dados SINAN para nowcasting.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
filepath : str
|
|
76
|
+
Caminho do CSV com dados do SINAN.
|
|
77
|
+
date_onset : str
|
|
78
|
+
Nome da coluna com data de início dos sintomas (default: ``DT_SIN_PRI``).
|
|
79
|
+
date_notif : str
|
|
80
|
+
Nome da coluna com data de notificação (default: ``DT_NOTIFIC``).
|
|
81
|
+
max_delay : int
|
|
82
|
+
Atraso máximo em dias para filtrar (default: 30). Deve estar entre 1 e 365.
|
|
83
|
+
min_year : int, optional
|
|
84
|
+
Ano mínimo para filtrar (útil para remover registros antigos).
|
|
85
|
+
Deve estar entre 1900 e 2100.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
pd.DataFrame
|
|
90
|
+
DataFrame com colunas ``onset``, ``notif``, ``delay``.
|
|
91
|
+
|
|
92
|
+
Raises
|
|
93
|
+
------
|
|
94
|
+
FileNotFoundError
|
|
95
|
+
Se o arquivo não existir.
|
|
96
|
+
ValueError
|
|
97
|
+
Se colunas obrigatórias estiverem ausentes, ou se nenhum registro
|
|
98
|
+
for válido após filtragem.
|
|
99
|
+
TypeError
|
|
100
|
+
Se tipos dos parâmetros estiverem incorretos.
|
|
101
|
+
"""
|
|
102
|
+
_validate_types(filepath, date_onset, date_notif, max_delay, min_year)
|
|
103
|
+
|
|
104
|
+
logger.info("Carregando dados: %s", filepath)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
df = pd.read_csv(filepath, low_memory=False, encoding="utf-8")
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
raise
|
|
110
|
+
except UnicodeDecodeError:
|
|
111
|
+
logger.warning("UTF-8 falhou, tentando encoding latin1...")
|
|
112
|
+
df = pd.read_csv(filepath, low_memory=False, encoding="latin1")
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Não foi possível ler o arquivo {filepath!r}: {exc}"
|
|
116
|
+
) from exc
|
|
117
|
+
|
|
118
|
+
required = [date_onset, date_notif]
|
|
119
|
+
missing = [c for c in required if c not in df.columns]
|
|
120
|
+
if missing:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Colunas obrigatórias ausentes no arquivo {filepath!r}: {missing}. "
|
|
123
|
+
f"Colunas disponíveis: {list(df.columns[:20])}..."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
df = df[[date_onset, date_notif]].copy()
|
|
127
|
+
df.columns = ["onset", "notif"]
|
|
128
|
+
|
|
129
|
+
# Converter datas — tenta formato automático
|
|
130
|
+
for col in ["onset", "notif"]:
|
|
131
|
+
df[col] = pd.to_datetime(df[col], dayfirst=False, errors="coerce")
|
|
132
|
+
|
|
133
|
+
# Se dayfirst=False falhou, tenta dayfirst=True
|
|
134
|
+
first_col = "onset"
|
|
135
|
+
if df[first_col].isna().sum() > len(df) * 0.5:
|
|
136
|
+
logger.info("dayfirst=False produziu muitos NaT, tentando dayfirst=True...")
|
|
137
|
+
for col in ["onset", "notif"]:
|
|
138
|
+
df[col] = pd.to_datetime(df[col], dayfirst=True, errors="coerce")
|
|
139
|
+
|
|
140
|
+
# Verificar se há datas mínimas plausíveis
|
|
141
|
+
valid_dates = df["onset"].dropna()
|
|
142
|
+
if len(valid_dates) > 0:
|
|
143
|
+
min_date = valid_dates.min()
|
|
144
|
+
if min_date < pd.Timestamp("1900-01-01"):
|
|
145
|
+
logger.warning(
|
|
146
|
+
"Datas muito antigas detectadas (mín: %s). Verifique o formato das datas.",
|
|
147
|
+
min_date,
|
|
148
|
+
)
|
|
149
|
+
if min_date > pd.Timestamp("2000-01-01") and len(valid_dates) < len(df) * 0.1:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Menos de 10%% das datas de onset são posteriores a 2000. "
|
|
152
|
+
"Possível erro de formato ou encoding."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Remover nulos
|
|
156
|
+
before = len(df)
|
|
157
|
+
df = df.dropna(subset=["onset", "notif"])
|
|
158
|
+
n_invalid = before - len(df)
|
|
159
|
+
if n_invalid > 0:
|
|
160
|
+
ratio = n_invalid / before * 100
|
|
161
|
+
logger.warning(
|
|
162
|
+
"%d registros removidos por data inválida (%.1f%% do total)",
|
|
163
|
+
n_invalid, ratio,
|
|
164
|
+
)
|
|
165
|
+
if ratio > 50:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"Mais de 50%% dos registros têm datas inválidas. "
|
|
168
|
+
"Verifique o formato das colunas %r e %r.",
|
|
169
|
+
date_onset, date_notif,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Calcular delay em dias
|
|
173
|
+
df["delay"] = (df["notif"] - df["onset"]).dt.days
|
|
174
|
+
|
|
175
|
+
# Filtrar delays válidos
|
|
176
|
+
n_before_filter = len(df)
|
|
177
|
+
df = df[(df["delay"] >= 0) & (df["delay"] <= max_delay)].copy()
|
|
178
|
+
|
|
179
|
+
n_removed = n_before_filter - len(df)
|
|
180
|
+
if n_removed > 0:
|
|
181
|
+
logger.info(
|
|
182
|
+
"%d registros removidos por delay fora do intervalo [0, %d]",
|
|
183
|
+
n_removed, max_delay,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
df = df.sort_values("onset").reset_index(drop=True)
|
|
187
|
+
|
|
188
|
+
if min_year is not None:
|
|
189
|
+
before_year = len(df)
|
|
190
|
+
df = df[df["onset"].dt.year >= min_year]
|
|
191
|
+
removed = before_year - len(df)
|
|
192
|
+
if removed > 0:
|
|
193
|
+
logger.info(
|
|
194
|
+
"%d registros removidos por ano < %d", removed, min_year
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if len(df) == 0:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
"Nenhum registro válido após filtragem. "
|
|
200
|
+
f"Verifique: max_delay={max_delay}, min_year={min_year}, "
|
|
201
|
+
f"colunas {date_onset!r}/{date_notif!r}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
logger.info(
|
|
205
|
+
"%d registros carregados (%s a %s)",
|
|
206
|
+
len(df),
|
|
207
|
+
df["onset"].min().date(),
|
|
208
|
+
df["onset"].max().date(),
|
|
209
|
+
)
|
|
210
|
+
return df
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def prepare_matrix(
|
|
214
|
+
df: pd.DataFrame,
|
|
215
|
+
max_delay: int = 30,
|
|
216
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
217
|
+
"""Prepara matriz onset × delay para o modelo nowcasting.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
df : pd.DataFrame
|
|
222
|
+
DataFrame com colunas ``onset`` e ``delay`` (saída de :func:`load_sinan`).
|
|
223
|
+
max_delay : int
|
|
224
|
+
Atraso máximo (default: 30). Deve estar entre 1 e 365.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
n_matrix : np.ndarray (T × D)
|
|
229
|
+
Matriz de contagem: ``n[t, d]`` = casos com onset no dia **t** e delay **d**.
|
|
230
|
+
dates : np.ndarray
|
|
231
|
+
Array de datas correspondentes a cada índice **t**.
|
|
232
|
+
obs_t : np.ndarray
|
|
233
|
+
Índices **t** das observações (para o modelo PyMC).
|
|
234
|
+
obs_d : np.ndarray
|
|
235
|
+
Índices **d** das observações.
|
|
236
|
+
counts : np.ndarray
|
|
237
|
+
Contagens observadas.
|
|
238
|
+
|
|
239
|
+
Raises
|
|
240
|
+
------
|
|
241
|
+
TypeError
|
|
242
|
+
Se **df** não for ``pd.DataFrame``.
|
|
243
|
+
ValueError
|
|
244
|
+
Se colunas obrigatórias estiverem ausentes ou dados forem inválidos.
|
|
245
|
+
"""
|
|
246
|
+
if not isinstance(df, pd.DataFrame):
|
|
247
|
+
raise TypeError(
|
|
248
|
+
f"df deve ser pd.DataFrame, recebeu {type(df).__name__}"
|
|
249
|
+
)
|
|
250
|
+
if not isinstance(max_delay, (int, np.integer)):
|
|
251
|
+
raise TypeError(
|
|
252
|
+
f"max_delay deve ser int, recebeu {type(max_delay).__name__}: {max_delay!r}"
|
|
253
|
+
)
|
|
254
|
+
if max_delay < 1 or max_delay > 365:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"max_delay deve estar entre 1 e 365, recebeu {max_delay}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
required_cols = ["onset", "delay"]
|
|
260
|
+
missing = [c for c in required_cols if c not in df.columns]
|
|
261
|
+
if missing:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"Colunas obrigatórias ausentes no DataFrame: {missing}. "
|
|
264
|
+
f"Colunas disponíveis: {list(df.columns)}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if len(df) == 0:
|
|
268
|
+
raise ValueError("DataFrame vazio — não é possível preparar a matriz.")
|
|
269
|
+
|
|
270
|
+
# Criar índice de dias únicos de onset
|
|
271
|
+
date_range = pd.date_range(
|
|
272
|
+
start=df["onset"].min(),
|
|
273
|
+
end=df["onset"].max(),
|
|
274
|
+
freq="D",
|
|
275
|
+
)
|
|
276
|
+
dates = date_range.values
|
|
277
|
+
T = len(dates)
|
|
278
|
+
D = max_delay + 1
|
|
279
|
+
|
|
280
|
+
date_to_idx = {d.date(): i for i, d in enumerate(date_range)}
|
|
281
|
+
|
|
282
|
+
# Montar índice (t, d) para cada caso
|
|
283
|
+
df = df.copy()
|
|
284
|
+
df["t"] = df["onset"].dt.date.map(date_to_idx)
|
|
285
|
+
|
|
286
|
+
# Garantir que todos os onset dates estão no índice
|
|
287
|
+
unlinked = df["t"].isna().sum()
|
|
288
|
+
if unlinked > 0:
|
|
289
|
+
logger.warning(
|
|
290
|
+
"%d registros não puderam ser mapeados para a grade temporal",
|
|
291
|
+
unlinked,
|
|
292
|
+
)
|
|
293
|
+
df = df.dropna(subset=["t"])
|
|
294
|
+
|
|
295
|
+
df["d"] = df["delay"].clip(0, max_delay).astype(int)
|
|
296
|
+
df["t"] = df["t"].astype(int)
|
|
297
|
+
|
|
298
|
+
# Agregar contagens
|
|
299
|
+
agg = df.groupby(["t", "d"], observed=True).size().reset_index(name="count")
|
|
300
|
+
|
|
301
|
+
# Matriz densa T × D
|
|
302
|
+
n_matrix = np.zeros((T, D), dtype=int)
|
|
303
|
+
n_matrix[agg["t"].values, agg["d"].values] = agg["count"].values
|
|
304
|
+
|
|
305
|
+
# Vetores esparsos para o modelo
|
|
306
|
+
obs_t = agg["t"].values.astype(int)
|
|
307
|
+
obs_d = agg["d"].values.astype(int)
|
|
308
|
+
counts = agg["count"].values.astype(int)
|
|
309
|
+
|
|
310
|
+
total = counts.sum()
|
|
311
|
+
logger.info(
|
|
312
|
+
"Matriz %d dias × %d delays (%s casos)",
|
|
313
|
+
T, D, _fmt_num(total),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return n_matrix, dates, obs_t, obs_d, counts
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _fmt_num(n: int) -> str:
|
|
320
|
+
"""Formata número inteiro com separador de milhar (ponto)."""
|
|
321
|
+
return f"{n:,}"
|