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.
@@ -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:,}"