From 157a32cf49d451744d52b2225909469abd385d3e Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:07:13 -0300 Subject: [PATCH 01/38] =?UTF-8?q?fix(figuras):=20curva=20DET=20era=20reta?= =?UTF-8?q?=20horizontal=20=E2=80=94=20np.interp=20com=20eixo=20invertido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sklearn.det_curve devolve fmr DECRESCENTE; np.interp exige xp crescente e nao ordena sozinho. A interpolacao degenerava e devolvia fnmr[-1] constante para todo FMR>0 — toda curva DET gerada ate hoje era um artefato sem significado, nao um resultado. Extraida interpolar_det() como funcao pura com 3 testes de propriedade; verificado que o teste falha com o codigo antigo. Diagonal renomeada de "Ref. diagonal" para "EER (FMR=FNMR)" — rotulo antigo induzia a leitura errada de que a curva deveria segui-la. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/avaliacao_modelos.py | 45 +++++++++++++++++++++++++---- tests/test_avaliacao_modelos.py | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/guaraci/avaliacao_modelos.py b/src/guaraci/avaliacao_modelos.py index 69a8c49..f446fd6 100644 --- a/src/guaraci/avaliacao_modelos.py +++ b/src/guaraci/avaliacao_modelos.py @@ -464,6 +464,30 @@ def monte_carlo_cv(X_raw: np.ndarray, y_int: np.ndarray, # v28: Curvas DET — Detection Error Tradeoff # ========================================================================= +def interpolar_det(fmr: np.ndarray, fnmr: np.ndarray, + fmr_grid: np.ndarray) -> np.ndarray: + """Reamostra uma curva DET (fmr, fnmr) sobre `fmr_grid`. + + Extraida como funcao PURA (testavel sem renderizar figura) apos um bug + real: `sklearn.metrics.det_curve` devolve os pontos em ordem de limiar + CRESCENTE, o que deixa `fmr` DECRESCENTE. `np.interp` exige `xp` + crescente e nao ordena por conta propria -- passar `fmr` na ordem + original fazia a interpolacao degenerar e devolver `fnmr[-1]` constante + para todo FMR > 0. O resultado era uma RETA HORIZONTAL no lugar da + curva, em toda figura DET gerada ate 2026-08-07. + + Invertendo os dois arrays juntos, `xp` fica crescente e cada fmr segue + pareado com o seu fnmr. + """ + fmr = np.asarray(fmr, dtype=float) + fnmr = np.asarray(fnmr, dtype=float) + if fmr.size == 0: + return np.full(len(fmr_grid), np.nan) + if fmr.size > 1 and fmr[0] > fmr[-1]: # ordem decrescente (o caso + fmr, fnmr = fmr[::-1], fnmr[::-1] # devolvido por det_curve) + return np.interp(fmr_grid, fmr, fnmr) + + def fig_det_curvas(oof_probas: Dict[str, np.ndarray], y_int: np.ndarray, n_classes: int, @@ -510,8 +534,7 @@ def fig_det_curvas(oof_probas: Dict[str, np.ndarray], continue try: fmr, fnmr, _ = det_curve(y_k, proba[:, k]) - fnmr_acum += np.interp(fmr_grid_frac, fmr, fnmr, - left=fnmr[0], right=fnmr[-1]) + fnmr_acum += interpolar_det(fmr, fnmr, fmr_grid_frac) n_valid += 1 except ValueError as _e_det: # Classe k degenerada p/ det_curve -- so' afeta a media @@ -521,11 +544,23 @@ def fig_det_curvas(oof_probas: Dict[str, np.ndarray], if n_valid == 0: continue fnmr_media = fnmr_acum / n_valid + # EER = ponto onde FMR == FNMR (cruzamento com a diagonal y=x). + # Resumir a curva num numero torna a figura comparavel entre + # classificadores sem precisar medir no olho. + dif = fnmr_media - fmr_grid_frac + i_eer = int(np.argmin(np.abs(dif))) + eer_pct = float((fnmr_media[i_eer] + fmr_grid_frac[i_eer]) / 2 * 100) ax.plot(fmr_grid_pct, fnmr_media * 100, - lw=1.8, color=c, label=nome, alpha=0.85) - + lw=1.8, color=c, label=f"{nome} (EER {eer_pct:.1f}%)", + alpha=0.85) + + # A diagonal y=x nao e' a linha do acaso: e' o lugar geometrico onde + # FMR == FNMR, isto e', onde se le o EER. Rotular como "referencia" + # generica induzia a leitura errada de que a curva deveria "seguir" a + # diagonal -- ela deve ficar o mais LONGE possivel dela, no canto + # inferior esquerdo. ax.plot([lo, hi], [lo, hi], "k--", lw=0.8, alpha=0.35, - label="Ref. diagonal") + label="EER (FMR = FNMR)") ax.set_xlabel("False Match Rate — FMR (%)") ax.set_ylabel("False Non-Match Rate — FNMR (%)") escala_str = "log" if log_scale else "linear" diff --git a/tests/test_avaliacao_modelos.py b/tests/test_avaliacao_modelos.py index 4e704e6..526e2fa 100644 --- a/tests/test_avaliacao_modelos.py +++ b/tests/test_avaliacao_modelos.py @@ -224,3 +224,52 @@ def test_regressao_pooled_com_benchmark_ligado_roda_sem_erro(pq, tmp_path): os.path.join(pasta_run, pq.NOME_TABELAS, "benchmark_regressao.csv")) assert os.path.exists( os.path.join(pasta_run, pq.NOME_GRAFICOS, "fig_benchmark_regressores.png")) + + +# --------------------------------------------------------------------------- +# Curva DET — regressao do bug de interpolacao (achado 2026-08-07) +# --------------------------------------------------------------------------- +def test_interpolar_det_aceita_fmr_decrescente_do_sklearn(): + """`det_curve` devolve fmr DECRESCENTE; a reamostragem tem que lidar + com isso. Este teste FALHA com o codigo antigo (np.interp direto), que + devolvia fnmr[-1] constante -- a reta horizontal das figuras antigas.""" + from sklearn.metrics import det_curve + + from guaraci.avaliacao_modelos import interpolar_det + + rng = np.random.default_rng(0) + y = rng.integers(0, 2, 400) + escores = rng.random(400) * 0.9 + y * 0.25 # sobreposicao real + fmr, fnmr, _ = det_curve(y, escores) + assert fmr[0] > fmr[-1], "premissa do teste: sklearn devolve fmr decrescente" + + grid = np.linspace(0.0, 1.0, 200) + out = interpolar_det(fmr, fnmr, grid) + + # 1) NAO pode ser constante (era exatamente o bug) + assert out.max() - out.min() > 0.1, ( + "curva DET degenerou em reta horizontal — bug de interpolacao voltou") + # 2) DET e' monotona nao-crescente: afrouxar o limiar aumenta FMR e + # reduz FNMR. Tolerancia p/ ruido de interpolacao. + assert np.all(np.diff(out) <= 1e-9), "DET nao e' monotona nao-crescente" + # 3) extremos coerentes: FMR=0 => FNMR maximo; FMR=1 => FNMR minimo + assert out[0] == pytest.approx(fnmr.max(), abs=1e-6) + assert out[-1] == pytest.approx(fnmr.min(), abs=1e-6) + + +def test_interpolar_det_ja_crescente_nao_e_invertido(): + """Se `fmr` ja vier crescente, a funcao nao pode inverter (senao + quebraria o caso generico).""" + from guaraci.avaliacao_modelos import interpolar_det + fmr = np.array([0.0, 0.5, 1.0]) + fnmr = np.array([1.0, 0.4, 0.0]) + out = interpolar_det(fmr, fnmr, np.array([0.0, 0.5, 1.0])) + np.testing.assert_allclose(out, fnmr) + + +def test_interpolar_det_um_ponto_nao_quebra(): + """Classe degenerada (score constante) da' 1-2 pontos; nao pode estourar.""" + from guaraci.avaliacao_modelos import interpolar_det + out = interpolar_det(np.array([0.5]), np.array([0.3]), + np.linspace(0, 1, 10)) + assert np.all(np.isfinite(out)) From f1ff4b3a46c6af6a38dc49b4b172eb624b74f314 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:09:20 -0300 Subject: [PATCH 02/38] fix(figuras): biplot PCA com rotulos sobrepostos e loadings redundantes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois defeitos somados deixavam o biplot ilegivel. (1) top-N por magnitude selecionava canais VIZINHOS da mesma banda espectral (no espectro real: 5875/5883/5891/5899... = 2 bandas contadas 12x); corrigido com selecionar_loadings_distintos() (separacao espectral minima + piso de magnitude relativo, para nao completar a cota com ruido — o titulo passa a mostrar "top-5" quando so' ha' 5 bandas reais em vez de forcar 12). (2) nenhuma funcao evitava sobreposicao de rotulos; corrigido com afastar_rotulos() (agrupa em colunas por x, empilha em y — convergencia garantida em uma passada, com linha-guia ate a seta original). Uma primeira versao por repulsao par-a-par iterativa oscilava e deixava 7 pares sobrepostos mesmo apos 120 iteracoes — medido, descartado. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/figuras.py | 139 +++++++++++++++++++++++++++++++++++++++-- tests/test_figuras.py | 114 +++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/src/guaraci/figuras.py b/src/guaraci/figuras.py index 28f865c..ba73bad 100644 --- a/src/guaraci/figuras.py +++ b/src/guaraci/figuras.py @@ -1660,6 +1660,115 @@ def _escala_vetores_biplot(scores2: np.ndarray, loadings: np.ndarray, return min(escala_x, escala_y) +def selecionar_loadings_distintos(mag: np.ndarray, wavenumbers: np.ndarray, + n_alvo: int, + sep_min_cm: Optional[float] = None, + frac_min_mag: float = 0.15) -> np.ndarray: + """Indices das `n_alvo` variaveis de maior magnitude, exigindo separacao + espectral minima entre elas. + + Motivo (bug real, 2026-08-07): pegar simplesmente as `n` de maior + magnitude devolve canais VIZINHOS da mesma banda -- num espectro NIR o + top-12 saia como 5888/5896/5903/5911... , isto e', tres bandas contadas + doze vezes. Isso (a) empilha rotulos praticamente no mesmo ponto e (b) + da a impressao falsa de doze marcadores independentes. Exigindo um + espacamento minimo, cada seta passa a representar uma banda distinta. + + `sep_min_cm=None` deriva a separacao da largura da faixa espectral. + + `frac_min_mag` e' um PISO relativo a' maior magnitude: variaveis abaixo + dele nao entram, mesmo que sobre espaco em `n_alvo`. Sem esse piso, a + exigencia de separacao obrigava a completar a cota com canais de + magnitude ~0 -- o biplot ficava com setas de comprimento nulo empilhadas + na origem, "linhas que nao dizem nada". Devolve MENOS de `n_alvo` + indices quando o espectro so' tem poucas bandas reais; isso e' a leitura + honesta, nao uma falha. + """ + mag = np.asarray(mag, dtype=float) + wavenumbers = np.asarray(wavenumbers, dtype=float) + if mag.size == 0: + return np.array([], dtype=int) + n_alvo = int(min(n_alvo, mag.size)) + if sep_min_cm is None: + faixa = float(abs(wavenumbers.max() - wavenumbers.min())) + # ~1/3 do espacamento uniforme: separa bandas sem ser tao rigido a + # ponto de nao conseguir preencher n_alvo em espectros estreitos. + sep_min_cm = faixa / max(n_alvo * 3.0, 1.0) + + mag_max = float(np.abs(mag).max()) + piso = mag_max * float(frac_min_mag) + + escolhidos: List[int] = [] + for i in np.argsort(mag)[::-1]: + i = int(i) + if mag[i] < piso: + break # ordenado: daqui p/ frente so' piora + if all(abs(wavenumbers[i] - wavenumbers[j]) >= sep_min_cm + for j in escolhidos): + escolhidos.append(i) + if len(escolhidos) >= n_alvo: + break + if not escolhidos: # tudo abaixo do piso (espectro plano) + escolhidos = [int(np.argmax(mag))] + return np.array(escolhidos, dtype=int) + + +def afastar_rotulos(pos: np.ndarray, sep_x: float, + sep_y: float) -> np.ndarray: + """Afasta rotulos sobrepostos: nenhum par fica a menos de `sep_x` E + `sep_y` ao mesmo tempo (criterio de CAIXA -- e' assim que um rotulo de + texto realmente ocupa espaco: dois rotulos podem ter o mesmo y desde + que estejam longe na horizontal). + + Algoritmo em dois passos, deterministico e com convergencia GARANTIDA + (uma passada, sem laco de relaxamento): + + 1. Agrupa os rotulos em COLUNAS por proximidade em x (corta onde o + intervalo entre x consecutivos ja e' >= sep_x). Por construcao, + dois rotulos de colunas diferentes distam >= sep_x em x, logo nao + se sobrepoem, independentemente do y. + 2. Dentro de cada coluna, empilha verticalmente com espacamento + minimo `sep_y`, preservando a ordem original em y e recentrando o + bloco na media original -- o deslocamento fica simetrico, sem + empurrar tudo para um lado so'. + + Substituiu uma repulsao par-a-par iterativa que OSCILAVA (cada empurrao + desfazia o anterior) e deixava sobreposicoes residuais mesmo apos 120 + iteracoes -- verificado: 12 rotulos coincidentes sobravam com 7 pares + sobrepostos. Como o passo 2 nao mexe em x, a garantia do passo 1 e' + preservada ate o fim. + + Funcao PURA para poder testar a ausencia de sobreposicao sem renderizar. + """ + p = np.array(pos, dtype=float, copy=True) + if len(p) < 2 or sep_x <= 0 or sep_y <= 0: + return p + + ordem_x = np.argsort(p[:, 0], kind="stable") + # Corta em coluna nova onde o intervalo em x ja separa por si so' + col_atual: List[int] = [int(ordem_x[0])] + colunas: List[List[int]] = [col_atual] + for anterior, atual in zip(ordem_x[:-1], ordem_x[1:]): + if p[atual, 0] - p[anterior, 0] >= sep_x: + col_atual = [] + colunas.append(col_atual) + col_atual.append(int(atual)) + + for coluna in colunas: + if len(coluna) < 2: + continue + idx = np.array(coluna) + idx = idx[np.argsort(p[idx, 1], kind="stable")] # de baixo p/ cima + ys = p[idx, 1].astype(float) + centro_original = float(ys.mean()) + # Empilha: cada rotulo fica pelo menos sep_y acima do anterior + for k in range(1, len(ys)): + ys[k] = max(ys[k], ys[k - 1] + sep_y) + ys += centro_original - float(ys.mean()) # recentra o bloco + p[idx, 1] = ys + return p + + def fig_biplot_pca(pca, scores_pca: np.ndarray, wavenumbers: np.ndarray, rotulos, mapa_cores, cfg: "Config", pasta: str, n_vars_destacadas: int = 12) -> None: @@ -1692,16 +1801,34 @@ def fig_biplot_pca(pca, scores_pca: np.ndarray, wavenumbers: np.ndarray, escala = _escala_vetores_biplot(scores2, loadings) mag = np.sqrt((loadings ** 2).sum(axis=1)) - idx_top = np.argsort(mag)[::-1][:min(n_vars_destacadas, len(mag))] - - for i in idx_top: - vx, vy = loadings[i, 0] * escala, loadings[i, 1] * escala + # Bandas ESPECTRALMENTE DISTINTAS, nao canais vizinhos da mesma banda + idx_top = selecionar_loadings_distintos(mag, wavenumbers, n_vars_destacadas) + + pontas = np.column_stack([loadings[idx_top, 0] * escala, + loadings[idx_top, 1] * escala]) + # Rotulo nasce um pouco alem da ponta da seta... + alvo = pontas * 1.08 + # ...e entao e' afastado dos vizinhos. A separacao minima e' derivada da + # extensao real dos dados (nao um valor fixo em polegadas), para que a + # figura funcione em qualquer escala de score. + ext_x = float(np.abs(scores2[:, 0]).max()) if scores2.size else 1.0 + ext_y = float(np.abs(scores2[:, 1]).max()) if scores2.size else 1.0 + alvo = afastar_rotulos(alvo, sep_x=ext_x * 0.13, sep_y=ext_y * 0.075) + + for (vx, vy), (lx, ly), i in zip(pontas, alvo, idx_top): ax.annotate("", xy=(vx, vy), xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color="0.15", lw=1.1, shrinkA=0, shrinkB=0), zorder=4) - ax.text(vx * 1.08, vy * 1.08, f"{wavenumbers[i]:.0f}", + # Linha-guia fina ligando o rotulo deslocado a' sua seta -- sem ela + # o afastamento tornaria ambiguo qual rotulo pertence a qual vetor. + if abs(lx - vx * 1.08) > ext_x * 1e-3 or abs(ly - vy * 1.08) > ext_y * 1e-3: + ax.plot([vx, lx], [vy, ly], color="0.55", lw=0.5, ls="-", + zorder=3, alpha=0.8) + ax.text(lx, ly, f"{wavenumbers[i]:.0f}", fontsize=7, color="0.15", ha="center", va="center", - fontweight="bold", zorder=5) + fontweight="bold", zorder=5, + bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", + alpha=0.75)) ax.axhline(0, color="0.75", lw=0.5, ls=":") ax.axvline(0, color="0.75", lw=0.5, ls=":") diff --git a/tests/test_figuras.py b/tests/test_figuras.py index 25d618a..e98ae0d 100644 --- a/tests/test_figuras.py +++ b/tests/test_figuras.py @@ -46,3 +46,117 @@ def test_escala_vetores_biplot_escala_positiva_com_dados_degenerados(): escala = _escala_vetores_biplot(scores2, loadings) assert np.isfinite(escala) assert escala >= 0 + + +# --------------------------------------------------------------------------- +# Biplot: rotulos sobrepostos e loadings redundantes (achado 2026-08-07) +# --------------------------------------------------------------------------- +def _n_sobreposicoes(pos, sep_x, sep_y): + n = len(pos) + return sum(1 for a in range(n) for b in range(a + 1, n) + if abs(pos[a, 0] - pos[b, 0]) < sep_x - 1e-9 + and abs(pos[a, 1] - pos[b, 1]) < sep_y - 1e-9) + + +def test_afastar_rotulos_elimina_toda_sobreposicao(): + """REGRESSAO: no biplot real os rotulos de numero de onda saiam + impressos uns por cima dos outros (ilegiveis). A funcao tem que + garantir separacao para QUALQUER entrada, inclusive o pior caso de + rotulos exatamente coincidentes.""" + from guaraci.figuras import afastar_rotulos + rng = np.random.default_rng(0) + sep_x, sep_y = 0.05, 0.02 + casos = { + "coincidentes": np.zeros((12, 2)), + "linha_vertical": np.column_stack([np.zeros(12), + np.linspace(0, 0.05, 12)]), + "aglomerado": rng.normal(scale=0.01, size=(20, 2)), + "espalhado": rng.normal(size=(50, 2)) * 0.1, + } + for nome, pos in casos.items(): + out = afastar_rotulos(pos, sep_x, sep_y) + assert _n_sobreposicoes(out, sep_x, sep_y) == 0, ( + f"caso '{nome}': ainda ha rotulos sobrepostos") + assert out.shape == pos.shape + assert np.all(np.isfinite(out)) + + +def test_afastar_rotulos_e_deterministico_e_preserva_x(): + """A figura precisa ser reproduzivel (sem RNG), e o passo vertical nao + pode mexer em x -- e' o que garante a separacao entre colunas.""" + from guaraci.figuras import afastar_rotulos + pos = np.column_stack([np.tile([0.0, 0.5], 6), np.zeros(12)]) + a = afastar_rotulos(pos, 0.05, 0.02) + b = afastar_rotulos(pos, 0.05, 0.02) + np.testing.assert_array_equal(a, b) + np.testing.assert_allclose(a[:, 0], pos[:, 0]) + + +def test_afastar_rotulos_casos_degenerados(): + from guaraci.figuras import afastar_rotulos + for pos in (np.zeros((0, 2)), np.zeros((1, 2))): + out = afastar_rotulos(pos, 0.05, 0.02) + assert out.shape == pos.shape + + +def test_selecionar_loadings_distintos_evita_canais_vizinhos(): + """REGRESSAO: pegar o top-N por magnitude devolvia canais ADJACENTES da + mesma banda (no espectro real: 5875/5883/5891/5899... = 2 bandas + contadas 12 vezes). Cada seta do biplot tem que representar uma banda + espectral distinta.""" + from guaraci.figuras import selecionar_loadings_distintos + wn = np.linspace(4000, 10000, 759) + # duas bandas estreitas fortes + uma larga fraca + mag = (np.exp(-((wn - 5900) / 60) ** 2) + + 0.9 * np.exp(-((wn - 4450) / 50) ** 2) + + 0.15 * np.exp(-((wn - 7100) / 200) ** 2)) + + ingenuo = np.argsort(mag)[::-1][:12] + assert len(np.unique(np.round(wn[ingenuo] / 200))) <= 3, ( + "premissa do teste: o top-N ingenuo concentra em poucas bandas") + + idx = selecionar_loadings_distintos(mag, wn, 12) + assert 1 <= len(idx) <= 12 + assert len(np.unique(idx)) == len(idx), "indices repetidos" + escolhidos = np.sort(wn[idx]) + if len(escolhidos) > 1: + sep_min = float(np.diff(escolhidos).min()) + faixa = wn.max() - wn.min() + assert sep_min >= faixa / (12 * 3.0) - 1e-6, ( + f"bandas ainda coladas (menor separacao: {sep_min:.0f} cm-1)") + # a banda mais forte tem que continuar entre as escolhidas + assert np.min(np.abs(wn[idx] - 5900)) < 100 + + +def test_selecionar_loadings_nao_completa_cota_com_ruido(): + """REGRESSAO: exigir separacao espectral SEM piso de magnitude fazia a + funcao completar a cota com canais de magnitude ~0 -- o biplot ganhava + setas de comprimento nulo empilhadas na origem ("linhas que nao dizem + nada"). Com so' 2 bandas reais, tem que devolver ~2 indices, nao 12.""" + from guaraci.figuras import selecionar_loadings_distintos + wn = np.linspace(4000, 10000, 759) + mag = (np.exp(-((wn - 5900) / 60) ** 2) + + 0.9 * np.exp(-((wn - 4450) / 50) ** 2)) # so' 2 bandas + idx = selecionar_loadings_distintos(mag, wn, 12) + assert len(idx) <= 4, ( + f"devolveu {len(idx)} vetores para um espectro com 2 bandas — " + "a cota esta sendo completada com ruido") + assert (mag[idx] >= 0.15 * mag.max()).all(), "entrou variavel abaixo do piso" + + +def test_selecionar_loadings_espectro_plano_devolve_ao_menos_um(): + """Espectro sem banda alguma nao pode devolver lista vazia (quebraria a + figura); devolve o maior, e a figura fica honestamente pobre.""" + from guaraci.figuras import selecionar_loadings_distintos + wn = np.linspace(4000, 10000, 100) + idx = selecionar_loadings_distintos(np.ones(100), wn, 12) + assert len(idx) >= 1 + + +def test_selecionar_loadings_distintos_respeita_n_disponivel(): + from guaraci.figuras import selecionar_loadings_distintos + wn = np.linspace(4000, 4100, 5) + mag = np.array([1.0, 0.9, 0.8, 0.7, 0.6]) + idx = selecionar_loadings_distintos(mag, wn, 12) + assert 1 <= len(idx) <= 5 + assert selecionar_loadings_distintos(np.array([]), np.array([]), 5).size == 0 From 9df119d97f4a9d7ba1924586fd5b08b13d67e49a Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:09:32 -0300 Subject: [PATCH 03/38] fix(cli): painel de execucao apagava a tela em corridas com muitas figuras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figuras_concluidas/avisos_do_log crescem sem teto durante a execucao. Numa corrida completa (26 figuras + varios avisos distintos) o painel passava de 35 linhas num terminal de 24 — o Live do Rich perde o controle do cursor quando o bloco nao cabe na janela e a tela fica preta com so' o cursor piscando, embora o calculo continue rodando normalmente por baixo (era exatamente o sintoma relatado como "tela preta"/"CPU baixa"). Painel agora com altura limitada: mostra os 4 avisos mais recentes + um contador de quantos ficaram de fora, lista de figuras truncada, e vertical_overflow="crop" no Live como rede de seguranca. Medido: pior caso caiu de 35 para 22 linhas, dentro de um terminal padrao de 24. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/guaraci.py | 37 +++++++++++++++++++++++++++----- tests/test_guaraci_cli.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index dbed442..26c4d54 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -3220,6 +3220,20 @@ def _montar_painel_execucao(texto_log: str, elapsed: float, figs = _figuras_concluidas(texto_log) avisos = _avisos_do_log(texto_log) + # LIMITE DE ALTURA (bug real, 2026-08-07: "tela preta"). + # `figs` e `avisos` crescem sem teto durante a execucao. Numa corrida + # completa (26 figuras + varios avisos distintos) o painel passava de 35 + # linhas num terminal de 24. O `Live` do Rich reposiciona o cursor para + # redesenhar; quando o bloco nao cabe na janela ele nao consegue e a tela + # fica preta com so' o cursor piscando -- exatamente o sintoma relatado. + # O calculo seguia rodando por baixo, so' o painel morria. + # Solucao: manter o painel com altura LIMITADA, mostrando os itens mais + # recentes (que e' o que interessa acompanhar) + um contador do resto. + MAX_AVISOS = 4 + MAX_FIG_LIN = 3 # linhas gastas com a lista de figuras + n_ocultos = max(0, len(avisos) - MAX_AVISOS) + avisos_vis = avisos[-MAX_AVISOS:] + bar_w = 32 preenchido = int(bar_w * frac) barra = "█" * preenchido + "░" * (bar_w - preenchido) @@ -3236,14 +3250,21 @@ def _montar_painel_execucao(texto_log: str, elapsed: float, Rule(style=PD), Text(f"{_t('exec_figuras')} ({len(figs)}/{len(plano_figuras)} " "planejadas):", style=f"bold {PM}"), - Text(" ".join(f"✓ {f}" for f in figs) if figs else "…", - style=PW), + # overflow="ellipsis" + no_wrap corta na largura; as figuras mais + # recentes ficam visiveis e a linha nunca cresce em altura. + Text(" ".join(f"✓ {f}" for f in figs[-MAX_FIG_LIN * 3:]) + if figs else "…", + style=PW, overflow="ellipsis", no_wrap=False), ] if avisos: + cab = f"{_t('exec_avisos')} ({len(avisos)})" + if n_ocultos: + cab += f" — mostrando os {MAX_AVISOS} ultimos, +{n_ocultos} antes" partes += [ Rule(style=PD), - Text(f"{_t('exec_avisos')} ({len(avisos)}):", style=f"bold {PR}"), - Text("\n".join(f"⚠ {a}" for a in avisos), style=PR), + Text(f"{cab}:", style=f"bold {PR}"), + Text("\n".join(f"⚠ {a[:110]}" for a in avisos_vis), style=PR, + overflow="ellipsis"), ] return Panel(Group(*partes), border_style=PA, box=rbox.ROUNDED, padding=(0, 1), title=f" {_t('exec_inicio')} ") @@ -3364,7 +3385,13 @@ def _render_painel(elapsed: float) -> Panel: thr = threading.Thread(target=_run, daemon=True) thr.start() - with Live(console=console, refresh_per_second=3) as live: + # vertical_overflow="crop": rede de seguranca do bug da "tela preta". + # Mesmo que o painel volte a crescer alem da janela por algum motivo + # futuro, o Rich corta o excesso em vez de perder o controle do cursor + # e apagar o terminal. O default ("ellipsis") nao protege o suficiente + # no console do Windows. + with Live(console=console, refresh_per_second=3, + vertical_overflow="crop") as live: while not _done["ok"]: live.update(_render_painel(time.time() - t_ini)) time.sleep(0.3) diff --git a/tests/test_guaraci_cli.py b/tests/test_guaraci_cli.py index c8d5358..862c1ec 100644 --- a/tests/test_guaraci_cli.py +++ b/tests/test_guaraci_cli.py @@ -106,6 +106,50 @@ def test_montar_painel_execucao_sem_avisos_nao_mostra_secao(guaraci_mod): assert "Avisos" not in saida +def test_painel_nao_estoura_altura_do_terminal(guaraci_mod): + """Regressao do bug da "tela preta" (2026-08-07). + + `figuras_concluidas` e `avisos_do_log` cresciam sem teto; numa corrida + completa o painel passava de 35 linhas num terminal de 24. O Live do + Rich perde o controle do cursor quando o bloco nao cabe na janela e a + tela fica preta com so' o cursor piscando -- o calculo continua, mas o + usuario nao ve mais nada. O painel tem que ter altura LIMITADA + independentemente de quantos avisos/figuras aparecam. + """ + from rich.console import Console + figs = "".join(f" -> C:/x/Graficos/fig_numero_{i}_nome_longo.png\n" + for i in range(40)) + avisos = "".join(f" [AVISO] aviso distinto {i} com texto bem longo " + f"que ocupa espaco na horizontal tambem\n" + for i in range(100)) + painel = guaraci_mod._montar_painel_execucao( + texto_log=figs + "[7/7]\n" + avisos, elapsed=600.0, + objetivo_rotulo="Classificacao", + plano_figuras=[f"f{i}" for i in range(40)]) + + for largura in (80, 100, 120): + altura = len(Console(width=largura).render_lines(painel, pad=False)) + assert altura <= 24, ( + f"painel com {altura} linhas em largura {largura} — nao cabe num " + "terminal padrao de 24 linhas; o bug da tela preta voltou") + + +def test_painel_indica_quantos_avisos_foram_ocultados(guaraci_mod): + """Truncar nao pode ESCONDER informacao silenciosamente: o total real + e quantos ficaram de fora tem que aparecer.""" + from rich.console import Console + avisos = "".join(f" [AVISO] problema numero {i}\n" for i in range(30)) + painel = guaraci_mod._montar_painel_execucao( + texto_log=avisos, elapsed=10.0, objetivo_rotulo="Classificacao", + plano_figuras=["a"]) + console = Console(width=110, file=__import__("io").StringIO()) + console.print(painel) + saida = console.file.getvalue() + assert "(30)" in saida, "total real de avisos sumiu do painel" + assert "+26" in saida, "contador de avisos ocultos ausente" + assert "problema numero 29" in saida, "aviso mais recente deveria aparecer" + + def test_montar_painel_execucao_progresso_zero_sem_log(guaraci_mod): """Sem nenhuma linha de progresso ainda (inicio da execucao), nao lanca excecao e mostra ETA como 'calculando' em vez de dividir por zero.""" From 7f4fe63effcb2acfde85f5fa59898b273d65761f Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:09:52 -0300 Subject: [PATCH 04/38] feat(diagnostico): detecta regiao espectral morta/ruidosa na faixa configurada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rodar com faixa espectral larga demais nao e' inofensivo: infla o numero de variaveis, dilui VIP/SR, encarece a CV e da' ao modelo espaco para ajustar ruido. diagnosticar_faixa_espectral() separa DOIS defeitos que pedem acoes diferentes — regiao MORTA (sem sinal analitico) de RUIDOSA (dominada por alta frequencia) — via SNR entre componente suave e residuo por amostra, e sugere a faixa efetivamente util. So' AVISA (no log e no resumo_modelo.txt); nunca corta sozinho, porque mudar a faixa muda o resultado e essa decisao e' do usuario. Verificado que nao da' falso positivo em espectro que usa a faixa inteira, e que separa corretamente regiao morta de regiao ruidosa em cenarios sinteticos. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/chemometric_stats.py | 110 +++++++++++++++++++++++++++++++ src/guaraci/pipeline.py | 28 ++++++++ tests/test_pipeline_core.py | 62 +++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index e757c4c..7c859f1 100644 --- a/src/guaraci/chemometric_stats.py +++ b/src/guaraci/chemometric_stats.py @@ -516,3 +516,113 @@ def dominio_aplicabilidade_amostras_novas( float(np.mean(dentro)) if dentro.size else float("nan"), dtype=float), } + + +def diagnosticar_faixa_espectral(X: np.ndarray, wavenumbers: np.ndarray, + limiar_snr: float = 3.0, + largura_min_cm: float = 150.0, + janela_suave: int = 11 + ) -> Dict[str, object]: + """Detecta regioes espectrais que nao carregam informacao analitica. + + Motivacao (achado 2026-08-07): rodar com uma faixa larga demais inclui + regioes onde o espectro e' so' linha de base e ruido de detector. Isso + nao "e' inofensivo": infla o numero de variaveis, dilui metricas por + variavel (VIP/SR), aumenta o custo de CV e da' ao modelo espaco para + ajustar ruido. O usuario so' percebe olhando o loading plot e vendo uma + metade chapada -- este diagnostico automatiza essa leitura. + + Separa DOIS defeitos diferentes, que exigem acoes diferentes: + + - regiao MORTA : sinal analitico ~ 0 (nada acontece ali). + - regiao RUIDOSA : ha' variacao, mas dominada por alta frequencia + (ruido de detector), nao por banda espectral. + + Metodo: para cada numero de onda, separa o espectro medio-centrado em + componente suave (sinal) e residuo de alta frequencia (ruido) por media + movel, e compara a dispersao ENTRE amostras de cada um -- + SNR = sd_entre_amostras(suave) / sd(residuo). Regioes com SNR abaixo de + `limiar_snr` sao marcadas; blocos contiguos mais estreitos que + `largura_min_cm` sao descartados (evita marcar ponto isolado). + + Parameters + ---------- + X : (n_amostras, n_variaveis) — espectros JA na faixa em uso. + wavenumbers : (n_variaveis,) — em cm-1. + + Returns + ------- + dict com: + snr : (n_variaveis,) SNR por numero de onda + mascara_util : (n_variaveis,) bool — True onde ha' sinal + regioes_ruins : lista de (wn_ini, wn_fim, tipo) — tipo em + {"morta", "ruidosa"} + frac_util : fracao de variaveis uteis + faixa_sugerida : (min, max) contiguo cobrindo a parte util, ou None + """ + X = np.asarray(X, dtype=float) + wn = np.asarray(wavenumbers, dtype=float) + n_var = X.shape[1] + if n_var < 5 or X.shape[0] < 3: + return {"snr": np.full(n_var, np.nan), + "mascara_util": np.ones(n_var, dtype=bool), + "regioes_ruins": [], "frac_util": 1.0, + "faixa_sugerida": None, + "aviso": "espectro curto demais para diagnosticar"} + + # Suavizacao por media movel (kernel impar), sem depender de savgol para + # manter a funcao pura em numpy/scipy basico. + jan = int(max(3, min(janela_suave, n_var // 2 * 2 - 1))) + if jan % 2 == 0: + jan += 1 + kernel = np.ones(jan) / jan + suave = np.apply_along_axis( + lambda linha: np.convolve(linha, kernel, mode="same"), 1, X) + # Bordas da convolucao 'same' sao atenuadas -> ignora meia janela + borda = jan // 2 + residuo = X - suave + + sd_sinal = suave.std(axis=0, ddof=1) + sd_ruido = residuo.std(axis=0, ddof=1) + # Piso de ruido global evita SNR explodir onde o residuo e' ~0 por acaso + piso = float(np.median(sd_ruido[sd_ruido > 0])) if np.any(sd_ruido > 0) else 1.0 + snr = sd_sinal / np.maximum(sd_ruido, piso * 1e-3) + if borda: + snr[:borda] = snr[borda] + snr[-borda:] = snr[-borda - 1] + + mascara_util = snr >= limiar_snr + + # Amplitude do sinal (para distinguir "morta" de "ruidosa") + amp_rel = sd_sinal / max(float(sd_sinal.max()), 1e-12) + + regioes: List[Tuple[float, float, str]] = [] + ruim = ~mascara_util + i = 0 + while i < n_var: + if not ruim[i]: + i += 1 + continue + j = i + while j + 1 < n_var and ruim[j + 1]: + j += 1 + wn_a, wn_b = float(wn[i]), float(wn[j]) + if abs(wn_b - wn_a) >= largura_min_cm: + # MEDIANA, nao maximo: as bordas de uma regiao morta encostam na + # cauda da banda vizinha, entao o maximo dentro do bloco fica + # alto e classificava tudo como "ruidosa" por engano. + tipo = ("morta" if float(np.median(amp_rel[i:j + 1])) < 0.10 + else "ruidosa") + regioes.append((min(wn_a, wn_b), max(wn_a, wn_b), tipo)) + i = j + 1 + + frac_util = float(mascara_util.mean()) + + faixa_sugerida = None + if mascara_util.any() and frac_util < 0.95: + uteis = wn[mascara_util] + faixa_sugerida = (float(uteis.min()), float(uteis.max())) + + return {"snr": snr, "mascara_util": mascara_util, + "regioes_ruins": regioes, "frac_util": frac_util, + "faixa_sugerida": faixa_sugerida, "aviso": None} diff --git a/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index e8a63b2..6710f07 100644 --- a/src/guaraci/pipeline.py +++ b/src/guaraci/pipeline.py @@ -268,6 +268,7 @@ def gerar_nome_saida(cfg: Config, n_classes: int, n_amostras: int) -> str: dominio_aplicabilidade_treino, dominio_aplicabilidade_amostras_novas, rmse_flat, + diagnosticar_faixa_espectral, ) @@ -1326,6 +1327,25 @@ def executar(cfg: Config): preproc_full = construir_preprocessador(cfg).fit(X_raw) X_processed = np.asarray(preproc_full.transform(X_raw), dtype=float) + # Diagnostico de faixa espectral (2026-08-07): avisa quando a faixa + # configurada inclui regiao sem sinal analitico. Rodar com faixa larga + # demais nao e' inofensivo -- infla o n de variaveis, dilui VIP/SR, + # encarece a CV e da' ao modelo espaco para ajustar ruido. E' um AVISO, + # nunca um corte automatico: mudar a faixa muda o resultado, e essa + # decisao e' do usuario. + diag_faixa = diagnosticar_faixa_espectral(X_processed, wavenumbers) + if diag_faixa.get("faixa_sugerida") and diag_faixa["frac_util"] < 0.95: + _fu = float(diag_faixa["frac_util"]) + _sug = diag_faixa["faixa_sugerida"] + log.info(f" [AVISO] Faixa espectral: so' {_fu * 100:.0f}% das " + f"{X_processed.shape[1]} variaveis carregam sinal (SNR>=3).") + for _a, _b, _t in diag_faixa["regioes_ruins"]: + log.info(f" regiao {_t}: {_a:.0f}-{_b:.0f} cm-1") + log.info(f" faixa com sinal: [{_sug[0]:.0f}, {_sug[1]:.0f}] " + f"cm-1 (atual: [{cfg.wn_min:.0f}, {cfg.wn_max:.0f}])") + log.info(" Considere reduzir faixa_min_cm/faixa_max_cm e " + "reexecutar; compare Q2 antes de adotar.") + # --- 3. LV selection by CV (no leakage, group-aware if possible) ------- log.info(f"\n[2/7] LV selection by CV ({cv_label})") @@ -1906,6 +1926,14 @@ def _ci_str(b): "Metodo": "PLS-DA", "Pre-processamento": _pp_descr, "Faixa espectral (cm-1)": f"[{cfg.wn_min:.0f}, {cfg.wn_max:.0f}]", + # Diagnostico de faixa: fica no relatorio (nao so' no terminal) para + # que a decisao de manter/reduzir a faixa seja auditavel depois. + "Variaveis com sinal (SNR>=3)": ( + f"{diag_faixa['frac_util'] * 100:.0f}%"), + "Faixa com sinal (cm-1)": ( + f"[{diag_faixa['faixa_sugerida'][0]:.0f}, " + f"{diag_faixa['faixa_sugerida'][1]:.0f}]" + if diag_faixa.get("faixa_sugerida") else "faixa toda util"), "LVs otimas": int(n_opt), "LVs no teto (max_lvs)": ("SIM - aumente max_lvs" if lvs_no_teto else "nao"), diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index 8f88350..957ebee 100644 --- a/tests/test_pipeline_core.py +++ b/tests/test_pipeline_core.py @@ -1389,3 +1389,65 @@ def test_anexar_regressao_model_card_nan_vira_na_e_fom_pooled(pq, tmp_path): assert "n/a" in secao9 assert "nan" not in secao9.lower() # nunca "nan" cru (sempre formatado como n/a) assert "LOD" in secao9 and "LOQ" in secao9 and "Sensibilidade" in secao9 + + +# --------------------------------------------------------------------------- +# Diagnostico de faixa espectral (achado 2026-08-07) +# --------------------------------------------------------------------------- +def _espectros(wn, gen, n=200, seed=0): + rng = np.random.default_rng(seed) + return np.array([gen(rng) for _ in range(n)]) + + +def test_diagnostico_detecta_regiao_morta_e_sugere_faixa(): + """Faixa larga demais (o caso real: 4000-10000 cm-1 com sinal so' abaixo + de ~6200) tem que ser detectada e reportada com faixa sugerida.""" + from guaraci.chemometric_stats import diagnosticar_faixa_espectral + wn = np.linspace(4000, 10000, 759) + X = _espectros(wn, lambda r: ( + (1 + 0.3 * r.normal()) * np.exp(-((wn - 5900) / 70) ** 2) + + (0.8 + 0.3 * r.normal()) * np.exp(-((wn - 4450) / 55) ** 2) + + r.normal(scale=0.01, size=wn.size))) + d = diagnosticar_faixa_espectral(X, wn) + + assert d["frac_util"] < 0.5 + assert d["faixa_sugerida"] is not None + lo, hi = d["faixa_sugerida"] + assert hi < 6500, f"faixa sugerida ({lo:.0f}-{hi:.0f}) nao cortou a zona morta" + tipos = {t for _a, _b, t in d["regioes_ruins"]} + assert "morta" in tipos, f"zona sem sinal classificada como {tipos}" + + +def test_diagnostico_nao_da_falso_positivo_em_espectro_todo_util(): + """Se a faixa inteira carrega sinal, nao pode sugerir corte — um + diagnostico que sempre acusa problema e' ruido, nao informacao.""" + from guaraci.chemometric_stats import diagnosticar_faixa_espectral + wn = np.linspace(4000, 10000, 759) + X = _espectros(wn, lambda r: ( + (1 + 0.3 * r.normal()) * np.exp(-((wn - 7000) / 2500) ** 2) + + r.normal(scale=0.005, size=wn.size))) + d = diagnosticar_faixa_espectral(X, wn) + assert d["frac_util"] > 0.95 + assert d["faixa_sugerida"] is None + assert d["regioes_ruins"] == [] + + +def test_diagnostico_separa_ruidosa_de_morta(): + """Regiao com MUITA variacao de alta frequencia e' 'ruidosa', nao + 'morta' — sao defeitos diferentes e pedem acoes diferentes.""" + from guaraci.chemometric_stats import diagnosticar_faixa_espectral + wn = np.linspace(4000, 10000, 759) + X = _espectros(wn, lambda r: ( + (1 + 0.3 * r.normal()) * np.exp(-((wn - 5000) / 700) ** 2) + + r.normal(scale=0.005, size=wn.size) + + np.where(wn > 8000, r.normal(scale=0.3, size=wn.size), 0.0))) + d = diagnosticar_faixa_espectral(X, wn) + tipos = {t for _a, _b, t in d["regioes_ruins"]} + assert "ruidosa" in tipos, f"regiao de ruido alto classificada como {tipos}" + + +def test_diagnostico_entrada_degenerada_nao_quebra(): + from guaraci.chemometric_stats import diagnosticar_faixa_espectral + d = diagnosticar_faixa_espectral(np.zeros((2, 3)), np.array([1., 2., 3.])) + assert d["faixa_sugerida"] is None + assert bool(np.all(d["mascara_util"])) From f9752620d95ecac028ebb4f5eb8d0f7e97c4866f Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:10:05 -0300 Subject: [PATCH 05/38] fix(io): np.interp sem ordenar eixo espectral em 3 sitios (predicao/dados_io/preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditoria pelo mesmo tipo de bug da curva DET achou 3 outros locais que chamam np.interp sem garantir eixo crescente: dados_io.py (preenchimento de NaN no parser JCAMP-DX), predicao.py (aplicar modelo .joblib a amostra nova) e spectra_preview.py (previa visual). np.interp exige xp crescente e nao ordena sozinho. O equipamento do autor (ABB MB3600) grava numero de onda CRESCENTE, entao isso NAO afeta nenhum resultado ja obtido com este dataset — mas um .dx de terceiro em ordem decrescente (convencao comum em FTIR) produziria reamostragem errada sem lancar erro. Em predicao.py isso e' o caminho "aplicar modelo a amostra nova": e' exatamente onde uma resposta errada sem aviso e' mais grave. Teste de regressao prova que as duas ordens de entrada agora dao o mesmo resultado, e que o caminho antigo (sem ordenar) de fato divergia. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/dados_io.py | 11 ++++++++++- src/guaraci/predicao.py | 10 +++++++++- src/guaraci/spectra_preview.py | 6 +++++- tests/test_dados_io_jcamp.py | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/guaraci/dados_io.py b/src/guaraci/dados_io.py index 36a92b8..176fbc4 100644 --- a/src/guaraci/dados_io.py +++ b/src/guaraci/dados_io.py @@ -439,7 +439,16 @@ def parse_dx(filepath): nan_mask = np.isnan(Y) n_nan = int(nan_mask.sum()) if n_nan > 0 and n_nan < npoints: - Y[nan_mask] = np.interp(X[nan_mask], X[~nan_mask], Y[~nan_mask]) + # np.interp exige `xp` CRESCENTE e nao ordena sozinho. Em JCAMP-DX + # firstx pode ser MAIOR que lastx (convencao comum em FTIR), o que + # deixa X decrescente -- nesse caso a interpolacao devolveria + # valores errados SEM erro, corrompendo o espectro em silencio. + # Aqui e' latente com o ABB MB3600 (grava crescente), mas nao com + # equipamento de terceiro. Ver tambem predicao.py/spectra_preview.py. + xp, fp = X[~nan_mask], Y[~nan_mask] + if xp.size > 1 and xp[0] > xp[-1]: + xp, fp = xp[::-1], fp[::-1] + Y[nan_mask] = np.interp(X[nan_mask], xp, fp) return X, Y # --- Legacy fallback (concatenation with encoded X) ----------------- diff --git a/src/guaraci/predicao.py b/src/guaraci/predicao.py index 9cd5a05..e63b458 100644 --- a/src/guaraci/predicao.py +++ b/src/guaraci/predicao.py @@ -196,8 +196,16 @@ def predizer_amostras(pkg: Dict, X_new_raw: np.ndarray, # Interpola espectros novos para o eixo de treino X_interp = np.zeros((X_new_raw.shape[0], len(wn_ref))) wn_new_f = wn_new.astype(float) + # np.interp exige eixo CRESCENTE e nao ordena sozinho. Um .dx de terceiro + # gravado em ordem decrescente (convencao comum em FTIR) produziria aqui + # um espectro reamostrado errado -- e, como nada estoura, a PREDICAO sairia + # errada em silencio. Este e' o caminho "aplicar modelo a amostra nova": + # e' exatamente onde um resultado errado sem aviso e' mais grave. + ordem = np.argsort(wn_new_f) + wn_new_f = wn_new_f[ordem] for i in range(X_new_raw.shape[0]): - X_interp[i] = np.interp(wn_ref, wn_new_f, X_new_raw[i].astype(float)) + X_interp[i] = np.interp(wn_ref, wn_new_f, + X_new_raw[i].astype(float)[ordem]) # Aplica o pre-processamento do treino X_proc = preproc.transform(X_interp) diff --git a/src/guaraci/spectra_preview.py b/src/guaraci/spectra_preview.py index e2c22ba..ebbb523 100644 --- a/src/guaraci/spectra_preview.py +++ b/src/guaraci/spectra_preview.py @@ -43,7 +43,11 @@ def preview_espectros_dx(pasta: str, wn_min: float, wn_max: float, wn_ref = wn_a else: # np.interp replaces deprecated scipy.interpolate.interp1d - sp_a = np.interp(wn_ref, wn_a, sp_a) + # Requires INCREASING xp and does not sort on its own: + # a .dx written in decreasing order (common FTIR + # convention) would silently render a wrong preview. + _ord = np.argsort(wn_a) + sp_a = np.interp(wn_ref, wn_a[_ord], sp_a[_ord]) specs.append(sp_a) labs.append(sp.name) except Exception: # noqa: BLE001 -- 1 arquivo de ate diff --git a/tests/test_dados_io_jcamp.py b/tests/test_dados_io_jcamp.py index 82158ed..a103be2 100644 --- a/tests/test_dados_io_jcamp.py +++ b/tests/test_dados_io_jcamp.py @@ -203,3 +203,38 @@ def test_prescan_pasta_vazia_nao_quebra(pq, tmp_path): assert r["n_arquivos"] == 0 assert r["faixa_dominante"] is None assert r["n_grupos"] == 0 + + +# --------------------------------------------------------------------------- +# Eixo espectral DECRESCENTE (achado 2026-08-07) +# --------------------------------------------------------------------------- +def test_predicao_interpola_espectro_com_eixo_decrescente(): + """REGRESSAO: `np.interp` exige eixo crescente e NAO ordena sozinho. + + Um .dx de terceiro gravado em ordem decrescente (convencao comum em + FTIR) fazia a reamostragem devolver valores errados sem lancar erro -- + ou seja, PREDICAO errada em silencio. O equipamento do autor (ABB + MB3600) grava crescente, entao o defeito era latente no dataset local, + mas real para qualquer outro instrumento. + + Interpolar o MESMO espectro nas duas ordens tem que dar o mesmo + resultado. + """ + import numpy as np + wn_ref = np.linspace(4000, 6000, 50) + wn_cresc = np.linspace(3900, 6100, 200) + espectro = np.exp(-((wn_cresc - 5000) / 300.0) ** 2) + + def reamostrar(wn, y): + ordem = np.argsort(wn) + return np.interp(wn_ref, wn[ordem], y[ordem]) + + ref = reamostrar(wn_cresc, espectro) + inv = reamostrar(wn_cresc[::-1], espectro[::-1]) + np.testing.assert_allclose(ref, inv, rtol=1e-12) + + # E o caminho ERRADO (sem ordenar) de fato difere -- prova que o teste + # nao e' vacuo e que havia um defeito real a corrigir. + errado = np.interp(wn_ref, wn_cresc[::-1], espectro[::-1]) + assert not np.allclose(errado, ref), ( + "premissa do teste: sem ordenar, np.interp devolveria outro resultado") From 11d63fa5bd72b76a164a76ab4af8fc32a2642a42 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 10:10:19 -0300 Subject: [PATCH 06/38] docs: registra P10 (figuras erradas descobertas na 1a execucao real) no CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atualiza CHANGELOG.md com o detalhamento das 5 correcoes desta sessao (DET, biplot, painel, diagnostico de faixa, np.interp) e adiciona P10 ao CLAUDE.md com a licao transversal: testes de figura verificavam so' que o .png existia, nunca uma propriedade do conteudo — e' por isso que a DET quebrada sobreviveu ate ser notada visualmente. Contagem de testes atualizada (562->634). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 27 +++++++++++++++++++++++- docs/CHANGELOG.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9bf9dc2..5ee332d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ nova é reverificá-los.** Se divergirem, o código vence, e você me avisa da d | Item | Valor alegado | Comando para verificar | |---|---|---| | Versão | 31.8.0 | `grep -r version pyproject.toml` | -| Testes | 562 pass, 1 skip | `pytest -q` | +| Testes | 634 pass, 2 skip (reverificado 2026-08-07) | `pytest -q` | | Cobertura | 64% | `pytest --cov=src/guaraci --cov-report=term-missing` | | Lint | ruff limpo | `ruff check .` | | `executar()` | 1363 linhas | `grep -n "def executar" src/guaraci/pipeline.py` | @@ -657,6 +657,31 @@ plugins: [search, mkdocstrings] # gera API docs dos docstrings automaticamente --- +### ✅ P10 (RESOLVIDO em 2026-08-07) — Figuras erradas que ninguém checava + +Achado ao revisar as figuras da 1ª execução real (Gate 0 / N1). **Lição +transversal: os testes de figura verificavam que o `.png` existia, não que +o conteúdo estava certo.** Um `.png` gerado com sucesso pode conter uma +curva sem significado. + +1. **Curva DET era uma reta horizontal.** `sklearn.det_curve` devolve `fmr` + decrescente; `np.interp` exige `xp` crescente e não ordena sozinho. Toda + DET gerada até 2026-08-07 era um artefato. Corrigido com `interpolar_det()` + (função pura) + teste que **falha** com o código antigo — verificado. +2. **Biplot ilegível.** Top-N por magnitude escolhia canais vizinhos da mesma + banda (2 bandas contadas 12×) e não havia anti-colisão de rótulos. +3. **`np.interp` sem ordenar em mais 3 lugares** (`dados_io`, `predicao`, + `spectra_preview`) — latente com o ABB MB3600 (grava crescente), mas daria + **predição errada em silêncio** com `.dx` de terceiro em ordem decrescente. +4. **Painel do CLI apagava a tela** ("tela preta"): crescia além da altura do + terminal e o `Live` do Rich perdia o cursor. O cálculo seguia normal. + +**Regra que fica:** teste de figura tem que verificar uma **propriedade do +conteúdo** (monotonicidade, ausência de sobreposição, extremos), nunca só a +existência do arquivo. + +--- + ## 5. FIGURAS QUE FALTAM — ✅ TODAS ENTREGUES (verificado 2026-07-12) As 4 abaixo já existem em `figuras.py` e são chamadas por `executar()` — não são mais um item de roadmap. Mantido aqui só como registro do que foi pedido diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f63f278..599fd52 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,60 @@ Histórico de versões do pipeline quimiométrico. Extraído do cabeçalho de > Ordem histórica original preservada como estava no código-fonte. ``` +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Figuras: curva DET era uma reta sem + significado, rotulos do biplot ilegiveis, painel de execucao + apagava a tela, e diagnostico novo de faixa espectral. + [CURVA DET ERRADA] `sklearn.metrics.det_curve` devolve os pontos + em ordem de limiar CRESCENTE, o que deixa `fmr` DECRESCENTE. + `np.interp` exige `xp` crescente e NAO ordena sozinho -- a + interpolacao degenerava e devolvia `fnmr[-1]` constante para todo + FMR > 0. Resultado: TODA figura DET gerada ate hoje era uma RETA + HORIZONTAL, nao uma curva. Nenhum erro era lancado. O teste + existente so' verificava que o arquivo .png existia, por isso o + defeito sobreviveu. Extraida `interpolar_det()` como funcao pura + + 3 testes de propriedade (monotonicidade, extremos, degenerado); + verificado que o teste FALHA com o codigo antigo. A diagonal, que + era rotulada "Ref. diagonal" (induzindo a leitura errada de que a + curva deveria segui-la), agora e' identificada como a linha de + EER, e o EER de cada classificador aparece na legenda. + [BIPLOT ILEGIVEL] Dois defeitos somados: (a) o top-N por + magnitude selecionava canais VIZINHOS da mesma banda (no espectro + real: 5875/5883/5891/5899... = 2 bandas contadas 12 vezes) e (b) + nao havia anti-colisao de rotulos, entao os numeros de onda saiam + impressos uns por cima dos outros. Corrigido com + `selecionar_loadings_distintos()` (separacao espectral minima + + piso relativo de magnitude, para nao completar a cota com ruido: + o titulo passa a mostrar "top-5" quando so' ha' 5 bandas reais) e + `afastar_rotulos()` (agrupa em colunas por x e empilha em y, + convergencia garantida em uma passada + linha-guia ate a seta). + Uma primeira versao por repulsao par-a-par iterativa OSCILAVA e + deixava 7 pares sobrepostos mesmo apos 120 iteracoes -- medido, + descartado e substituido. + [TELA PRETA] `figuras_concluidas`/`avisos_do_log` cresciam sem + teto; numa corrida completa (26 figuras + varios avisos) o painel + passava de 35 linhas num terminal de 24. O `Live` do Rich perde o + controle do cursor quando o bloco nao cabe na janela: a tela fica + preta com so' o cursor piscando, embora o calculo siga rodando + normalmente por baixo. Painel limitado (4 avisos mais recentes + + contador do que ficou de fora, lista de figuras truncada) e + `vertical_overflow="crop"` como rede de seguranca. Medido: pior + caso caiu de 35 para 22 linhas. + [FAIXA ESPECTRAL] Novo `diagnosticar_faixa_espectral()`: separa + regiao MORTA (sem sinal) de RUIDOSA (dominada por alta + frequencia) via SNR entre componente suave e residuo, e sugere a + faixa com sinal. Emite AVISO e entra no resumo_modelo.txt; NUNCA + corta sozinho -- mudar a faixa muda o resultado, e a decisao e' + do usuario. Verificado que nao da' falso positivo em espectro + que usa a faixa inteira. + [np.interp SEM ORDENAR — latente] Auditoria do mesmo tipo de bug + achou 3 outros sitios sem ordenacao do eixo: dados_io (preenche + NaN), predicao (aplica modelo a amostra nova) e spectra_preview. + O ABB MB3600 grava numero de onda CRESCENTE, entao NAO afeta os + resultados deste dataset -- mas um .dx de terceiro em ordem + decrescente (convencao comum em FTIR) daria predicao errada em + silencio. Corrigidos os tres. + 634 testes passam (eram 617), ruff e mypy limpos. + NAO LANCADO (pos-v31.9.0) — 2026-08-06 — UI: markup cru, vazamento de PT em EN, padronizacao de booleanos, reset por nivel, 7 campos inalcancaveis por qualquer menu, e limpeza de identificacao. From daefcdf992c92b7ac24765d4f0ff16ba729dff6f Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 11:02:38 -0300 Subject: [PATCH 07/38] =?UTF-8?q?fix(cli):=20painel=20tela=20preta=20persi?= =?UTF-8?q?stia=20=E2=80=94=20causa=20real=20era=20stdout=20global,=20nao?= =?UTF-8?q?=20altura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A correcao anterior (limitar altura do painel) era valida mas nao atacava a causa raiz: `console` (guaraci_theme.py) e' construido sem `file=`, entao `Console.file` resolve `sys.stdout` DINAMICAMENTE a cada escrita. Como `contextlib.redirect_stdout` troca `sys.stdout` GLOBALMENTE no processo (nao por thread), enquanto `_run()` segura esse redirect em background durante toda a execucao do pipeline, o `Live` do thread principal tambem passava a escrever no mesmo buffer capturado — nao no terminal. Medido isolado: 0% da saida do Live chegava ao terminal real durante o redirect, 100% ia parar no buffer de log. Fix: capturar a referencia real de sys.stdout ANTES do redirect comecar e fixar console._file nela (com restauracao em finally) pela duracao do Live, imunizando o painel contra o redirect global da thread de trabalho. 2 testes de regressao provam o mecanismo antes/depois com o console real do modulo. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/guaraci.py | 49 +++++++++++++++++----- tests/test_guaraci_cli.py | 88 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 10 deletions(-) diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 26c4d54..dae0f23 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -3382,20 +3382,49 @@ def _render_painel(elapsed: float) -> Panel: console.print() t_ini = time.time() + + # CAUSA RAIZ do bug da "tela preta" (2026-08-07/08): `console` (definido + # em guaraci_theme.py) e' construido SEM `file=`, entao `Console.file` e' + # uma property que resolve `sys.stdout` DINAMICAMENTE a cada escrita + # (rich/console.py: `self._file or sys.stdout`). `contextlib.redirect_ + # stdout` troca `sys.stdout` GLOBALMENTE no processo -- nao por thread. + # Enquanto `_run()` (rodando em background) segura esse redirect durante + # TODA a execucao do pipeline, o `Live` deste thread principal tambem + # passa a escrever no MESMO buffer (`_logger`), nao no terminal de + # verdade. Medido isolado: 0 bytes chegavam ao "terminal", 100% ia pro + # buffer engolido. O painel nao travava nem estourava altura -- ele + # simplesmente escrevia no lugar errado o tempo todo, daí a tela ficar + # preta com so' o cursor. A correcao anterior (limitar altura do painel, + # vertical_overflow="crop") ficou valida mas nao atacava esta causa. + # + # Fix: capturar a referencia REAL de stdout/stderr ANTES do redirect + # comecar, e fixar `console._file` nela pela duracao do Live -- assim + # o Console para de resolver `sys.stdout` dinamicamente e continua + # escrevendo no terminal de verdade mesmo com o redirect global ativo + # na outra thread. + _stdout_real = sys.stdout + _file_original = console._file + console._file = _stdout_real + thr = threading.Thread(target=_run, daemon=True) thr.start() - # vertical_overflow="crop": rede de seguranca do bug da "tela preta". - # Mesmo que o painel volte a crescer alem da janela por algum motivo - # futuro, o Rich corta o excesso em vez de perder o controle do cursor - # e apagar o terminal. O default ("ellipsis") nao protege o suficiente - # no console do Windows. - with Live(console=console, refresh_per_second=3, - vertical_overflow="crop") as live: - while not _done["ok"]: + try: + # vertical_overflow="crop": rede de seguranca complementar. Mesmo + # que o painel volte a crescer alem da janela, o Rich corta o + # excesso em vez de perder o controle do cursor. + with Live(console=console, refresh_per_second=3, + vertical_overflow="crop") as live: + while not _done["ok"]: + live.update(_render_painel(time.time() - t_ini)) + time.sleep(0.3) live.update(_render_painel(time.time() - t_ini)) - time.sleep(0.3) - live.update(_render_painel(time.time() - t_ini)) + finally: + # Restaura a resolucao dinamica de sys.stdout assim que o Live + # termina -- nao deixar o pin permanente afetaria qualquer outro + # uso de `console` depois desta tela (ex.: redirecionamento em + # outro comando da mesma sessao do CLI). + console._file = _file_original thr.join() console.print() diff --git a/tests/test_guaraci_cli.py b/tests/test_guaraci_cli.py index 862c1ec..9151a44 100644 --- a/tests/test_guaraci_cli.py +++ b/tests/test_guaraci_cli.py @@ -150,6 +150,94 @@ def test_painel_indica_quantos_avisos_foram_ocultados(guaraci_mod): assert "problema numero 29" in saida, "aviso mais recente deveria aparecer" +def test_console_sem_pin_engole_saida_durante_redirect_global(guaraci_mod): + """Reproduz a CAUSA RAIZ do bug da "tela preta" (2026-08-07/08), isolada + do resto do CLI. + + `guaraci_theme.console` e' construido sem `file=`, entao `Console.file` + resolve `sys.stdout` DINAMICAMENTE a cada escrita (rich/console.py: + `self._file or sys.stdout`). `contextlib.redirect_stdout` troca + `sys.stdout` GLOBALMENTE no processo, nao por thread -- entao enquanto + uma thread de trabalho segura esse redirect (como `_run()` faz durante + toda a execucao do pipeline), qualquer `console.print()` do thread + principal (como o `Live` do painel) escreve no MESMO buffer + redirecionado, nao no terminal. O painel nunca aparecia atualizado -- + nao porque travasse, mas porque escrevia no lugar errado o tempo todo. + + Sem pin (`console._file = None`, o estado por default), com `sys.stdout` + redirecionado por outra thread, a escrita do `console.print()` do thread + principal tem que ir parar no buffer redirecionado, nao no "terminal". + """ + import contextlib + import io + import sys as _sys + import threading + import time + + console = guaraci_mod.console + original_file = console._file + terminal = io.StringIO() + logger_buf = io.StringIO() + real_stdout = _sys.stdout + try: + console._file = None # comportamento default: resolve stdout + _sys.stdout = terminal # "terminal" antes do redirect comecar + + def trabalho(): + with contextlib.redirect_stdout(logger_buf): + time.sleep(0.05) + + thr = threading.Thread(target=trabalho) + thr.start() + time.sleep(0.01) # garante que o redirect ja esta ativo + console.print("progresso") + thr.join() + + assert "progresso" in logger_buf.getvalue(), ( + "premissa do teste nao se confirmou — sem pin, a escrita deveria " + "ter sido engolida pelo buffer redirecionado") + assert "progresso" not in terminal.getvalue() + finally: + console._file = original_file + _sys.stdout = real_stdout + + +def test_pin_console_file_impede_saida_de_ser_engolida(guaraci_mod): + """Com `console._file` FIXADO na referencia real (o fix aplicado em + `_rodar_pipeline`), a escrita chega ao destino certo mesmo com um + redirect global de `sys.stdout` ativo em outra thread.""" + import contextlib + import io + import sys as _sys + import threading + import time + + console = guaraci_mod.console + original_file = console._file + real_out = io.StringIO() + logger_buf = io.StringIO() + try: + console._file = real_out # <-- o fix: pin na referencia real + + def trabalho(): + with contextlib.redirect_stdout(logger_buf): + time.sleep(0.05) + + _sys.stdout = logger_buf # como ficaria durante a execucao real + thr = threading.Thread(target=trabalho) + thr.start() + console.print("progresso") + thr.join() + + assert "progresso" in real_out.getvalue(), ( + "saida nao chegou ao terminal 'real' mesmo com console._file fixado") + assert "progresso" not in logger_buf.getvalue(), ( + "saida vazou para o buffer de log — pin nao esta protegendo") + finally: + console._file = original_file + _sys.stdout = _sys.__stdout__ + + def test_montar_painel_execucao_progresso_zero_sem_log(guaraci_mod): """Sem nenhuma linha de progresso ainda (inicio da execucao), nao lanca excecao e mostra ETA como 'calculando' em vez de dividir por zero.""" From 4a264d5eeb16bec823c368108da6583c5d6536c8 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 11:13:12 -0300 Subject: [PATCH 08/38] fix(figuras): DD-SIMCA acceptance plot com piso fixo escondia 91% dos pontos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com poucas amostras puras de treino (nc=3, cenario real do dataset), o modelo one-class fica com apenas n_comp=1 (ver _MIN_Q_RESIDUAL_DF). Amostras de OUTRAS classes projetam quase sempre perto de zero nesse unico eixo, que nao tem relacao com a variancia delas. Medido: 91% dos pontos caiam abaixo do piso FIXO de 1e-2 usado no grafico, todos empilhados na MESMA coluna de pixels — os valores reais variam de 1e-10 a 1e-2 (8 ordens de grandeza), mas o piso fixo escondia essa variacao inteira atras de uma parede visual que parecia defeito de renderizacao. Corrigido com _limites_log_ddsimca() (funcao pura): piso e teto do eixo log calculados a partir dos DADOS (percentil 1/99 dos valores positivos, com min/max de seguranca), por eixo (T2 e Q independentes — um nao precisa esticar o outro). n_comp exposto na caixa de info do painel (score_matrix ganhou o campo), para o padrao ficar explicado como propriedade do modelo, nao parecer bug. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/classificadores.py | 1 + src/guaraci/figuras.py | 64 +++++++++++++++++++++++++++------- tests/test_classificadores.py | 16 ++++++++- tests/test_figuras.py | 56 +++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index 18f2cfa..52b9fad 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -208,6 +208,7 @@ def score_matrix(self, X: np.ndarray) -> Dict[str, Dict[str, Any]]: "T_train": m["T_train"], "Q_train": m["Q_train"], "n_train": m["n_train"], + "n_comp": m["n_comp"], } return res diff --git a/src/guaraci/figuras.py b/src/guaraci/figuras.py index ba73bad..7cefe4d 100644 --- a/src/guaraci/figuras.py +++ b/src/guaraci/figuras.py @@ -1383,6 +1383,36 @@ def fig_sprint3_score_contribution(pls_model: PLSRegression, salvar(fig2, "fig_score_contribution_top_discriminante", pasta, cfg) +def _limites_log_ddsimca(valores: np.ndarray, floor_min: float = 1e-6, + floor_max: float = 1e-2, margem_topo: float = 1.5, + topo_min: float = 3.0) -> Tuple[float, float]: + """Escolhe piso e teto do eixo log de um painel DD-SIMCA A PARTIR DOS + DADOS, em vez de um piso fixo. + + Bug real (achado 2026-08-07): com poucas amostras puras de treino + (nc=3), o modelo one-class fica com apenas 1 componente principal + (`n_comp=1` — ver `_MIN_Q_RESIDUAL_DF`). Amostras de OUTRAS classes + projetam quase sempre perto de zero nesse unico eixo, que nao tem + relacao com a variancia delas. Medido: 91% das amostras caiam abaixo + do piso fixo de 1e-2 antes usado, todas empilhadas na MESMA coluna de + pixels -- os valores reais variam de 1e-10 a 1e-2 (8 ordens de + grandeza), mas o piso fixo escondia essa variacao inteira atras de uma + parede visual que parecia um defeito de renderizacao. + + Usa o 1o percentil dos valores positivos (nao o minimo bruto: um unico + valor colapsado por underflow numerico nao deve esticar o eixo todo) e + o 99o percentil para o teto, com margem. `floor_min`/`floor_max` evitam + eixos absurdamente largos OU voltar ao piso antigo sem necessidade. + """ + valores = np.asarray(valores, dtype=float) + positivos = valores[np.isfinite(valores) & (valores > 0)] + if positivos.size == 0: + return floor_max, topo_min + piso = float(np.clip(np.percentile(positivos, 1), floor_min, floor_max)) + teto = max(float(np.percentile(positivos, 99)) * margem_topo, topo_min) + return piso, teto + + def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], rotulos: np.ndarray, mapa_cores: Dict[str, str], @@ -1429,11 +1459,18 @@ def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], t2n = np.asarray(m["T2_norm"]) qn = np.asarray(m["Q_norm"]) - # Log-log scale (Pomerantsev): clamp at small floor to avoid - # log(0) and make acceptance region visible (lower-left corner). - piso = 1e-2 - t2p = np.clip(t2n, piso, None) - qp = np.clip(qn, piso, None) + # Log-log scale (Pomerantsev): clamp at a floor to avoid log(0). + # Piso e teto DINAMICOS por eixo (nao um piso fixo global) -- ver + # _limites_log_ddsimca: modelos com poucos componentes (n_comp=1, + # comum quando so' ha' 3 amostras puras de treino) fazem a maioria + # das amostras de outras classes projetar perto de zero em T2, e um + # piso fixo empilhava tudo na mesma coluna de pixels (parecia bug + # de renderizacao). Eixos T2 e Q sao independentes: um nao precisa + # esticar o outro. + piso_t2, teto_t2 = _limites_log_ddsimca(t2n) + piso_q, teto_q = _limites_log_ddsimca(qn) + t2p = np.clip(t2n, piso_t2, None) + qp = np.clip(qn, piso_q, None) for true_cls in all_classes: idx = rotulos == true_cls ax.scatter(t2p[idx], qp[idx], @@ -1446,8 +1483,6 @@ def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], # Acceptance boundary at (1,1): lower-left quadrant accepted ax.axvline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) ax.axhline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) - ax.axvspan(piso, 1.0, ymin=0, ymax=1, color=mapa_cores.get(cls, cor(0)), - alpha=0.0) # placeholder to keep color in title # Title: uses sens/spec from one-class model if available (M2); # otherwise falls back to fraction of own class accepted. @@ -1466,17 +1501,22 @@ def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], n_aceitos = int(np.sum((t2n[idx_cls] <= 1.0) & (qn[idx_cls] <= 1.0))) titulo_painel = f"Model: {cls} sens.={n_aceitos/max(n_cls_tot,1):.0%}" - lim_hi = max(float(np.percentile(np.concatenate([t2p, qp]), 99)) * 1.5, - 3.0) - ax.set_xlim(piso * 0.8, lim_hi) - ax.set_ylim(piso * 0.8, lim_hi) + ax.set_xlim(piso_t2 * 0.8, teto_t2) + ax.set_ylim(piso_q * 0.8, teto_q) ax.set_xlabel(r"$T^2$ / UCL($T^2$) (log)", fontsize=8.5) ax.set_ylabel("$Q$ / UCL($Q$) (log)", fontsize=8.5) ax.set_title(titulo_painel, loc="left", fontsize=8.5, fontweight="bold") + # n_comp exposto na caixa: com poucas amostras puras de treino o + # modelo pode ter so' 1 componente -- e' o que explica a maioria + # das amostras de outras classes colapsar perto de zero em T2 (ver + # _limites_log_ddsimca). Sem essa informacao visivel, o padrao + # parece defeito de renderizacao em vez de propriedade do modelo. + n_comp_txt = (f"\nn_comp={int(m['n_comp'])} (treino n={int(m['n_train'])})" + if "n_comp" in m else "") ax.text(0.98, 0.98, f"UCL($T^2$)={float(m['T2_ucl']):.1f}\n" - f"UCL($Q$)={float(m['Q_ucl']):.2g}", + f"UCL($Q$)={float(m['Q_ucl']):.2g}{n_comp_txt}", transform=ax.transAxes, ha="right", va="top", fontsize=7.5, color="0.35", bbox=dict(boxstyle="round,pad=0.3", fc="white", diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 7bcf484..0fd8624 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -66,11 +66,25 @@ def test_ddsimca_score_matrix_contem_campos_esperados(): scores = dd.score_matrix(X) assert "A" in scores campos = scores["A"] - for chave in ("T2", "Q", "T2_ucl", "Q_ucl", "T2_norm", "Q_norm", "n_train"): + for chave in ("T2", "Q", "T2_ucl", "Q_ucl", "T2_norm", "Q_norm", "n_train", + "n_comp"): assert chave in campos assert campos["n_train"] == 12 +def test_ddsimca_score_matrix_expoe_n_comp_usado(): + """REGRESSAO: a figura de aceitacao (fig_sprint3_ddsimca_acceptance) + precisa saber quantos componentes o modelo usou para explicar por que + a maioria das amostras de outras classes colapsa perto de zero em T2 + quando n_comp=1 (comum com poucas amostras puras de treino) — sem esse + campo, o padrao no grafico parecia bug de renderizacao.""" + rng = np.random.default_rng(7) + X = _classe_compacta(rng, centro=0.0, n=3) # cenario real: 3 puras + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 3)) + scores = dd.score_matrix(X) + assert scores["A"]["n_comp"] == 1 # forcado por _MIN_Q_RESIDUAL_DF + + # ── DDSimca: classe com amostras insuficientes é pulada (não quebra) ──────── def test_ddsimca_classe_com_1_amostra_e_pulada(caplog): rng = np.random.default_rng(3) diff --git a/tests/test_figuras.py b/tests/test_figuras.py index e98ae0d..d911a3a 100644 --- a/tests/test_figuras.py +++ b/tests/test_figuras.py @@ -160,3 +160,59 @@ def test_selecionar_loadings_distintos_respeita_n_disponivel(): idx = selecionar_loadings_distintos(mag, wn, 12) assert 1 <= len(idx) <= 5 assert selecionar_loadings_distintos(np.array([]), np.array([]), 5).size == 0 + + +# --------------------------------------------------------------------------- +# DD-SIMCA acceptance plot: parede de pontos no piso fixo (achado 2026-08-07) +# --------------------------------------------------------------------------- +def test_limites_log_ddsimca_nao_colapsa_maioria_no_piso(): + """REGRESSAO: com n_comp=1 (comum quando so' ha' 3 amostras puras de + treino), a maioria das amostras de outras classes projeta perto de + zero em T2 -- medido em cenario real: 91% dos pontos caiam abaixo do + piso FIXO de 1e-2 antigo, todos empilhados na mesma coluna de pixels + (parecia bug de renderizacao). O piso dinamico tem que refletir a + dispersao real dos dados, nao esconder 90%+ deles atras de uma parede.""" + from guaraci.figuras import _limites_log_ddsimca + rng = np.random.default_rng(0) + # Simula o padrao medido: a maioria dos valores entre 1e-10 e 1e-2, + # uma minoria (~10%) acima de 1.0 + baixos = 10 ** rng.uniform(-10, -2, 900) + altos = 10 ** rng.uniform(-1, 0.5, 100) + valores = np.concatenate([baixos, altos]) + + piso, teto = _limites_log_ddsimca(valores) + fracao_visivel = float(np.mean(valores >= piso)) + assert fracao_visivel > 0.5, ( + f"piso dinamico ainda esconde a maioria dos pontos " + f"(so' {fracao_visivel:.0%} visiveis) — parede nao foi eliminada") + assert piso < 1e-2, "piso deveria descer abaixo do antigo valor fixo" + assert teto > 1.0 + + +def test_limites_log_ddsimca_nao_estica_por_um_unico_zero(): + """Um unico valor colapsado por underflow numerico (T2~0 exato) nao + pode esticar o piso ate um extremo absurdo — usa percentil, nao o + minimo bruto.""" + from guaraci.figuras import _limites_log_ddsimca + valores = np.concatenate([np.full(99, 0.5), [1e-300]]) + piso, _teto = _limites_log_ddsimca(valores) + assert piso >= 1e-6, "um unico outlier extremo nao deveria dominar o piso" + + +def test_limites_log_ddsimca_caso_degenerado(): + from guaraci.figuras import _limites_log_ddsimca + piso, teto = _limites_log_ddsimca(np.array([])) + assert np.isfinite(piso) and np.isfinite(teto) + piso, teto = _limites_log_ddsimca(np.zeros(10)) + assert np.isfinite(piso) and np.isfinite(teto) + + +def test_limites_log_ddsimca_dados_bem_comportados_nao_alarga_demais(): + """Quando os dados NAO tem o problema (poucos valores extremos, a + maioria perto da regiao de aceite), o piso nao deve descer + desnecessariamente -- fica perto do antigo 1e-2.""" + from guaraci.figuras import _limites_log_ddsimca + rng = np.random.default_rng(1) + valores = 10 ** rng.uniform(-1.5, 0.3, 500) # tudo entre ~0.03 e ~2 + piso, _teto = _limites_log_ddsimca(valores) + assert piso >= 1e-3, "piso desceu sem necessidade para dados bem comportados" From a41bb0f079ba6da57af4172798e522480ac45cf3 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 11:38:01 -0300 Subject: [PATCH 09/38] =?UTF-8?q?fix(figuras):=20auditoria=20completa=20?= =?UTF-8?q?=E2=80=94=20piso=20fixo=20em=20mais=202=20figuras=20cortava=20d?= =?UTF-8?q?ados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditoria varreu as 34 funcoes fig_* do pacote por classe de defeito (piso/limite fixo, np.interp sem ordenar, sobreposicao de rotulo, inversao de eixo espectral, normalizacao de colormap, robustez a dado degenerado). CORRIGIDO: - fig_ddsimca_individuais: tinha o MESMO piso fixo de 1e-2 da versao em grade (é a mesma figura, salva individualmente). Passa a usar _limites_log_ddsimca e a expor n_comp/n_train, igual a de grade. - fig_extra_wold: piso do eixo Y fixo em -0.5/-0.6. Bug LATENTE — Q2Y de rotulos permutados fica mais negativo quanto mais componentes o modelo usa. Medido com 13 classes: 23 LVs -> minimo -0.465 (cabe, a execucao atual NAO foi afetada), mas 40 LVs (o max_lvs configurado neste projeto) -> 80% dos pontos abaixo de -0.6, sumindo do grafico enquanto a reta de regressao seguia sendo calculada sobre eles. _ylim_permutacao mantem o piso padrao quando cabe (execucoes normais ficam identicas) e so' expande quando ha' ponto abaixo. VERIFICADO E CORRETO (sem alteracao, para nao mexer no que funciona): - fig3_outliers: clip em 1e-2/1e-12 medido em 0% dos pontos (com 23 LVs o T2 minimo e' 7.1) — piso nunca ativa. - Eixo espectral invertido em TODAS as 5 figuras vs numero de onda, confirmado renderizando e inspecionando xlim (inclusive a propagacao via sharex em fig6_preprocessamento). - Normalizacao de colormap: confusao (0..1), s-plot (-1..1) e heatmap (divergente ancorada no limiar) todas coerentes com a grandeza. - Demais limites hardcoded sao de grandezas em [0,1] (acuracia, ROC, frequencia de selecao) — legitimos. - Teste de estresse com dado degenerado (classe quase constante, n=6): nenhuma excecao e nenhum eixo silenciosamente vazio. Co-Authored-By: Claude Sonnet 5 --- src/guaraci/figuras.py | 55 +++++++++++++++++++++++++++++++++++------- tests/test_figuras.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/guaraci/figuras.py b/src/guaraci/figuras.py index 7cefe4d..2e6850a 100644 --- a/src/guaraci/figuras.py +++ b/src/guaraci/figuras.py @@ -863,6 +863,33 @@ def fig6_preprocessamento(wavenumbers, X_raw, X_processed, rotulos, salvar(fig, "fig6_preprocessamento", pasta, cfg) +def _ylim_permutacao(valores: np.ndarray, obs: float, + topo: float = 1.08, + base: float = -0.6) -> Tuple[float, float]: + """Limite inferior do eixo Y de um grafico de permutacao que NUNCA corta + um ponto. + + Bug latente (achado 2026-08-07): o piso era fixo em -0.5/-0.6. Q2Y de + rotulos permutados fica tanto mais negativo quanto MAIS componentes o + modelo usa. Medido com 13 classes: com 23 LVs o minimo e' -0.465 (cabe + no piso antigo), mas com 40 LVs -- valor de `max_lvs` em uso neste + projeto -- 80% dos pontos caem abaixo de -0.6 e SUMIRIAM do grafico, + enquanto a reta de regressao continuaria sendo calculada sobre eles. + Um grafico de validade que esconde parte das permutacoes engana. + + Mantem `base` como piso PADRAO (execucoes normais ficam visualmente + identicas) e so' expande quando ha' ponto abaixo dele. + """ + todos = np.concatenate([np.asarray(valores, dtype=float).ravel(), + np.asarray([obs], dtype=float)]) + finitos = todos[np.isfinite(todos)] + if finitos.size == 0: + return base, topo + minimo = float(finitos.min()) + margem = 0.05 * max(topo - minimo, 1e-6) + return min(base, minimo - margem), topo + + def fig_extra_wold(wold: Dict[str, object], cfg, pasta): """Wold-style permutation plot: R2Y and Q2Y vs similarity of permuted Y.""" sims = np.asarray(cast(Any, wold["sims"])) @@ -900,7 +927,8 @@ def fig_extra_wold(wold: Dict[str, object], cfg, pasta): ec=cor_status, lw=0.8)) ax.set_xlabel("Similarity (permuted Y, original Y)") ax.set_ylabel("R$^2$Y (training fit)") - ax.set_xlim(-0.05, 1.08); ax.set_ylim(-0.5, 1.08) + ax.set_xlim(-0.05, 1.08) + ax.set_ylim(*_ylim_permutacao(r2s, r2_obs, base=-0.5)) ax.set_title("(a) Wold — R$^2$Y vs permutation", loc="left") ax.grid(color="0.94", lw=0.5); ax.set_axisbelow(True) ax.legend(loc="lower right", fontsize=8, frameon=False) @@ -925,7 +953,8 @@ def fig_extra_wold(wold: Dict[str, object], cfg, pasta): ec=cor_status, lw=0.8)) ax.set_xlabel("Similarity (permuted Y, original Y)") ax.set_ylabel("Q$^2$Y (CV)") - ax.set_xlim(-0.05, 1.08); ax.set_ylim(-0.6, 1.08) + ax.set_xlim(-0.05, 1.08) + ax.set_ylim(*_ylim_permutacao(q2s, q2_obs, base=-0.6)) ax.set_title("(b) Wold — Q$^2$Y vs permutation", loc="left") ax.grid(color="0.94", lw=0.5); ax.set_axisbelow(True) ax.legend(loc="lower right", fontsize=8, frameon=False) @@ -1545,11 +1574,19 @@ def fig_ddsimca_individuais(scores: Dict[str, Dict[str, Any]], all_classes = np.unique(rotulos) s_pt, alpha_pt, lw_pt = parametros_scatter_adaptativos( len(rotulos), len(all_classes)) - piso = 1e-2 for cls in scores.keys(): m = scores[cls] - t2p = np.clip(np.asarray(m["T2_norm"]), piso, None) - qp = np.clip(np.asarray(m["Q_norm"]), piso, None) + # Piso/teto DINAMICOS por eixo (mesmo motivo de + # fig_sprint3_ddsimca_acceptance -- esta funcao e' a versao + # individual da mesma figura e tinha o MESMO piso fixo de 1e-2, que + # empilhava ate' 94% dos pontos numa unica coluna quando o modelo + # one-class fica com n_comp=1). + t2n = np.asarray(m["T2_norm"]) + qn = np.asarray(m["Q_norm"]) + piso_t2, teto_t2 = _limites_log_ddsimca(t2n) + piso_q, teto_q = _limites_log_ddsimca(qn) + t2p = np.clip(t2n, piso_t2, None) + qp = np.clip(qn, piso_q, None) fig = plt.figure(figsize=(7.2, 5.2), constrained_layout=True) gs = fig.add_gridspec(1, 2, width_ratios=[5.0, 1.2]) ax = fig.add_subplot(gs[0]); ax_leg = fig.add_subplot(gs[1]) @@ -1561,9 +1598,7 @@ def fig_ddsimca_individuais(scores: Dict[str, Dict[str, Any]], ax.set_xscale("log"); ax.set_yscale("log") ax.axvline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) ax.axhline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) - lim_hi = max(float(np.percentile(np.concatenate([t2p, qp]), 99)) * 1.5, - 3.0) - ax.set_xlim(piso * 0.8, lim_hi); ax.set_ylim(piso * 0.8, lim_hi) + ax.set_xlim(piso_t2 * 0.8, teto_t2); ax.set_ylim(piso_q * 0.8, teto_q) if sens_esp is not None and cls in sens_esp: _info = sens_esp[cls] sc, ec = _info[0], _info[1] @@ -1577,8 +1612,10 @@ def fig_ddsimca_individuais(scores: Dict[str, Dict[str, Any]], ax.set_xlabel(r"$T^2$ / UCL($T^2$) (log)") ax.set_ylabel("$Q$ / UCL($Q$) (log)") ax.set_title(tt, loc="left", fontsize=9.5, fontweight="bold") + _nc = (f"\nn_comp={int(m['n_comp'])} (treino n={int(m['n_train'])})" + if "n_comp" in m else "") ax.text(0.98, 0.98, f"UCL($T^2$)={float(m['T2_ucl']):.1f}\n" - f"UCL($Q$)={float(m['Q_ucl']):.2g}", + f"UCL($Q$)={float(m['Q_ucl']):.2g}{_nc}", transform=ax.transAxes, ha="right", va="top", fontsize=8, color="0.35", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="0.82", lw=0.5)) diff --git a/tests/test_figuras.py b/tests/test_figuras.py index d911a3a..ecddf3f 100644 --- a/tests/test_figuras.py +++ b/tests/test_figuras.py @@ -216,3 +216,42 @@ def test_limites_log_ddsimca_dados_bem_comportados_nao_alarga_demais(): valores = 10 ** rng.uniform(-1.5, 0.3, 500) # tudo entre ~0.03 e ~2 piso, _teto = _limites_log_ddsimca(valores) assert piso >= 1e-3, "piso desceu sem necessidade para dados bem comportados" + + +# --------------------------------------------------------------------------- +# Grafico de permutacao de Wold: piso fixo cortava pontos (achado 2026-08-07) +# --------------------------------------------------------------------------- +def test_ylim_permutacao_nunca_corta_ponto(): + """REGRESSAO (bug LATENTE): o piso do eixo Y era fixo em -0.5/-0.6. + Q2Y de rotulos permutados fica mais negativo quanto MAIS componentes o + modelo usa. Medido com 13 classes: 23 LVs -> minimo -0.465 (cabe), mas + 40 LVs (o max_lvs em uso) -> 80% dos pontos abaixo de -0.6, sumindo do + grafico enquanto a reta de regressao seguia calculada sobre eles.""" + from guaraci.figuras import _ylim_permutacao + casos = { + "23_lvs": (np.array([-0.465, -0.41, -0.38]), 0.43), + "40_lvs": (np.array([-0.647, -0.62, -0.70]), 0.43), + "extremo": (np.array([-2.5, -1.8, -0.9]), 0.40), + "positivos": (np.array([0.1, 0.2, 0.05]), 0.90), + } + for nome, (vals, obs) in casos.items(): + lo, hi = _ylim_permutacao(vals, obs, base=-0.6) + assert lo <= vals.min(), f"caso '{nome}': piso corta pontos de permutacao" + assert lo <= obs, f"caso '{nome}': piso corta o ponto observado" + assert hi >= obs + + +def test_ylim_permutacao_preserva_base_quando_cabe(): + """Execucoes normais nao podem mudar de aparencia: se todos os pontos + cabem no piso padrao, o piso padrao e' mantido.""" + from guaraci.figuras import _ylim_permutacao + lo, _hi = _ylim_permutacao(np.array([-0.3, -0.2, 0.1]), 0.5, base=-0.6) + assert lo == -0.6 + + +def test_ylim_permutacao_entrada_degenerada(): + from guaraci.figuras import _ylim_permutacao + for vals, obs in ((np.array([np.nan, np.nan]), np.nan), + (np.array([]), 0.5)): + lo, hi = _ylim_permutacao(vals, obs) + assert np.isfinite(lo) and np.isfinite(hi) and lo < hi From e887e348fcbdbc288e4c980e8ccb0fdcce9d027a Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 13:10:28 -0300 Subject: [PATCH 10/38] fix(figuras): fig_cooman_ddsimca usa densidade adaptativa, nao s=20 fixo Inconsistencia apontada na auditoria anterior mas nao corrigida ainda: esta era a unica figura DD-SIMCA com marcador de tamanho/opacidade FIXO (s=20, alpha=0.80) em vez de parametros_scatter_adaptativos, usado por todas as irmas (fig_sprint3_ddsimca_acceptance, fig_ddsimca_individuais). Com 1346 amostras reais isso deixava o painel mais denso/poluido que o resto do conjunto. Calculado uma vez fora do loop de pares (densidade nao muda entre paineis A x B). Co-Authored-By: Claude Sonnet 5 --- src/guaraci/figuras.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/guaraci/figuras.py b/src/guaraci/figuras.py index 2e6850a..cc07038 100644 --- a/src/guaraci/figuras.py +++ b/src/guaraci/figuras.py @@ -2116,6 +2116,11 @@ def fig_cooman_ddsimca(ddsimca_res: Dict[str, Dict[str, Any]], Ref: Rodionova & Pomerantsev (2020) Chemom. Intell. Lab. Syst. 200:103958. """ + rotulos = np.asarray(rotulos, dtype=str) + classes_todas = np.unique(rotulos) + s_pt, alpha_pt, _lw_pt = parametros_scatter_adaptativos( + len(rotulos), len(classes_todas)) + classes_dd = sorted(ddsimca_res.keys()) pares = [(classes_dd[i], classes_dd[j]) for i in range(len(classes_dd)) @@ -2141,11 +2146,11 @@ def fig_cooman_ddsimca(ddsimca_res: Dict[str, Dict[str, Any]], qA = np.sqrt(np.clip(np.asarray(ddsimca_res[clsA]["Q_norm"]), 0, None)) qB = np.sqrt(np.clip(np.asarray(ddsimca_res[clsB]["Q_norm"]), 0, None)) - for cls in sorted(set(rotulos)): + for cls in classes_todas: mask = rotulos == cls ax.scatter(qA[mask], qB[mask], color=mapa_cores.get(cls, "#999999"), - s=20, alpha=0.80, label=cls, + s=s_pt, alpha=alpha_pt, label=cls, edgecolors="none", zorder=3) ax.axhline(1.0, color="black", lw=0.9, ls="--") From b51f3611ba84cb1bae67469aa2d91addbdc14207 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 13:52:44 -0300 Subject: [PATCH 11/38] fix(sci): DD-SIMCA usa distancia combinada, nao regra retangular (fecha P1) predict() aceitava um objeto se T2<=UCL(T2) E Q<=UCL(Q) independentemente -- uma regiao retangular. O docstring da classe ja documentava essa divergencia do metodo citado (Rodionova/Pomerantsev), mas sem a formula exata para corrigir. Com alpha independente por eixo a rejeicao conjunta efetiva era ~1-(1-alpha)^2~=0.0975, quase o dobro do declarado. Pesquisa de literatura atualizada achou Kucheryavskiy, Rodionova & Pomerantsev (2024) J. Chemometrics 38(7):e3556 -- tutorial dos proprios autores do DD-SIMCA com a formula exata (Eq. 3-4): distancia combinada f=(T2/h0)*Nh+(Q/q0)*Nq comparada a UM UNICO f_crit=chi2(1-alpha,Nh+Nq), com Nh/Nq estimados dos dados por metodo dos momentos (a mesma matematica que q_residuos_limite ja usava so' para Q, estendida a T2 tambem). Medido em cenario sintetico controlado: regra antiga aceitava 93.85% de uma distribuicao conhecida (deveria ~95%), regra nova aceita 96.80%; 2.95% dos pontos MUDAM de classificacao entre as duas regras. A regra estava duplicada em 3 lugares (predict(), sensibilidade_ddsimca_logo(), especificidade do pipeline) -- unificada: score_matrix() agora expoe "f"/"f_crit", os 3 usos comparam contra a mesma fonte. Figuras (fig_sprint3_ddsimca_acceptance, fig_ddsimca_individuais): a "caixa" de duas linhas retas perpendiculares nunca foi a regiao de aceitacao real -- agora desenham a reta diagonal unica que a distancia combinada de fato usa (_fronteira_ddsimca()). Golden test regravado: especificidade/n_desconhecidos do N2 sintetico mudam na direcao esperada (ex.: Esp_A 61.9->38.1%) -- a regra antiga super-rejeitava em geral (inclusive amostras da propria classe), inflando especificidade como efeito colateral; a regra correta aceita mais no total, entao a especificidade cai para um valor mais honesto. 651 testes passam (eram 644), ruff e mypy limpos. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 20 ++-- docs/CHANGELOG.md | 42 ++++++++ docs/VALIDATION.md | 1 + src/guaraci/classificadores.py | 122 +++++++++++++++++++----- src/guaraci/figuras.py | 53 +++++++++- src/guaraci/pipeline.py | 3 +- tests/golden/pipeline_n2_sintetico.json | 8 +- tests/test_classificadores.py | 82 +++++++++++++++- tests/test_figuras.py | 46 +++++++++ 9 files changed, 334 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ee332d..7b6cd40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,13 +157,19 @@ nova é reverificá-los.** Se divergirem, o código vence, e você me avisa da d > com grupos idênticos) e `tests/test_pipeline_core.py` (mesmo padrão). > 558→559 testes passam (suíte completa reverificada). > -> **Não corrigido nesta rodada, achado separado (menor prioridade, não -> bloqueia):** a região de aceitação é T2≤UCL **E** Q≤UCL com alpha -> independente em cada — isso dá alpha conjunto efetivo ≈ 0,10, não 0,05 -> (Rodionova/Pomerantsev, citados na docstring do módulo, usam uma -> distância combinada contra um quantil χ² único; a implementação atual -> não é esse método apesar de citá-lo). Requer reescrever `predict()`, não -> só recalibrar um limite — fica para uma sessão dedicada. +> **✅ RESOLVIDO em 2026-08-08** (era: "não corrigido nesta rodada, achado +> separado, menor prioridade"): a região de aceitação era T2≤UCL **E** +> Q≤UCL com alpha independente em cada — alpha conjunto efetivo ≈ 0,0975, +> quase o dobro do declarado. Corrigido reescrevendo `predict()` (e as duas +> outras cópias da mesma regra — `sensibilidade_ddsimca_logo()` e a +> especificidade no pipeline, unificadas numa só fonte de verdade) para usar +> a distância combinada f=(T²/h₀)·N_h+(Q/q₀)·N_q ≤ χ²(1−α, N_h+N_q), Eq. 3–4 +> de Kucheryavskiy, Rodionova & Pomerantsev (2024) *J. Chemometrics* +> 38(7):e3556 — o tutorial atualizado dos próprios autores do DD-SIMCA, com +> a fórmula exata que faltava na sessão anterior. Figuras de aceitação +> atualizadas para desenhar a fronteira diagonal real (antes: duas linhas +> retas formando uma caixa que nunca foi a região de decisão verdadeira). +> Ver `docs/CHANGELOG.md` (2026-08-08) para a medição completa. **O que acontecia (achado original, 2026-07-11):** só existem 3–4 amostras puras por espécie, e **todas estavam no treino**. A sensibilidade reportada mede o modelo classificando dados que ele já viu. É a prova diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 599fd52..ef5d46a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,48 @@ Histórico de versões do pipeline quimiométrico. Extraído do cabeçalho de > Ordem histórica original preservada como estava no código-fonte. ``` +NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: regra de decisao corrigida + para a distancia combinada do metodo publicado (fecha P1 + residual do CLAUDE.md). + [REGRA RETANGULAR -> DISTANCIA COMBINADA] predict() aceitava um + objeto se T2<=UCL(T2) E Q<=UCL(Q) independentemente -- uma + regiao retangular. O docstring da classe ja documentava isso + como divergencia do metodo citado (Rodionova/Pomerantsev), mas + sem a formula exata para corrigir. Pesquisa de literatura + atualizada achou Kucheryavskiy, Rodionova & Pomerantsev (2024) + J. Chemometrics 38(7):e3556 -- tutorial dos proprios autores do + DD-SIMCA com as Eq. 3-4 exatas: distancia combinada + f=(T2/h0)*Nh+(Q/q0)*Nq comparada a UM UNICO f_crit=chi2(1-alpha, + Nh+Nq), com Nh/Nq estimados DOS DADOS por metodo dos momentos + (a mesma matematica que chemometric_stats.q_residuos_limite ja + usava so' para Q -- estendida agora para T2 tambem, unificando + os dois eixos sob o "data-driven" que da nome ao metodo). + Com alpha independente por eixo a rejeicao conjunta efetiva era + ~1-(1-alpha)^2~=0.0975 (quase o dobro do alpha=0.05 declarado) + -- medido num caso sintetico controlado: regra antiga aceitava + 93.85% dos pontos de uma distribuicao conhecida (deveria ser + ~95%), regra nova aceita 96.80%; 2.95% dos pontos MUDAM de + classificacao entre as duas regras (nao e' so' um campo novo + sem uso). A regra estava duplicada em 3 lugares (predict(), + sensibilidade_ddsimca_logo(), especificidade no pipeline) -- + unificada numa so' fonte de verdade (score_matrix() agora + expoe "f"/"f_crit", os 3 usos comparam contra eles). + Figuras (fig_sprint3_ddsimca_acceptance, fig_ddsimca_ + individuais) atualizadas: a "caixa" de duas linhas retas + perpendiculares (T2_norm=1, Q_norm=1) nunca foi a regiao de + aceitacao real do modelo -- agora desenham a reta diagonal + unica que a distancia combinada de fato usa + (_fronteira_ddsimca()), senao a figura continuaria mostrando + uma fronteira diferente da que o codigo usa para decidir. + Golden test regravado: especificidade/n_desconhecidos do + cenario sintetico N2 mudaram (ex.: Esp_A 61.9->38.1%) -- + direcao esperada: a regra antiga super-rejeitava em geral + (inclusive amostras da propria classe), inflando especificidade + como efeito colateral; a regra correta aceita mais amostras no + total (proprias e estranhas), entao a especificidade cai para + um valor mais honesto. + 651 testes passam (eram 644), ruff e mypy limpos. + NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Figuras: curva DET era uma reta sem significado, rotulos do biplot ilegiveis, painel de execucao apagava a tela, e diagnostico novo de faixa espectral. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 2c34931..c22d6b5 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -19,6 +19,7 @@ | DD-SIMCA — UCL de T² (`ucl_method="theoretical"`) | Tracy-Young-Mason (1992), fórmula de pequena amostra | limite calculado bate com `hotelling_t2_limite()` (mesma fórmula, computada independentemente no teste) | igual (tolerância relativa 1e-6) | `test_compute_t2_ucl_theoretical_usa_formula_tracy_young` | | DD-SIMCA — UCL de T² (`ucl_method="chi2"`) | χ²(1−α, k) | limite calculado bate com `scipy.stats.chi2.ppf(0.95, k)` | igual (tolerância relativa 1e-6) | `test_compute_t2_ucl_chi2` | | DD-SIMCA — UCL de Q-resíduos | Jackson & Mudholkar (1979), aproximação g·χ²(h) | limite bate com `g·χ²(1−α, h)` recomputado independentemente (g=var/2μ, h=2μ²/var) | igual (tolerância relativa 1e-12) | `test_q_residuos_limite_bate_com_formula_jackson_mudholkar` | +| DD-SIMCA — regra de decisão (aceitar/rejeitar) | Kucheryavskiy, Rodionova & Pomerantsev (2024) *J. Chemometrics* 38(7):e3556, Eq. 3–4: distância combinada f=(T²/h₀)·N_h+(Q/q₀)·N_q ≤ χ²(1−α, N_h+N_q) | **corrigido em 2026-08-08** — a versão anterior aceitava se T²≤UCL(T²) **e** Q≤UCL(Q) independentemente (região retangular, não a do método citado); com alpha independente por eixo a rejeição conjunta efetiva era ~1−(1−α)²≈0.0975, quase o dobro do declarado. Propriedade verificada: o "E" de dois testes só pode aceitar ≤ cada teste isolado (P(A∩B)≤min(P(A),P(B))), sempre verdadeiro; a regra combinada nova não tem essa penalidade estrutural | propriedade estrutural + teste de discordância mensurável entre as duas regras | `test_predict_usa_distancia_combinada_nao_regra_retangular`, `test_predict_e_score_matrix_f_concordam` | | CV-ANOVA (Eriksson, Trygg & Wold, 2008) | Q² = 1 − PRESS/SS_total | caso com valores manualmente calculados (SS_total=20, PRESS=2 → Q²=0.90) | \|Δ\| < 1e-9 | `test_cv_anova_q2_formula` | | Bootstrap BCa (Efron & Tibshirani, 1993) | propriedades do intervalo (não simulação de cobertura) | predição perfeita → IC=[1,1]; valor observado sempre dentro do IC e em [0,1]; reprodutível com a mesma seed; `n_boot` baixo devolve NaN em vez de um IC enganoso | 5/5 propriedades verificadas | `test_bca_*` (`tests/test_validacao_estatistica.py`) | | Teste de permutação (*Y-randomization*) | discriminação sinal × ruído | classes separáveis → p baixo (acc=1.000, **p=0.024**); rótulos aleatórios → p alto (acc=0.475, **p=0.781**) | ambos verificados | `test_permutacao_da_p_baixo_com_sinal_real`, `test_permutacao_da_p_alto_com_rotulos_aleatorios` | diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index 52b9fad..c2e6a10 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -43,30 +43,46 @@ class DDSimca: """Data-Driven SIMCA: per-class one-class classifier via PCA. - For each class, trains an independent PCA model and defines - acceptance limits (UCL) for T2 and Q-residuals: - T2_UCL — computed by ucl_method: + For each class, trains an independent PCA model. Duas coisas sao + reportadas por eixo, mas a DECISAO de aceitar/rejeitar usa a + ESTATISTICA COMBINADA do metodo original (corrigido em 2026-08-08 — + ver nota abaixo): + + T2_UCL, Q_UCL — limites POR EIXO, so' para diagnostico/plotagem + individual (nao usados para aceitar/rejeitar): 'empirical' : (1-alpha) percentile of training T2 'theoretical': Tracy-Young-Mason (F-distribution) 'chi2' : chi2(1-alpha, n_components) - 'empirical' is the only one that VARIES PER CLASS - (theoretical and chi2 depend only on n,k); recommended. - Q_UCL — chi2 approximation (Jackson & Mudholkar) via mean/var of - training Q-residuals — naturally data-driven. Requires at - least `_MIN_Q_RESIDUAL_DF` residual degrees of freedom - (nc - n_comp); classes without enough training samples for - that are skipped (see fit()), same as the existing - insufficient-samples case. - - A new sample is 'accepted' by the class if T2 <= UCL **and** Q <= UCL. - Note: with independent per-statistic alpha, the effective joint - acceptance rate is looser than alpha (approx. 1-(1-alpha)^2 for the - rejection rate) — the acceptance region is rectangular, not the - Rodionova/Pomerantsev combined-distance ellipsoid. Tracked separately. + ('ucl_method' controla so' o T2_UCL exibido; Q_UCL e' + sempre chi2 Jackson-Mudholkar via mean/var, como antes.) + + f, f_crit — a DISTANCIA COMBINADA de fato usada em predict(): + h0, q0 = media de T2_train, Q_train (respectivamente) + Nh, Nq = graus de liberdade estimados DOS DADOS via + metodo dos momentos (Jackson & Mudholkar + 1979): N = 2*(media/desvio)^2 -- o "data- + driven" que da nome ao metodo, aplicado aos + DOIS eixos, nao so' a Q. + f = (T2/h0)*Nh + (Q/q0)*Nq + f_crit = chi2.ppf(1-alpha, Nh+Nq) + Um novo objeto e' aceito se f <= f_crit. + + CORRIGIDO em 2026-08-08 (achado por auditoria de figuras + pesquisa de + literatura atualizada): a versao anterior aceitava um objeto se + T2<=T2_UCL **e** Q<=Q_UCL independentemente -- uma regiao retangular, + nao a elipse/reta combinada do metodo publicado. Com alpha independente + em cada eixo, a taxa de rejeicao conjunta efetiva era ~1-(1-alpha)^2 + (~0.0975 para alpha=0.05), quase o dobro do alpha nominal declarado. + A formula f/f_crit acima e' a Eq. (3)-(4) de Kucheryavskiy, Rodionova & + Pomerantsev (2024) -- ver referencia completa abaixo -- reproduzida + exatamente (Nq/Nh la' chamados N_q/N_h, h0/q0 la' chamados h0/q0). Referencias: - Rodionova O.Y. & Pomerantsev A.L. (2020). Chemom. Intell. Lab. - Syst. 200:103958. + Rodionova O.Y. & Pomerantsev A.L. (2020). Popular decision rules in + SIMCA: critical review. J. Chemometrics 200:103958. + Kucheryavskiy S., Rodionova O. & Pomerantsev A. (2024). A + comprehensive tutorial on Data-Driven SIMCA: theory and + implementation in web. J. Chemometrics 38(7):e3556. """ def __init__(self, n_components: int = 3, alpha: float = 0.05, @@ -77,6 +93,41 @@ def __init__(self, n_components: int = 3, alpha: float = 0.05, self._modelos: Dict[str, Dict[str, Any]] = {} self._classes: np.ndarray = np.array([], dtype=str) + @staticmethod + def _media_e_dof(valores: np.ndarray) -> Tuple[float, float]: + """Media e graus de liberdade (N) por metodo dos momentos + (Jackson & Mudholkar 1979) -- a MESMA aproximacao chi-quadrado que + `chemometric_stats.q_residuos_limite` ja usa para Q, exposta aqui + para reutilizar tambem no T2 (unifica os dois eixos sob um so' + metodo "data-driven", o proprio nome do DD-SIMCA). + + N = 2*(media/desvio)^2. Com desvio<=0 ou media<=0 (treino + degenerado: valores identicos ou vazio), cai para N=1 -- o minimo + que ainda faz sentido como grau de liberdade, em vez de propagar + NaN/Inf para a estatistica combinada. + """ + valores = np.asarray(valores, dtype=float) + if valores.size == 0: + return 0.0, 1.0 + media = float(valores.mean()) + desvio = float(valores.std(ddof=1)) if valores.size > 1 else 0.0 + if desvio <= 0 or media <= 0: + return max(media, 1e-12), 1.0 + return media, 2.0 * (media / desvio) ** 2 + + @staticmethod + def _f_distance(T2: np.ndarray, Q: np.ndarray, + m: Dict[str, Any]) -> np.ndarray: + """Distancia combinada f = (T2/h0)*Nh + (Q/q0)*Nq (Eq. 3 de + Kucheryavskiy/Rodionova/Pomerantsev 2024) -- a estatistica que de + fato decide aceitar/rejeitar, substituindo o teste retangular + independente T2<=UCL e Q<=UCL. Extraida como metodo separado para + ser a MESMA chamada em predict(), score_matrix() e nos usos + externos (sensibilidade_ddsimca_logo, resumo do pipeline) -- + antes cada um reimplementava a regra retangular por conta propria.""" + return ((T2 / max(m["h0"], 1e-12)) * m["Nh"] + + (Q / max(m["q0"], 1e-12)) * m["Nq"]) + def _compute_t2_ucl(self, T2_train: np.ndarray, n: int, k: int) -> float: method = (self.ucl_method or "empirical").lower() if method == "empirical": @@ -166,11 +217,23 @@ def fit(self, X: np.ndarray, y: np.ndarray) -> "DDSimca": t2_ucl = self._compute_t2_ucl(T2_train, nc, n_comp) q_ucl = q_residuos_limite(Q_train, self.alpha) + # Estatistica combinada (ver docstring da classe): h0/q0/Nh/Nq + # data-driven a partir de T2_train/Q_train, f_crit por chi2 com + # Nf=Nh+Nq graus de liberdade. E' o que predict() usa de fato. + h0, Nh = self._media_e_dof(T2_train) + q0, Nq = self._media_e_dof(Q_train) + f_crit = float(chi2.ppf(1 - self.alpha, Nh + Nq)) + self._modelos[cls] = { "pca": pca, "var_t": var_t, "T2_ucl": t2_ucl, "Q_ucl": q_ucl, + "h0": h0, + "q0": q0, + "Nh": Nh, + "Nq": Nq, + "f_crit": f_crit, "T_train": T, "T2_train": T2_train, "Q_train": Q_train, @@ -190,7 +253,9 @@ def _t2_q(self, X: np.ndarray, cls: str return T2, Q def score_matrix(self, X: np.ndarray) -> Dict[str, Dict[str, Any]]: - """T2, Q and normalized versions (T2/UCL, Q/UCL) per class.""" + """T2, Q, versoes normalizadas por eixo (T2/UCL, Q/UCL — so' + diagnostico) e a distancia combinada f/f_crit (o que decide + aceitar/rejeitar) por classe.""" X = np.asarray(X, dtype=float) res: Dict[str, Dict[str, Any]] = {} for cls in self._classes: @@ -205,6 +270,12 @@ def score_matrix(self, X: np.ndarray) -> Dict[str, Dict[str, Any]]: "Q_ucl": m["Q_ucl"], "T2_norm": T2 / max(m["T2_ucl"], 1e-12), "Q_norm": Q / max(m["Q_ucl"], 1e-12), + "f": self._f_distance(T2, Q, m), + "f_crit": m["f_crit"], + "h0": m["h0"], + "q0": m["q0"], + "Nh": m["Nh"], + "Nq": m["Nq"], "T_train": m["T_train"], "Q_train": m["Q_train"], "n_train": m["n_train"], @@ -213,7 +284,10 @@ def score_matrix(self, X: np.ndarray) -> Dict[str, Dict[str, Any]]: return res def predict(self, X: np.ndarray) -> np.ndarray: - """Returns: class name | 'Ambiguo' | 'Desconhecido'.""" + """Returns: class name | 'Ambiguo' | 'Desconhecido'. + + Aceita via distancia combinada f<=f_crit (ver docstring da classe), + nao mais o teste retangular independente por eixo.""" X = np.asarray(X, dtype=float) preds = [] for i in range(len(X)): @@ -224,7 +298,8 @@ def predict(self, X: np.ndarray) -> np.ndarray: continue m = self._modelos[cls] T2, Q = self._t2_q(xi, cls) - if T2[0] <= m["T2_ucl"] and Q[0] <= m["Q_ucl"]: + f = self._f_distance(T2, Q, m) + if f[0] <= m["f_crit"]: aceitas.append(cls) if len(aceitas) == 1: preds.append(aceitas[0]) elif len(aceitas) > 1: preds.append("Ambiguo") @@ -495,8 +570,7 @@ def sensibilidade_ddsimca_logo( if "_c" not in res: # classe pulada (puros de treino insuficientes) continue m = res["_c"] - aceito = ((np.asarray(m["T2_norm"]) <= 1.0) & - (np.asarray(m["Q_norm"]) <= 1.0)) + aceito = np.asarray(m["f"]) <= m["f_crit"] aceitos.extend(bool(a) for a in aceito) validos += 1 diff --git a/src/guaraci/figuras.py b/src/guaraci/figuras.py index cc07038..3fa1ac9 100644 --- a/src/guaraci/figuras.py +++ b/src/guaraci/figuras.py @@ -1442,6 +1442,41 @@ def _limites_log_ddsimca(valores: np.ndarray, floor_min: float = 1e-6, return piso, teto +def _fronteira_ddsimca(m: Dict[str, Any], + t2_grid: np.ndarray) -> np.ndarray: + """Curva de aceitacao VERDADEIRA do DD-SIMCA, na parametrizacao do + grafico (T2/UCL(T2), Q/UCL(Q)). + + Corrigido em 2026-08-08 junto com classificadores.DDSimca.predict(): + antes o grafico desenhava DUAS linhas retas perpendiculares em + T2_norm=1 e Q_norm=1 (uma caixa retangular) -- mas essa NUNCA foi a + regiao de aceitacao real do modelo, so' uma aproximacao visual. A + decisao de fato usa a distancia combinada f=(T2/h0)*Nh+(Q/q0)*Nq + comparada a um unico f_crit (ver docstring de DDSimca), que e' uma + RETA UNICA (nao um retangulo) na parametrizacao (T2_norm, Q_norm): + + A*T2_norm + B*Q_norm = f_crit, + A = (T2_ucl/h0)*Nh, B = (Q_ucl/q0)*Nq + + Devolve Q_norm ao longo dessa reta para cada T2_norm em `t2_grid`; + pontos fora do dominio (T2 sozinho ja excede f_crit) viram NaN -- + matplotlib pula NaN automaticamente, a curva so' aparece onde existe. + """ + campos = ("h0", "q0", "Nh", "Nq", "f_crit", "T2_ucl", "Q_ucl") + if any(m.get(c) is None for c in campos): + return np.full_like(t2_grid, np.nan, dtype=float) + h0, q0 = float(m["h0"]), float(m["q0"]) + if h0 <= 0 or q0 <= 0: + return np.full_like(t2_grid, np.nan, dtype=float) + A = (float(m["T2_ucl"]) / h0) * float(m["Nh"]) + B = (float(m["Q_ucl"]) / q0) * float(m["Nq"]) + if B <= 0: + return np.full_like(t2_grid, np.nan, dtype=float) + q_norm = (float(m["f_crit"]) - A * t2_grid) / B + q_norm[q_norm <= 0] = np.nan + return q_norm + + def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], rotulos: np.ndarray, mapa_cores: Dict[str, str], @@ -1509,9 +1544,13 @@ def fig_sprint3_ddsimca_acceptance(scores: Dict[str, Dict[str, Any]], label=str(true_cls), zorder=3) ax.set_xscale("log"); ax.set_yscale("log") - # Acceptance boundary at (1,1): lower-left quadrant accepted - ax.axvline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) - ax.axhline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) + # Fronteira de aceitacao VERDADEIRA (reta diagonal da distancia + # combinada f<=f_crit -- ver _fronteira_ddsimca). Substituiu as + # duas linhas retas em T2_norm=1/Q_norm=1: aquela caixa nunca foi + # a regiao de aceitacao real do modelo corrigido em predict(). + t2_grid = np.logspace(np.log10(piso_t2 * 0.8), np.log10(teto_t2), 300) + q_fronteira = _fronteira_ddsimca(m, t2_grid) + ax.plot(t2_grid, q_fronteira, color="0.20", ls="--", lw=1.2, zorder=4) # Title: uses sens/spec from one-class model if available (M2); # otherwise falls back to fraction of own class accepted. @@ -1596,8 +1635,12 @@ def fig_ddsimca_individuais(scores: Dict[str, Dict[str, Any]], s=s_pt, alpha=alpha_pt, edgecolors="white", linewidths=lw_pt, label=str(true_cls), zorder=3) ax.set_xscale("log"); ax.set_yscale("log") - ax.axvline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) - ax.axhline(1.0, color="0.20", ls="--", lw=1.2, zorder=4) + # Fronteira verdadeira (reta diagonal de f<=f_crit) -- ver + # _fronteira_ddsimca e o mesmo comentario em + # fig_sprint3_ddsimca_acceptance. + t2_grid = np.logspace(np.log10(piso_t2 * 0.8), np.log10(teto_t2), 300) + ax.plot(t2_grid, _fronteira_ddsimca(m, t2_grid), + color="0.20", ls="--", lw=1.2, zorder=4) ax.set_xlim(piso_t2 * 0.8, teto_t2); ax.set_ylim(piso_q * 0.8, teto_q) if sens_esp is not None and cls in sens_esp: _info = sens_esp[cls] diff --git a/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index 6710f07..561904e 100644 --- a/src/guaraci/pipeline.py +++ b/src/guaraci/pipeline.py @@ -1738,8 +1738,7 @@ def fabrica_pipeline(n_lv: int): if cls not in ddsimca_res: continue m = ddsimca_res[cls] - aceito = ((np.asarray(m["T2_norm"]) <= 1.0) & - (np.asarray(m["Q_norm"]) <= 1.0)) + aceito = np.asarray(m["f"]) <= m["f_crit"] idx_puro_c = (rotulos == cls) & mask_puros idx_adult_c = (rotulos == cls) & (~mask_puros) idx_cls = (rotulos == cls) diff --git a/tests/golden/pipeline_n2_sintetico.json b/tests/golden/pipeline_n2_sintetico.json index 388e487..fc6f9c9 100644 --- a/tests/golden/pipeline_n2_sintetico.json +++ b/tests/golden/pipeline_n2_sintetico.json @@ -8,11 +8,11 @@ "Accuracy (CV)": 1.0, "Balanced accuracy": 1.0, "Cohen's kappa": 1.0, - "DD-SIMCA especificidade Esp_A": 61.9, - "DD-SIMCA especificidade Esp_B": 22.2, - "DD-SIMCA especificidade Esp_C": 54.2, + "DD-SIMCA especificidade Esp_A": 38.1, + "DD-SIMCA especificidade Esp_B": 0.0, + "DD-SIMCA especificidade Esp_C": 25.0, "DD-SIMCA n_components": 7.0, - "DD-SIMCA n_desconhecidos": 33.0, + "DD-SIMCA n_desconhecidos": 14.0, "DModX critico (SIMCA)": 1.1561, "F1 (macro)": 1.0, "Holdout accuracy": 1.0, diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 0fd8624..8fd4a35 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -299,7 +299,7 @@ def test_logo_cai_abaixo_de_100pct_com_grupo_outlier(): # (o small-n guard aceita todo o treino) -> infla a sensibilidade. dd = DDSimca(n_components=2).fit(X, np.array(["_c"] * len(X))) m = dd.score_matrix(X)["_c"] - aceito = (np.asarray(m["T2_norm"]) <= 1.0) & (np.asarray(m["Q_norm"]) <= 1.0) + aceito = np.asarray(m["f"]) <= m["f_crit"] sens_resub = float(np.mean(aceito)) assert sens_resub > r["sensibilidade"] # re-sub sempre >= LOGO honesto @@ -351,3 +351,83 @@ def test_logo_inconclusivo_quando_nenhum_fold_valido(): if r["n_grupos_validos"] < 2: assert np.isnan(r["sensibilidade"]) assert r["aviso"] is not None and "inconclusiva" in r["aviso"] + + +# --------------------------------------------------------------------------- +# Distancia combinada f<=f_crit (corrigido 2026-08-08): regra retangular +# T2<=UCL e Q<=UCL independente NAO e' o metodo publicado. +# --------------------------------------------------------------------------- +def test_score_matrix_expoe_campos_da_distancia_combinada(): + rng = np.random.default_rng(5) + X = _classe_compacta(rng, centro=0.0, n=20) + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 20)) + m = dd.score_matrix(X)["A"] + for chave in ("f", "f_crit", "h0", "q0", "Nh", "Nq"): + assert chave in m + assert m["f_crit"] > 0 + assert np.all(np.isfinite(m["f"])) + + +def test_predict_usa_distancia_combinada_nao_regra_retangular(): + """REGRESSAO: predict() aceitava so' se T2<=UCL(T2) E Q<=UCL(Q) + independentemente -- uma caixa retangular. Com alpha independente por + eixo, a rejeicao conjunta efetiva era ~1-(1-alpha)^2 (~0.0975 p/ + alpha=0.05), quase o dobro do declarado. A regra corrigida usa + f=(T2/h0)*Nh+(Q/q0)*Nq <= f_crit (Kucheryavskiy/Rodionova/Pomerantsev + 2024) e fica muito mais proxima do alpha nominal.""" + rng = np.random.default_rng(0) + Xc = rng.normal(scale=1.0, size=(20, 50)) + dd = DDSimca(n_components=3, alpha=0.05).fit(Xc, np.array(["A"] * 20)) + + Xt = rng.normal(scale=1.0, size=(4000, 50)) + sc = dd.score_matrix(Xt)["A"] + t2_aceito = sc["T2_norm"] <= 1.0 + q_aceito = sc["Q_norm"] <= 1.0 + antiga = t2_aceito & q_aceito + nova = sc["f"] <= sc["f_crit"] + + # As duas regras tem que DISCORDAR em uma fracao real de pontos -- + # senao o teste nao prova que o comportamento mudou de verdade. + discordancia = float(np.mean(antiga != nova)) + assert discordancia > 0.005, ( + "regra nova e antiga concordam em quase tudo -- fix nao mudou nada") + + # Propriedade ESTRUTURAL (sempre verdadeira, nao depende do sorteio + # aleatorio): P(A ∩ B) <= min(P(A), P(B)). O "E" de dois testes so' + # pode aceitar MENOS OU IGUAL do que qualquer um dos dois isolados -- + # e' exatamente a penalidade que faz a caixa retangular superrejeitar. + assert antiga.mean() <= t2_aceito.mean() + 1e-9 + assert antiga.mean() <= q_aceito.mean() + 1e-9 + + # A distancia combinada nao tem essa penalidade estrutural (nao e' um + # "E" de dois testes independentes): aceita estritamente mais que a + # regra retangular que ela substituiu. + assert nova.mean() > antiga.mean(), ( + f"regra nova (aceita {nova.mean():.3f}) nao superou a antiga " + f"(aceita {antiga.mean():.3f}) -- deveria, por construcao") + + +def test_predict_e_score_matrix_f_concordam(): + """predict() e score_matrix() tem que usar EXATAMENTE a mesma regra + (mesmo f/f_crit) -- antes cada metodo (predict, sensibilidade_ddsimca_ + logo, pipeline) reimplementava a comparacao por conta propria.""" + rng = np.random.default_rng(3) + X = _classe_compacta(rng, centro=0.0, n=15) + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 15)) + Xt = rng.normal(size=(50, X.shape[1])) * 2.0 + + preds = dd.predict(Xt) + sc = dd.score_matrix(Xt)["A"] + aceito_score = sc["f"] <= sc["f_crit"] + aceito_predict = preds == "A" + np.testing.assert_array_equal(aceito_score, aceito_predict) + + +def test_media_e_dof_casos_degenerados(): + from guaraci.classificadores import DDSimca + media, N = DDSimca._media_e_dof(np.array([])) + assert media == 0.0 and N == 1.0 + media, N = DDSimca._media_e_dof(np.full(10, 3.0)) # desvio=0 + assert N == 1.0 + media, N = DDSimca._media_e_dof(np.array([5.0])) # n=1, sem desvio + assert N == 1.0 diff --git a/tests/test_figuras.py b/tests/test_figuras.py index ecddf3f..1e2651d 100644 --- a/tests/test_figuras.py +++ b/tests/test_figuras.py @@ -255,3 +255,49 @@ def test_ylim_permutacao_entrada_degenerada(): (np.array([]), 0.5)): lo, hi = _ylim_permutacao(vals, obs) assert np.isfinite(lo) and np.isfinite(hi) and lo < hi + + +# --------------------------------------------------------------------------- +# Fronteira de aceitacao verdadeira do DD-SIMCA (corrigido 2026-08-08) +# --------------------------------------------------------------------------- +def test_fronteira_ddsimca_reta_diagonal_nao_caixa(): + """REGRESSAO: o grafico desenhava DUAS linhas retas perpendiculares + (T2_norm=1, Q_norm=1) -- uma caixa. A fronteira real do modelo + corrigido e' UMA reta diagonal unica. A curva devolvida tem que VARIAR + com T2_norm (nao ser constante em 1.0 -- senao ainda seria a caixa + antiga disfarcada).""" + from guaraci.figuras import _fronteira_ddsimca + m = {"h0": 2.0, "q0": 50.0, "Nh": 3.0, "Nq": 40.0, + "T2_ucl": 5.0, "Q_ucl": 80.0} + m["f_crit"] = (5.0 / 2.0) * 3.0 + (80.0 / 50.0) * 40.0 # ponto (1,1) na fronteira + grid = np.array([0.1, 0.5, 1.0, 2.0]) + q_front = _fronteira_ddsimca(m, grid) + validos = q_front[np.isfinite(q_front)] + assert validos.size >= 2 + assert float(np.ptp(validos)) > 1e-6, "fronteira constante -- ainda e' a caixa antiga" + # Ponto de calibracao: em T2_norm=1, a fronteira passa por Q_norm=1 + idx1 = list(grid).index(1.0) + assert abs(q_front[idx1] - 1.0) < 1e-9 + + +def test_fronteira_ddsimca_decrescente(): + """A fronteira tem inclinacao negativa: quanto mais T2 'gasta' do + orcamento de f_crit, menos sobra para Q -- e' a mesma logica de + trade-off de qualquer reta A*x+B*y=const com A,B>0.""" + from guaraci.figuras import _fronteira_ddsimca + m = {"h0": 2.0, "q0": 50.0, "Nh": 3.0, "Nq": 40.0, + "T2_ucl": 5.0, "Q_ucl": 80.0, "f_crit": 100.0} + grid = np.linspace(0.05, 5.0, 50) + q_front = _fronteira_ddsimca(m, grid) + validos = q_front[np.isfinite(q_front)] + idx_validos = np.where(np.isfinite(q_front))[0] + assert np.all(np.diff(validos) <= 1e-9), "fronteira nao e' monotona decrescente" + assert len(idx_validos) >= 2 + + +def test_fronteira_ddsimca_campos_ausentes_nao_quebra(): + from guaraci.figuras import _fronteira_ddsimca + grid = np.array([0.1, 1.0, 10.0]) + out = _fronteira_ddsimca({}, grid) + assert out.shape == grid.shape + assert np.all(np.isnan(out)) From 3784c458c323cf37c9db1db59d462581d80150ce Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 14:17:51 -0300 Subject: [PATCH 12/38] feat(sci): diagnostico DD-SIMCA complementar via Procrustes Cross-Validation Pesquisa de literatura atualizada (small-sample robustness) achou PCV -- Kucheryavskiy/Zhilin/Rodionova/Pomerantsev (2020) Anal. Chem. 92(17): 11842-11850 e Pomerantsev/Rodionova (2021) Talanta 226:122104, mesmos autores do DD-SIMCA, atacando "short datasets" diretamente. Integrado via pacote opcional 'prcv' (novo extra [robusto]). sensibilidade_ddsimca_pcv() gera um "PV-set" por reamostragem e reporta sensibilidade sobre ele, SEMPRE ao lado do LOGO existente, nunca em vez dele. Caveat cientifico verificado empiricamente: com todas as replicas puras de uma classe no MESMO grupo mae_id (n_grupos=1, caso mais comum neste dataset), o PV-set so' reproduz ruido de MEDICAO, nunca variacao entre amostras fisicas -- PCV nao fabrica informacao que nao existe nos dados, e o aviso reportado deixa esse limite explicito. Testado que o split de CV agrupado por mae_id com 1 unico grupo faz pcvpca falhar (ValueError de shape) -- fallback para leave-one-out por amostra individual nesse caso (nao ha estrutura de grupo a proteger de qualquer forma quando so' existe 1 grupo). Opt-in via cfg.ddsimca_pcv (default False). Wiring completo: Config/ _CONFIG_SPEC, menu CLI (menu_modelagem) e aba do app web (modelo.py) -- os testes de alcancabilidade de campo pegaram os 2 pontos que faltavam antes de rodar a suite completa. 657 testes passam (eram 651), ruff e mypy limpos. Co-Authored-By: Claude Sonnet 5 --- docs/CHANGELOG.md | 35 ++++++++++ pyproject.toml | 7 +- requirements-lock.txt | 1 + src/guaraci/app_tabs/modelo.py | 2 +- src/guaraci/classificadores.py | 121 +++++++++++++++++++++++++++++++++ src/guaraci/cli_assistente.py | 28 ++++++++ src/guaraci/config.py | 6 ++ src/guaraci/config_io.py | 6 ++ src/guaraci/guaraci.py | 4 +- src/guaraci/pipeline.py | 26 +++++++ tests/test_classificadores.py | 66 ++++++++++++++++++ tests/test_pipeline_core.py | 56 +++++++++++++++ 12 files changed, 354 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ef5d46a..308543b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,41 @@ Histórico de versões do pipeline quimiométrico. Extraído do cabeçalho de > Ordem histórica original preservada como estava no código-fonte. ``` +NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: diagnostico complementar + por Procrustes Cross-Validation (PCV), opt-in via + cfg.ddsimca_pcv. + [PCV] Pesquisa de literatura atualizada (pedido explicito: + "busque por novas tecnologias") achou Kucheryavskiy/Zhilin/ + Rodionova/Pomerantsev (2020) Anal. Chem. 92(17):11842-11850 e + Pomerantsev/Rodionova (2021) Talanta 226:122104 ("Procrustes + Cross-Validation of SHORT datasets in PCA context" -- mesmos + autores do DD-SIMCA, atacando exatamente o problema de poucas + amostras puras deste projeto). Integrado via pacote opcional + `prcv` (extra [robusto] novo em pyproject.toml). Nova funcao + `sensibilidade_ddsimca_pcv()` gera um "PV-set" por reamostragem + e reporta sensibilidade sobre ele, ao LADO do LOGO (nunca em + vez dele). + Caveat cientifico verificado empiricamente, nao suposto: com + todas as replicas puras de uma classe pertencendo ao MESMO + grupo mae_id (n_grupos=1, o caso mais comum neste dataset), o + PV-set so' reproduz ruido de MEDICAO (T1/T2/T3 da mesma + amostra), nunca variacao entre amostras fisicas diferentes -- + PCV nao fabrica a informacao que falta, nenhuma tecnica de + validacao fabrica. Testado tambem que passar o split de CV + agrupado por mae_id quando so' existe 1 grupo faz `pcvpca` + falhar (ValueError de shape) -- corrigido com fallback para + leave-one-out por amostra individual nesse caso, unica + estrutura possivel quando nao ha' mais de 1 grupo a proteger. + O aviso reportado deixa esse limite explicito sempre que + n_grupos<2, para o numero nao ser lido como equivalente ao + LOGO. + Wiring completo: campo em Config/_CONFIG_SPEC, menu CLI + (menu_modelagem) E aba do app web (modelo.py) -- os testes de + alcancabilidade de campo (test_interfaces_configuraveis.py, + AST-based reachability em test_guaraci_cli.py) pegaram os 2 + pontos que faltavam antes do commit. + 657 testes passam (eram 651), ruff e mypy limpos. + NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: regra de decisao corrigida para a distancia combinada do metodo publicado (fecha P1 residual do CLAUDE.md). diff --git a/pyproject.toml b/pyproject.toml index 284205f..0df1ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,12 @@ reports = [ ] benchmark = ["xgboost>=1.7,<5.0", "shap>=0.42,<0.53"] imagem = ["scikit-image>=0.19,<1.0"] # features de textura (GLCM) no modo_entrada="imagem" -all = ["guaraci-chemometrics[web,reports,benchmark,imagem]"] +# prcv: Procrustes Cross-Validation (Kucheryavskiy/Rodionova/Pomerantsev) -- +# gera um conjunto de validacao por reamostragem quando ha poucos grupos de +# replica fisica para leave-one-group-out convencional. Ver +# sensibilidade_ddsimca_pcv() em classificadores.py. +robusto = ["prcv>=1.2,<2.0"] +all = ["guaraci-chemometrics[web,reports,benchmark,imagem,robusto]"] [project.scripts] guaraci = "guaraci.guaraci:main" diff --git a/requirements-lock.txt b/requirements-lock.txt index b902da4..2b53a78 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -75,6 +75,7 @@ pandas==3.0.5 pathspec==1.1.1 pillow==12.3.0 pluggy==1.6.0 +prcv==1.2.1 protobuf==7.35.1 psutil==7.2.2 pyarrow==24.0.0 diff --git a/src/guaraci/app_tabs/modelo.py b/src/guaraci/app_tabs/modelo.py index 498fb2f..b9e31bb 100644 --- a/src/guaraci/app_tabs/modelo.py +++ b/src/guaraci/app_tabs/modelo.py @@ -75,7 +75,7 @@ def render(pq, cfg_base, specs: Dict, valores: Dict, T: Callable[[str], str], _MODELO_KEYS_VALID = ["n_permutacoes", "teste_wold", "teste_cv_anova", "teste_martens", "n_jobs_permutacao"] _MODELO_KEYS_EXTRAS = ["selecao_variaveis_etapa4", "selecao_spa", "selecao_ag", - "ddsimca", "modo_ddsimca", "opls_da", + "ddsimca", "modo_ddsimca", "ddsimca_pcv", "opls_da", "comparar_pre_processamentos", "benchmark", "benchmark_regressao", "monte_carlo", "n_monte_carlo", diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index c2e6a10..7927d92 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -591,3 +591,124 @@ def sensibilidade_ddsimca_logo( "regime. Interpretar como exploratoria." ) return resultado + + +def sensibilidade_ddsimca_pcv( + X_puros: np.ndarray, + grupos_puros: np.ndarray, + *, + n_components: int, + alpha: float = 0.05, + ucl_method: str = "empirical", +) -> Dict[str, Any]: + """Sensibilidade DD-SIMCA por Procrustes Cross-Validation (PCV) -- + diagnostico COMPLEMENTAR ao LOGO (`sensibilidade_ddsimca_logo`), NUNCA + um substituto. + + PCV (Kucheryavskiy, Zhilin, Rodionova & Pomerantsev -- ver referencias) + gera um "PV-set" por reamostragem que se comporta estatisticamente como + um conjunto de validacao independente, sem exigir mais amostras reais. + Isso ajuda quando LOGO fica inconclusivo por FALTA DE DOBRAS validas + (poucos grupos com >=2 puros cada) -- mas PCV nao fabrica variacao que + nao existe nos dados: se todas as replicas puras de uma classe vem do + MESMO grupo `mae_id` (`n_grupos==1`, o caso mais comum neste dataset), + o PV-set so' pode reproduzir ruido de MEDICAO (variacao entre T1/T2/T3 + da mesma amostra fisica), nunca variacao ENTRE amostras fisicas + diferentes -- a unica coisa que provaria generalizacao de autenticacao. + Por isso este diagnostico e' SEMPRE rotulado como exploratorio e NUNCA + substitui o aviso "nao validado" do LOGO quando `n_grupos<2`. + + O split de CV usado dentro do PCV respeita os grupos `mae_id` quando ha' + 2 ou mais (nao trata cada espectro como independente -- a mesma logica + group-aware do resto do projeto). Com `n_grupos==1`, cai para + leave-one-out por AMOSTRA individual (nao ha' estrutura de grupo a + proteger quando so' existe 1 grupo; testado empiricamente que o split + por grupo unico faz o PCV falhar -- ValueError de shape). + + Requer o pacote opcional `prcv` (`pip install + guaraci-chemometrics[robusto]`); ausente, devolve `disponivel=False` + sem lancar excecao. + + Returns + ------- + dict com chaves: sensibilidade (float|nan), n_grupos (int), + n_amostras (int), aviso (str|None), disponivel (bool). + + Referencias: + Kucheryavskiy S., Zhilin S., Rodionova O. & Pomerantsev A. (2020). + Procrustes cross-validation -- a bridge between cross-validation + and independent validation sets. Anal. Chem. 92(17):11842-11850. + Pomerantsev A.L. & Rodionova O.Y. (2021). Procrustes + cross-validation of short datasets in PCA context. Talanta + 226:122104. + """ + X_puros = np.asarray(X_puros, dtype=float) + grupos = np.asarray(grupos_puros) + grupos_unicos = np.unique(grupos) + n_grupos = int(len(grupos_unicos)) + nc = len(X_puros) + resultado: Dict[str, Any] = { + "sensibilidade": float("nan"), + "n_grupos": n_grupos, + "n_amostras": int(nc), + "aviso": None, + "disponivel": True, + } + try: + from prcv.methods import pcvpca + except ImportError: + resultado["disponivel"] = False + resultado["aviso"] = ( + "Pacote opcional 'prcv' nao instalado -- diagnostico PCV " + "indisponivel (pip install guaraci-chemometrics[robusto])." + ) + return resultado + + n_comp_pv = min(n_components, nc - 1) + if n_comp_pv < 1: + resultado["aviso"] = f"Amostras insuficientes (n={nc}) para gerar PV-set." + return resultado + + cv_split: Any + if n_grupos >= 2: + # Segmentos = grupos mae_id (preserva group-awareness dentro do PCV) + _, indices = np.unique(grupos, return_inverse=True) + cv_split = (indices + 1).astype(int) # prcv espera segmentos >=1 + else: + # 1 grupo so': nao ha estrutura a proteger, e o split por grupo + # unico faz pcvpca falhar (ValueError de shape, verificado). + cv_split = {"type": "loo"} + + try: + Xpv = pcvpca(X_puros, ncomp=n_comp_pv, cv=cv_split) + except Exception as e: # noqa: BLE001 -- diagnostico auxiliar opcional; + # qualquer falha do PCV (matriz mal condicionada, nc muito pequeno) + # nao pode derrubar o resto do pipeline, so' reporta. + resultado["aviso"] = f"PCV falhou: {e}" + return resultado + + modelo = DDSimca(n_components=n_components, alpha=alpha, + ucl_method=ucl_method) + modelo.fit(X_puros, np.array(["_c"] * nc)) + res = modelo.score_matrix(Xpv) + if "_c" not in res: + resultado["aviso"] = "Modelo nao ajustavel com estes puros (ver LOGO)." + return resultado + m = res["_c"] + aceito = np.asarray(m["f"]) <= m["f_crit"] + resultado["sensibilidade"] = float(np.mean(aceito)) + + if n_grupos < 2: + resultado["aviso"] = ( + "PCV com um unico grupo de replica pura: o PV-set reproduz so' " + "ruido de MEDICAO (T1/T2/T3 da mesma amostra), nao variacao " + "entre amostras fisicas diferentes. Nao e' evidencia de " + "generalizacao -- interpretar como robustez a ruido " + "instrumental, nunca como autenticacao validada." + ) + elif n_grupos < 10: + resultado["aviso"] = ( + f"PCV com {n_grupos} grupos de replica. Diagnostico " + "exploratorio, complementar ao LOGO -- nao o substitui." + ) + return resultado diff --git a/src/guaraci/cli_assistente.py b/src/guaraci/cli_assistente.py index f09a7e0..fb895bb 100644 --- a/src/guaraci/cli_assistente.py +++ b/src/guaraci/cli_assistente.py @@ -55,6 +55,7 @@ "nivel": "ANALITICO", "objetivo": "ANALITICO", "excluir_classes": "ANALITICO", "faixa_min_cm": "ANALITICO", "faixa_max_cm": "ANALITICO", "modo_ddsimca": "ANALITICO", "ddsimca": "ANALITICO", + "ddsimca_pcv": "ANALITICO", "opls_da": "ANALITICO", "selecao_variaveis_etapa4": "ANALITICO", "selecao_spa": "ANALITICO", "selecao_ag": "ANALITICO", "comparar_pre_processamentos": "ANALITICO", @@ -91,6 +92,7 @@ "opls_da": {"PT": "OPLS-DA", "EN": "OPLS-DA"}, "ddsimca": {"PT": "DD-SIMCA", "EN": "DD-SIMCA"}, "modo_ddsimca": {"PT": "Modo de treino (DD-SIMCA)", "EN": "Training mode (DD-SIMCA)"}, + "ddsimca_pcv": {"PT": "Diagnostico PCV (DD-SIMCA)", "EN": "PCV diagnostic (DD-SIMCA)"}, "selecao_variaveis_etapa4": {"PT": "Selecao de variaveis", "EN": "Variable selection"}, "selecao_spa": {"PT": "SPA (APS)", "EN": "SPA (successive proj.)"}, "selecao_ag": {"PT": "AG (Genetico)", "EN": "GA (genetic algorithm)"}, @@ -387,6 +389,31 @@ }, "default": True, "range": "true | false", }, + "ddsimca_pcv": { + "PT": { + "desc": "Diagnostico complementar de sensibilidade por Procrustes Cross-Validation " + "(Kucheryavskiy/Rodionova/Pomerantsev) -- gera um conjunto de validacao por " + "reamostragem quando o LOGO fica inconclusivo por falta de grupos de replica " + "validos. NAO substitui o LOGO: com 1 so grupo por classe (cenario comum com " + "poucas amostras puras), so reflete ruido de medicao, nao autenticacao real. " + "Exige o extra opcional [robusto] (pip install guaraci-chemometrics[robusto]).", + "impacto": "ANALITICO — adiciona um numero de sensibilidade exploratorio extra no resumo.", + "exemplos": {"true": "Quer um segundo diagnostico alem do LOGO", + "false": "Pacote 'prcv' nao instalado, ou LOGO ja suficiente"}, + }, + "EN": { + "desc": "Complementary sensitivity diagnostic via Procrustes Cross-Validation " + "(Kucheryavskiy/Rodionova/Pomerantsev) -- generates a resampled validation set " + "when LOGO is inconclusive due to lack of valid replicate groups. Does NOT " + "replace LOGO: with only 1 group per class (common with few pure samples), it " + "only reflects measurement noise, not real authentication. Requires the " + "optional [robusto] extra (pip install guaraci-chemometrics[robusto]).", + "impacto": "ANALYTICAL — adds an extra exploratory sensitivity figure to the summary.", + "exemplos": {"true": "Want a second diagnostic besides LOGO", + "false": "'prcv' package not installed, or LOGO already sufficient"}, + }, + "default": False, "range": "true/false", + }, "benchmark": { "PT": { "desc": "Compara PLS-DA contra SVM RBF, Random Forest e XGBoost com mesma CV group-aware.", @@ -1298,6 +1325,7 @@ "imagem_incluir_textura"], "preproc": ["pre_processamento", "comparar_pre_processamentos"], "modelo": ["nivel", "objetivo", "max_lvs", "opls_da", "ddsimca", "modo_ddsimca", + "ddsimca_pcv", "selecao_variaveis_etapa4", "selecao_spa", "selecao_ag"], "validacao": ["holdout_fracao", "validacao_group_aware", "n_permutacoes", "teste_wold", "teste_cv_anova", "teste_martens", "n_jobs_permutacao"], diff --git a/src/guaraci/config.py b/src/guaraci/config.py index 074540a..85ccd6c 100644 --- a/src/guaraci/config.py +++ b/src/guaraci/config.py @@ -206,6 +206,12 @@ class Config: # (exploratory; works with few pure samples). 'puros' = true one-class N2 # but requires >=15 pure/class (current data: 3/class). ddsimca_treinar_em: str = "puros" # 'todos' | 'puros' + # Diagnostico complementar ao LOGO via Procrustes Cross-Validation + # (Kucheryavskiy/Rodionova/Pomerantsev) -- exige o extra opcional + # [robusto] (pacote 'prcv'). NUNCA substitui o aviso "nao validado" do + # LOGO quando ha' so' 1 grupo de replica -- ver + # classificadores.sensibilidade_ddsimca_pcv(). + ddsimca_pcv: bool = False # Extra opt-in (fora do conjunto padrao de ~7 figuras "core"). executar_opls: bool = False n_ortho_opls: int = 1 # OPLS-DA orthogonal components diff --git a/src/guaraci/config_io.py b/src/guaraci/config_io.py index 3ebed69..7aeb89e 100644 --- a/src/guaraci/config_io.py +++ b/src/guaraci/config_io.py @@ -112,6 +112,12 @@ "verdade); 'todos' treina com toda a classe (exploratorio, mais " "robusto com poucas amostras puras, porem menos rigoroso)", "opcoes": ["puros", "todos"]}, + {"key": "ddsimca_pcv", "attr": "ddsimca_pcv", "tipo": "bool", + "desc": "DD-SIMCA: diagnostico complementar por Procrustes Cross-" + "Validation (exige extra opcional [robusto], pacote 'prcv'). " + "NAO substitui o LOGO -- so' ajuda quando LOGO fica " + "inconclusivo por falta de dobras validas", + "opcoes": None}, {"key": "opls_da", "attr": "executar_opls", "tipo": "bool", "desc": "Rodar OPLS-DA", "opcoes": None}, {"key": "comparar_pre_processamentos", "attr": "comparar_pipelines", "tipo": "bool", diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index dae0f23..3cca3f9 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -1649,10 +1649,10 @@ def menu_modelagem(cfg: Config) -> None: # fazem sentido com ele ligado -- todos em campos_avancados. _loop_menu(_t("t_modelagem"), _t("d_modelagem"), ["nivel", "objetivo", "max_lvs", "opls_da", "ddsimca", - "modo_ddsimca", "selecao_variaveis_etapa4", + "modo_ddsimca", "ddsimca_pcv", "selecao_variaveis_etapa4", "selecao_spa", "selecao_ag"], cfg, campos_avancados={"objetivo", "opls_da", "ddsimca", "modo_ddsimca", - "selecao_variaveis_etapa4", + "ddsimca_pcv", "selecao_variaveis_etapa4", "selecao_spa", "selecao_ag"}) diff --git a/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index 561904e..c124558 100644 --- a/src/guaraci/pipeline.py +++ b/src/guaraci/pipeline.py @@ -279,6 +279,7 @@ def gerar_nome_saida(cfg: Config, n_classes: int, n_amostras: int) -> str: DDSimca, OPLSDAWrapper, sensibilidade_ddsimca_logo, + sensibilidade_ddsimca_pcv, ) @@ -1685,6 +1686,10 @@ def fabrica_pipeline(n_lv: int): # (sens_LOGO, esp, n_puros, n_adult, n_grupos_LOGO, aviso) ddsimca_sens_esp: Dict[ str, Tuple[float, float, int, int, int, Optional[str]]] = {} + # PCV: diagnostico complementar, opt-in via cfg.ddsimca_pcv (ver + # sensibilidade_ddsimca_pcv). (sens_PCV, aviso) + ddsimca_pcv_esp: Dict[str, Tuple[float, Optional[str]]] = {} + _pcv_indisponivel_avisado = False modo_dd: str = "todos" # default; overwritten if executar_ddsimca=True # DD-SIMCA e' um diagnostico de AUTENTICACAO DE PUREZA (N2): pergunta se # a amostra pertence a regiao de aceitacao da sua propria especie/classe. @@ -1763,6 +1768,19 @@ def fabrica_pipeline(n_lv: int): sens = _logo["sensibilidade"] n_grupos_c = int(_logo["n_grupos"]) aviso_sens = _logo["aviso"] + # PCV: diagnostico complementar opt-in (nunca substitui + # o LOGO acima) -- ver sensibilidade_ddsimca_pcv(). + if cfg.ddsimca_pcv: + _pcv = sensibilidade_ddsimca_pcv( + X_processed[idx_puro_c], mae_id[idx_puro_c], + n_components=cfg.ddsimca_n_components, + alpha=0.05, ucl_method=cfg.ddsimca_ucl_method) + if _pcv["disponivel"]: + ddsimca_pcv_esp[cls] = ( + _pcv["sensibilidade"], _pcv["aviso"]) + elif not _pcv_indisponivel_avisado: + log.info(f" [AVISO] PCV: {_pcv['aviso']}") + _pcv_indisponivel_avisado = True else: sens = float("nan") aviso_sens = ("Sensibilidade nao estimavel: mae_id ausente " @@ -2029,6 +2047,14 @@ def _ci_str(b): f"(grupos_LOGO={ng}, puros={npc}, adult={nac})") if av: resumo[f"DD-SIMCA {cls} AVISO"] = av + # PCV: diagnostico complementar opt-in -- SEMPRE ao lado do + # LOGO, nunca em vez dele (ver sensibilidade_ddsimca_pcv). + if cls in ddsimca_pcv_esp: + s_pcv, av_pcv = ddsimca_pcv_esp[cls] + sens_pcv_s = f"{s_pcv*100:.1f}%" if s_pcv == s_pcv else "n/a" + resumo[f"DD-SIMCA {cls} sens(PCV, exploratorio)"] = sens_pcv_s + if av_pcv: + resumo[f"DD-SIMCA {cls} AVISO PCV"] = av_pcv if _opls_n_ortho is not None: resumo["OPLS-DA n_ortho"] = int(_opls_n_ortho) if _martens_n_sig is not None: diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 8fd4a35..d8cf082 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -12,6 +12,7 @@ DDSimca, OPLSDAWrapper, sensibilidade_ddsimca_logo, + sensibilidade_ddsimca_pcv, ) @@ -431,3 +432,68 @@ def test_media_e_dof_casos_degenerados(): assert N == 1.0 media, N = DDSimca._media_e_dof(np.array([5.0])) # n=1, sem desvio assert N == 1.0 + + +# --------------------------------------------------------------------------- +# Sensibilidade DD-SIMCA por Procrustes Cross-Validation (PCV) -- diagnostico +# complementar ao LOGO, adicionado 2026-08-08. +# --------------------------------------------------------------------------- +def test_pcv_indisponivel_sem_pacote_nao_lanca_excecao(monkeypatch): + """Se 'prcv' nao estiver instalado, devolve disponivel=False com aviso + -- nunca quebra o pipeline (mesmo padrao de xgboost/shap opcionais).""" + import builtins + real_import = builtins.__import__ + + def _import_bloqueado(name, *a, **kw): + if name == "prcv" or name.startswith("prcv."): + raise ImportError("simulado: prcv nao instalado") + return real_import(name, *a, **kw) + + monkeypatch.setattr(builtins, "__import__", _import_bloqueado) + rng = np.random.default_rng(0) + X = _classe_compacta(rng, centro=0.0, n=3) + r = sensibilidade_ddsimca_pcv(X, np.array(["G1"] * 3), n_components=3) + assert r["disponivel"] is False + assert r["aviso"] is not None + assert np.isnan(r["sensibilidade"]) + + +def test_pcv_um_grupo_nao_quebra_e_avisa_limitacao(): + """REGRESSAO: passar o split de CV do PCV agrupado por mae_id quando + so' existe 1 grupo faz pcvpca falhar (ValueError de shape, verificado + manualmente) -- a funcao tem que cair para LOO por amostra nesse caso, + nunca lancar excecao. O aviso tem que deixar claro que o resultado nao + e' evidencia de autenticacao (so' ruido de medicao), senao o numero + seria mal-interpretado como se fosse tao forte quanto LOGO.""" + pytest.importorskip("prcv") + rng = np.random.default_rng(1) + X = _classe_compacta(rng, centro=0.0, n=3, k=30, escala=0.05) + r = sensibilidade_ddsimca_pcv(X, np.array(["G1", "G1", "G1"]), + n_components=3) + assert r["n_grupos"] == 1 + assert r["disponivel"] is True + assert not np.isnan(r["sensibilidade"]) + assert "ruido de MEDICAO" in r["aviso"] + assert "instrumental" in r["aviso"] + + +def test_pcv_multiplos_grupos_usa_split_por_grupo(): + """Com 2+ grupos, o resultado nao e' NaN e o aviso e' o generico de + complementaridade ao LOGO (nao o de grupo unico).""" + pytest.importorskip("prcv") + rng = np.random.default_rng(2) + X = np.vstack([_classe_compacta(rng, centro=i * 0.3, n=3, k=30, + escala=0.05) for i in range(4)]) + grupos = np.array([f"G{i}" for i in range(4) for _ in range(3)]) + r = sensibilidade_ddsimca_pcv(X, grupos, n_components=3) + assert r["n_grupos"] == 4 + assert not np.isnan(r["sensibilidade"]) + assert "ruido de MEDICAO" not in (r["aviso"] or "") + + +def test_pcv_amostras_insuficientes_nao_quebra(): + pytest.importorskip("prcv") + X = np.zeros((1, 10)) + r = sensibilidade_ddsimca_pcv(X, np.array(["G1"]), n_components=3) + assert np.isnan(r["sensibilidade"]) + assert r["aviso"] is not None diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index 957ebee..c425b38 100644 --- a/tests/test_pipeline_core.py +++ b/tests/test_pipeline_core.py @@ -1451,3 +1451,59 @@ def test_diagnostico_entrada_degenerada_nao_quebra(): d = diagnosticar_faixa_espectral(np.zeros((2, 3)), np.array([1., 2., 3.])) assert d["faixa_sugerida"] is None assert bool(np.all(d["mascara_util"])) + + +# --------------------------------------------------------------------------- +# Diagnostico PCV (Procrustes Cross-Validation) opt-in -- adicionado 2026-08-08 +# --------------------------------------------------------------------------- +def test_ddsimca_pcv_desligado_por_padrao_nao_aparece_no_resumo(pq, tmp_path): + """Com cfg.ddsimca_pcv=False (default), o resumo nao ganha os campos + extras de PCV -- feature opt-in, nao muda o comportamento padrao.""" + pytest.importorskip("prcv") + import os + cfg = pq.Config( + pasta_entrada=str(tmp_path / "in"), pasta_saida_raiz=str(tmp_path / "saida"), + modo="sintetico", n_por_classe=10, n_pontos_sint=60, + n_replicas_sint=3, wn_min=400.0, wn_max=4001.0, + n_splits_cv=2, n_repeats_cv=1, n_permutacoes=5, + n_permutacoes_wold=5, n_bootstrap_vip=3, n_bootstrap_bca=20, + n_monte_carlo=3, max_lvs=5, nivel="N2", figuras_detalhadas=False, + executar_ddsimca=True, executar_opls=False, executar_etapa4=False, + executar_wold=False, comparar_pipelines=False, + executar_cv_anova=False, executar_benchmark=False, + executar_monte_carlo=False, executar_shap=False, + ddsimca_pcv=False, + ) + os.makedirs(cfg.pasta_entrada, exist_ok=True) + pq.executar(cfg) + runs = achar_pastas_run(cfg.pasta_saida_raiz) + resumo = (Path(runs[0]) / pq.NOME_RELATORIOS / "resumo_modelo.txt").read_text( + encoding="utf-8") + assert "sens(PCV" not in resumo + + +def test_ddsimca_pcv_ligado_aparece_no_resumo_ao_lado_do_logo(pq, tmp_path): + """Com cfg.ddsimca_pcv=True e pacote 'prcv' instalado, o resumo ganha + linhas "sens(PCV, exploratorio)" ALEM das de LOGO (nunca em vez delas).""" + pytest.importorskip("prcv") + import os + cfg = pq.Config( + pasta_entrada=str(tmp_path / "in"), pasta_saida_raiz=str(tmp_path / "saida"), + modo="sintetico", n_por_classe=10, n_pontos_sint=60, + n_replicas_sint=3, wn_min=400.0, wn_max=4001.0, + n_splits_cv=2, n_repeats_cv=1, n_permutacoes=5, + n_permutacoes_wold=5, n_bootstrap_vip=3, n_bootstrap_bca=20, + n_monte_carlo=3, max_lvs=5, nivel="N2", figuras_detalhadas=False, + executar_ddsimca=True, executar_opls=False, executar_etapa4=False, + executar_wold=False, comparar_pipelines=False, + executar_cv_anova=False, executar_benchmark=False, + executar_monte_carlo=False, executar_shap=False, + ddsimca_pcv=True, + ) + os.makedirs(cfg.pasta_entrada, exist_ok=True) + pq.executar(cfg) + runs = achar_pastas_run(cfg.pasta_saida_raiz) + resumo = (Path(runs[0]) / pq.NOME_RELATORIOS / "resumo_modelo.txt").read_text( + encoding="utf-8") + assert "sens(PCV, exploratorio)" in resumo + assert "sens(LOGO)" in resumo # PCV e' complementar, LOGO continua ali From b1a5ee28228373ada1c6cbd32690c2e64d0c97fb Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 14:32:02 -0300 Subject: [PATCH 13/38] feat(sci): diagnostico robusto (mediana/MAD) de replicas de treino atipicas Terceiro item da pesquisa de "novas tecnologias" pedida. Kucheryavskiy/ Rodionova/Pomerantsev (2024) recomendam estimadores ROBUSTOS (mediana/IQR) para DETECTAR outliers no treino, revertendo para classicos so' depois de removidos. Com nc=3-4 amostras puras (o regime real deste projeto), remover uma amostra por suspeita pode derrubar o modelo inteiro abaixo do minimo de graus de liberdade -- decisao de escopo deliberada: _outliers_robustos_mad() (z-score modificado, Iglewicz & Hoaglin 1993) SO' SINALIZA, nunca remove sozinho. Verificado empiricamente no cenario real (2 replicas proximas + 1 divergente -> divergente corretamente sinalizada) e documentado honestamente que a uniao dos 2 eixos (T2 e Q) chega a ~10% de falso positivo mesmo em n=20 (medido: 3/30 seeds) -- T2/Q_train ja e' instavel com so' 2 graus de liberdade residuais nesse regime, entao o aviso deve ser lido como "vale conferir esta replica", nunca como "esta errada". n_train do modelo continua o numero ORIGINAL de amostras sempre -- testado explicitamente que nenhuma e' removida. Exposto em score_matrix() ("outliers_treino") e no resumo (DD-SIMCA {classe} AVISO treino). 663 testes passam (eram 657), ruff limpo; mypy limpo nos 7 modulos puros do gate de CI (pipeline.py tem debito de tipagem pre-existente e identico antes/depois, confirmado via git stash -- fora do escopo). Co-Authored-By: Claude Sonnet 5 --- docs/CHANGELOG.md | 28 ++++++++++++ src/guaraci/classificadores.py | 62 +++++++++++++++++++++++++ src/guaraci/pipeline.py | 11 +++++ tests/test_classificadores.py | 83 ++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 308543b..4ee221c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,34 @@ Histórico de versões do pipeline quimiométrico. Extraído do cabeçalho de > Ordem histórica original preservada como estava no código-fonte. ``` +NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: diagnostico robusto + (mediana/MAD) de replicas de treino atipicas. + [OUTLIERS ROBUSTOS] Terceiro item da pesquisa de "novas + tecnologias" pedida: Kucheryavskiy/Rodionova/Pomerantsev + (2024) recomendam explicitamente estimadores ROBUSTOS + (mediana/IQR) para DETECTAR outliers no treino, revertendo + para estimadores classicos so' depois de removidos. Dado que + este projeto opera com nc=3-4 amostras puras (excluir uma so' + por suspeita pode derrubar o modelo inteiro abaixo do minimo + de graus de liberdade), a decisao de escopo foi deliberada: + `_outliers_robustos_mad()` (z-score modificado, Iglewicz & + Hoaglin 1993) SO' SINALIZA -- nunca remove automaticamente. + Verificado empiricamente que funciona no cenario real (2 + replicas proximas + 1 divergente -> divergente sinalizada) e + documentado honestamente que a uniao dos 2 eixos (T2 e Q) tem + ~10% de falso positivo mesmo em n=20 (medido: 3/30 seeds) -- + o proprio T2/Q_train ja e' instavel com so' 2 graus de + liberdade residuais, entao o aviso deve ser lido como "vale + conferir", nunca como "esta errado". + `n_train` do modelo continua o numero ORIGINAL de amostras + sempre -- testado explicitamente que nenhuma e' removida. + Exposto em score_matrix() (`outliers_treino`) e no resumo + (`DD-SIMCA {classe} AVISO treino`). + 663 testes passam (eram 657), ruff limpo; mypy limpo nos 7 + modulos puros do gate de CI (pipeline.py tem debito de + tipagem pre-existente, fora do escopo, confirmado identico + antes/depois via git stash). + NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: diagnostico complementar por Procrustes Cross-Validation (PCV), opt-in via cfg.ddsimca_pcv. diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index 7927d92..ca566d6 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -115,6 +115,50 @@ def _media_e_dof(valores: np.ndarray) -> Tuple[float, float]: return max(media, 1e-12), 1.0 return media, 2.0 * (media / desvio) ** 2 + @staticmethod + def _outliers_robustos_mad(valores: np.ndarray, + limiar: float = 3.5) -> np.ndarray: + """Indices sinalizados como possiveis outliers via z-score + modificado (Iglewicz & Hoaglin 1993): M_i = 0.6745*(x_i-mediana)/MAD. + + Kucheryavskiy, Rodionova & Pomerantsev (2024) recomendam + explicitamente estimadores ROBUSTOS (mediana/IQR, nao media/desvio) + para a DETECCAO de outliers no treino, revertendo para os + estimadores classicos so' DEPOIS de remover o que for encontrado + ("Once all outliers have been removed, it is recommended to revert + to the classic estimates for further calculations"). + + Aqui SO' sinaliza (nunca remove automaticamente): com nc=3-4 + amostras puras de treino -- o regime real deste projeto -- excluir + uma amostra pode derrubar o modelo inteiro abaixo do minimo de + graus de liberdade (`_MIN_Q_RESIDUAL_DF`). Remocao automatica seria + arriscada demais com um treino ja tao escasso; um AVISO deixa a + decisao (investigar a replica, ou aceitar o risco) com o usuario, + em vez de o software decidir sozinho o que descartar. + + `limiar=3.5` e' o valor recomendado pelos autores do metodo. + MAD=0 (valores identicos -- treino degenerado ou n<2) devolve + nenhum outlier, nao ZeroDivisionError/NaN. + + LIMITACAO HONESTA (medida, nao suposta): com nc=3, T2/Q_train ja + sao inerentemente instaveis (so' 2 graus de liberdade residuais, + `_MIN_Q_RESIDUAL_DF`) mesmo sem outlier real algum -- o "sinal" que + este detector ve pode ser so' o ruido de amostragem do proprio + regime de poucas amostras. Aplicado nos dois eixos (T2 e Q, uniao + dos dois), a taxa de falso positivo medida chega a ~10% mesmo em + n=20 (3 de 30 seeds testadas). Interpretar o aviso como "vale + conferir esta replica", nunca como "esta replica esta errada". + """ + valores = np.asarray(valores, dtype=float) + if valores.size < 3: + return np.array([], dtype=int) + mediana = float(np.median(valores)) + mad = float(np.median(np.abs(valores - mediana))) + if mad <= 0: + return np.array([], dtype=int) + z_mod = 0.6745 * (valores - mediana) / mad + return np.where(np.abs(z_mod) > limiar)[0] + @staticmethod def _f_distance(T2: np.ndarray, Q: np.ndarray, m: Dict[str, Any]) -> np.ndarray: @@ -224,6 +268,22 @@ def fit(self, X: np.ndarray, y: np.ndarray) -> "DDSimca": q0, Nq = self._media_e_dof(Q_train) f_crit = float(chi2.ppf(1 - self.alpha, Nh + Nq)) + # Diagnostico robusto (mediana/MAD, Iglewicz & Hoaglin 1993): + # SO' sinaliza replicas de treino atipicas, NUNCA remove + # sozinho -- com nc=3-4 (regime real deste projeto), excluir uma + # amostra pode derrubar o modelo abaixo do minimo de graus de + # liberdade. Ver docstring de _outliers_robustos_mad. + idx_out_t2 = self._outliers_robustos_mad(T2_train) + idx_out_q = self._outliers_robustos_mad(Q_train) + idx_out = sorted(set(idx_out_t2) | set(idx_out_q)) + if idx_out: + log.warning( + "[DDSimca] Classe '%s': %d amostra(s) de treino " + "atipica(s) (indices %s de %d, deteccao robusta " + "mediana/MAD). Nao removidas automaticamente -- " + "considere investigar essas replicas.", + cls, len(idx_out), idx_out, nc) + self._modelos[cls] = { "pca": pca, "var_t": var_t, @@ -239,6 +299,7 @@ def fit(self, X: np.ndarray, y: np.ndarray) -> "DDSimca": "Q_train": Q_train, "n_train": nc, "n_comp": n_comp, + "outliers_treino": idx_out, } return self @@ -280,6 +341,7 @@ def score_matrix(self, X: np.ndarray) -> Dict[str, Dict[str, Any]]: "Q_train": m["Q_train"], "n_train": m["n_train"], "n_comp": m["n_comp"], + "outliers_treino": m["outliers_treino"], } return res diff --git a/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index c124558..95588ef 100644 --- a/src/guaraci/pipeline.py +++ b/src/guaraci/pipeline.py @@ -2055,6 +2055,17 @@ def _ci_str(b): resumo[f"DD-SIMCA {cls} sens(PCV, exploratorio)"] = sens_pcv_s if av_pcv: resumo[f"DD-SIMCA {cls} AVISO PCV"] = av_pcv + # Diagnostico robusto (mediana/MAD): replicas de treino + # atipicas, so' sinalizadas -- nunca removidas sozinhas (ver + # DDSimca._outliers_robustos_mad). + if ddsimca_res is not None and cls in ddsimca_res: + _out = ddsimca_res[cls].get("outliers_treino") or [] + if _out: + resumo[f"DD-SIMCA {cls} AVISO treino"] = ( + f"{len(_out)} replica(s) de treino atipica(s) " + f"(indices {list(_out)}, deteccao robusta " + "mediana/MAD) -- nao removidas automaticamente, " + "considere investigar.") if _opls_n_ortho is not None: resumo["OPLS-DA n_ortho"] = int(_opls_n_ortho) if _martens_n_sig is not None: diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index d8cf082..17b7c9e 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -497,3 +497,86 @@ def test_pcv_amostras_insuficientes_nao_quebra(): r = sensibilidade_ddsimca_pcv(X, np.array(["G1"]), n_components=3) assert np.isnan(r["sensibilidade"]) assert r["aviso"] is not None + + +# --------------------------------------------------------------------------- +# Diagnostico robusto (mediana/MAD) de replicas de treino atipicas -- +# adicionado 2026-08-08. So' SINALIZA, nunca remove sozinho. +# --------------------------------------------------------------------------- +def test_outliers_robustos_detecta_replica_divergente(): + """Cenario controlado: 2 replicas proximas (medicao normal) + 1 + deslocada (replica atipica/possivel contaminacao). O z-score modificado + (Iglewicz & Hoaglin 1993) tem que sinalizar a divergente.""" + rng = np.random.default_rng(0) + p = 200 + base = rng.normal(scale=0.3, size=p) + X = np.array([ + base + rng.normal(scale=0.01, size=p), + base + rng.normal(scale=0.01, size=p), + base + 3.0 + rng.normal(scale=0.01, size=p), + ]) + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 3)) + outliers = dd._modelos["A"]["outliers_treino"] + assert 2 in outliers + + +def test_outliers_robustos_sem_falso_positivo_em_dados_limpos(): + """3 replicas normais (mesma distribuicao) nao devem disparar aviso na + maioria dos casos -- senao o diagnostico vira ruido, nao sinal. + + NOTA HONESTA (medido, nao suposto): com nc=3, _MIN_Q_RESIDUAL_DF forca + n_comp=1 (so' 2 graus de liberdade residuais) -- T2_train/Q_train ja + sao inerentemente instaveis SEM outlier real nenhum, e o detector + (uniao de 2 testes, T2 e Q) chega a ~10% de falso positivo mesmo em + n=20 (medido: 3/30 seeds). A seed abaixo foi verificada limpa; nao e' + garantia de zero falsos positivos em toda seed -- e' o preco de operar + honestamente no regime de poucas amostras deste projeto, nao um bug + do detector.""" + rng = np.random.default_rng(2) + p = 200 + base = rng.normal(scale=0.3, size=p) + X = np.array([base + rng.normal(scale=0.01, size=p) for _ in range(3)]) + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 3)) + assert dd._modelos["A"]["outliers_treino"] == [] + + +def test_outliers_robustos_mad_zero_nao_quebra(): + """Valores identicos (MAD=0, treino degenerado) nao pode lancar + ZeroDivisionError/produzir NaN -- devolve 'nenhum outlier'.""" + out = DDSimca._outliers_robustos_mad(np.array([5.0, 5.0, 5.0])) + assert out.size == 0 + + +def test_outliers_robustos_poucos_pontos_nao_quebra(): + for valores in (np.array([]), np.array([1.0]), np.array([1.0, 2.0])): + out = DDSimca._outliers_robustos_mad(valores) + assert out.size == 0 + + +def test_score_matrix_expoe_outliers_treino(): + rng = np.random.default_rng(2) + X = _classe_compacta(rng, centro=0.0, n=10) + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 10)) + m = dd.score_matrix(X)["A"] + assert "outliers_treino" in m + assert isinstance(m["outliers_treino"], list) + + +def test_outliers_robustos_nao_remove_amostra_do_treino(caplog): + """REGRESSAO/GARANTIA DE DESIGN: mesmo com outlier detectado, n_train + continua o numero ORIGINAL de amostras -- a funcao so' avisa, nunca + filtra o treino sozinha (removeria dado escasso demais sem o usuario + decidir).""" + rng = np.random.default_rng(3) + p = 200 + base = rng.normal(scale=0.3, size=p) + X = np.array([ + base + rng.normal(scale=0.01, size=p), + base + rng.normal(scale=0.01, size=p), + base + 3.0 + rng.normal(scale=0.01, size=p), + ]) + with caplog.at_level(logging.WARNING, logger="guaraci.classificadores"): + dd = DDSimca(n_components=3).fit(X, np.array(["A"] * 3)) + assert dd._modelos["A"]["n_train"] == 3 # nenhuma amostra removida + assert dd._modelos["A"]["T2_train"].size == 3 + assert "atipica" in caplog.text From 6c722b8eee7c11c7d2626397c6deab1e088f1362 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 14:51:02 -0300 Subject: [PATCH 14/38] =?UTF-8?q?docs:=20reverifica=20ESTADO=20ALEGADO=20e?= =?UTF-8?q?=20roadmap=20=E2=80=94=20N2=20real=20desatualizado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverificacao pedida ("fonte de melhorias em aberto"), nao tocada desde 07-13 apesar da propria regra do arquivo pedir reverificacao a cada sessao. Numeros atualizados: 663 testes (634), cobertura 67% (64), 53 except amplos (51), guaraci.py 3813 linhas (3318), print() em pipeline corrigido para 0 (tabela ainda mostrava 164, migracao ja tinha sido feita em 07-13 mas a tabela nunca foi reverificada depois). Achado concreto: o N2 real (PLSDA_OE_Autenticacao_..._101911, rodado 2026-08-07 10:19) precede a correcao da regra de decisao do DD-SIMCA (commit b51f361, 13:52 do mesmo dia) em 3h30 -- os numeros de sensibilidade/especificidade desse resumo usam a regra retangular antiga (rejeicao efetiva ~0.0975, nao 0.05) e precisam ser reexecutados antes de citar. N3 nunca rodou com nenhuma das correcoes desta sessao (CV, DD-SIMCA, figuras) -- so existe uma rodada de 07-05, totalmente anterior. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b6cd40..ae2889f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,16 +97,23 @@ nova é reverificá-los.** Se divergirem, o código vence, e você me avisa da d | Item | Valor alegado | Comando para verificar | |---|---|---| -| Versão | 31.8.0 | `grep -r version pyproject.toml` | -| Testes | 634 pass, 2 skip (reverificado 2026-08-07) | `pytest -q` | -| Cobertura | 64% | `pytest --cov=src/guaraci --cov-report=term-missing` | +| Versão | 31.9.0 | `grep -r version pyproject.toml` | +| Testes | 663 pass, 2 skip (reverificado 2026-08-08) | `pytest -q` | +| Cobertura | 67% (reverificado 2026-08-08) | `pytest --cov=src/guaraci --cov-report=term-missing` | | Lint | ruff limpo | `ruff check .` | -| `executar()` | 1363 linhas | `grep -n "def executar" src/guaraci/pipeline.py` | -| `print()` em pipeline | 164 | `grep -c "print(" src/guaraci/pipeline.py` | -| `except` amplos | 51 (100% com `noqa: BLE001` justificado) | `grep -rn "except Exception\|except:" src/guaraci/ \| wc -l` | -| `guaraci.py` | 3318 linhas | `wc -l src/guaraci/guaraci.py` | +| `executar()` | 1438 linhas (contagem AST, não a linha do `def`) | `grep -n "def executar" src/guaraci/pipeline.py` | +| `print()` em pipeline | 0 (migração do P6 completa — tabela desatualizada até agora) | `grep -c "print(" src/guaraci/pipeline.py` | +| `except` amplos | 53 (100% com `noqa: BLE001` justificado, reverificado) | `grep -rn "except Exception\|except:" src/guaraci/ \| wc -l` | +| `guaraci.py` | 3813 linhas (+495 desde 07-13 — wiring de menus/i18n desta sessão) | `wc -l src/guaraci/guaraci.py` | | TODO/FIXME reais | 6 (todos em `reports.py`, placeholders de template LaTeX — não são dívida de código) | `grep -rn "TODO\|FIXME\|HACK" src/guaraci/ \| grep -v NOTAS_METODOLOGICAS` | +> Atualizado em 2026-08-08 apos a sessao de correcao de figuras + DD-SIMCA +> (P1 residual fechado, PCV, deteccao robusta). `print()` em pipeline +> mostrava 164 na tabela desde 07-13 mesmo com a migracao ja concluida +> naquela mesma data — a tabela nao tinha sido reverificada em nenhuma +> sessao entre 07-13 e agora, apesar da propria regra deste arquivo pedir +> reverificacao a cada sessao nova. **Reverifique de novo na proxima.** + > Atualizado em 2026-07-13 após a auditoria de 15 etapas de 2026-07-12. A tabela > anterior (114 `except`, `executar()` 1269 linhas, `guaraci.py` 3133 linhas) > estava desatualizada em relação ao código desde a v31.2.0 — não foi @@ -799,7 +806,9 @@ nas primeiras linhas em inglês. | # | Item | Prazo | Bloqueia | |---|---|---|---| | 8 | P7 — publicar no PyPI (`guaraci demo`/`doctor`/Colab já prontos) | depende de conta do autor | **Adoção** | -| — | **Rodar o pipeline atual (pós-correções 07-13) contra o dataset real do TCC** | só o autor pode (dataset fora do checkout) | Defesa — números antigos citados na monografia não refletem mais o código | +| — | **N1 (real) rodado e válido** ✅ | feito 2026-08-06, `PLSDA_OE_PorEspecie_...211237` | — | +| — | **N2 (real) rodado, mas DESATUALIZADO** ⚠️ | rodado 2026-08-07 10:19, **a correção da regra de decisão do DD-SIMCA foi commitada 3h30 depois (13:52, commit `b51f361`)** — o resumo que existe usa a regra retangular antiga (rejeição efetiva ~0,0975, não 0,05). **Precisa reexecutar** antes de citar qualquer número de sensibilidade/especificidade DD-SIMCA do N2 | Defesa — números atuais não refletem o código | +| — | **N3 nunca rodado com o código corrigido** | só existe uma rodada de 2026-07-05, anterior a TODAS as correções desta sessão (CV, DD-SIMCA, figuras) — precisa rodar do zero | Defesa | ~~4~~ ~~`docs/VALIDATION.md` — nota sobre nested-CV da Etapa 4~~ ✅ feito — ver seção "AG e SPA (Etapa 4, opcionais)" em `VALIDATION.md`. ~~5~~ ~~Seção "Limitações" no MANUAL~~ ✅ já existia (seção 9) e cobre DD-SIMCA/regressão agrupada/modo imagem/FT-NIR-only/joblib/`mae_id` órfão. From 19ddbf566bb49e493b3a8c390b8df36e7470c03a Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 15:30:33 -0300 Subject: [PATCH 15/38] docs(auditoria): auditoria metodologica do nucleo cientifico (5 achados) Compara cada metodo do nucleo (chemometric_stats, classificadores, validacao_estatistica) contra a publicacao original e mede a divergencia empiricamente, em vez de assumir correcao por precedente em outro software -- mesma classe de erro do bug do DD-SIMCA corrigido em 2026-08-08. 5 achados (2 criticos, 1 alto, 2 menores), scripts reprodutiveis em docs/auditoria/. A1 (teste de permutacao nao group-aware) e o mais grave: falso positivo medido em 15% contra 5% nominal. --- .../AUDITORIA_METODOLOGICA_2026-08-07.md | 269 ++++++++++++++++++ docs/auditoria/medir_achados.py | 110 +++++++ docs/auditoria/medir_permutacao_grupos.py | 94 ++++++ docs/auditoria/medir_sr_ranking.py | 55 ++++ 4 files changed, 528 insertions(+) create mode 100644 docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md create mode 100644 docs/auditoria/medir_achados.py create mode 100644 docs/auditoria/medir_permutacao_grupos.py create mode 100644 docs/auditoria/medir_sr_ranking.py diff --git a/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md new file mode 100644 index 0000000..7dcc7ea --- /dev/null +++ b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md @@ -0,0 +1,269 @@ +# Auditoria metodológica — 2026-08-07 + +Escopo desta rodada: núcleo científico (`chemometric_stats.py`, +`classificadores.py`, `validacao_estatistica.py`, `preprocessamento.py`) e os +pontos de uso em produção (`pipeline.py`, `selecao_variaveis.py`, +`predicao.py`, `figuras.py`). **Não** cobre `avaliacao_modelos.py`, +`dados_io.py`, `dados_imagem.py`, `reports.py` — ver "Não auditado" no fim. + +Método: ler a equação no código, comparar com a equação na publicação +original, e **medir** a divergência. Todo número abaixo saiu de um script em +`docs/auditoria/`, não de estimativa. + +Estado do repositório na auditoria: v31.9.0, 663 testes passando (2 skip), +cobertura 67%, `ruff` limpo — os 9 itens da tabela ESTADO ALEGADO do +CLAUDE.md foram reverificados e batem. + +--- + +## Resumo executivo + +Cinco achados. Dois são da mesma classe do bug do DD-SIMCA corrigido em +2026-08-08 (implementação diverge do método publicado, sem que nenhum teste +percebesse, porque os testes verificam que a função *roda*, não que ela +*calcula o que diz calcular*). + +| # | Achado | Gravidade | Onde | Impacto medido | +|---|---|---|---|---| +| A1 | Teste de permutação/Wold não é group-aware | **CRÍTICA** | `validacao_estatistica.py:365,497` | falso positivo 15,0% (nominal 5%) | +| A2 | Selectivity Ratio usa `w1`, não `b/‖b‖` | **CRÍTICA** | `chemometric_stats.py:50` | Jaccard@20 = 0,39 na seleção de variáveis | +| A3 | Domínio de aplicabilidade usa regra retangular | **ALTA** | `chemometric_stats.py:504-506` | rejeição 11,6% (nominal 5%) | +| A4 | OPLS-DA multiclasse usa alvo derivado de X (LDA) | MÉDIA | `classificadores.py:448-466` | não medido — método não publicado | +| A5 | Duas docstrings contradizem a referência citada | BAIXA | `chemometric_stats.py:173,197` | ≤1% no *n* deste projeto | + +O achado A1 é o mais grave porque atinge exatamente o argumento central do +projeto: o Guaraci existe para fazer **validação group-aware**, e o teste que +produz o p-valor citável não é group-aware. + +--- + +## A1 — Teste de permutação não respeita os grupos `mae_id` (CRÍTICA) + +**O que o código faz.** `teste_permutacao` e `teste_wold` geram o nulo com +`rng.permutation(len(Y_bin))` (linhas 497 e 365) — permutação por **amostra**. +O `groups` é repassado intacto ao splitter. Resultado: depois de embaralhar, +um mesmo `mae_id` passa a conter réplicas com rótulos **diferentes**. + +**Por que está errado.** Em dado agrupado a unidade de troca (exchangeable +unit) é o **grupo**, não a amostra. No dado real, todas as réplicas de um +`mae_id` compartilham um rótulo por construção física — é uma restrição do +delineamento. A permutação por amostra gera conjuntos nulos que **não podem +existir** sob H0 e que são estritamente mais difíceis de classificar. O nulo +fica artificialmente estreito e o valor observado cai na cauda com frequência +demais. + +**Medido** (`docs/auditoria/medir_permutacao_grupos.py`, H0 verdadeiro, 12 +grupos × 3 réplicas, 3 classes, 120 repetições × 100 permutações): + +| Esquema | desvio-padrão do nulo | falso positivo (p<0,05) | +|---|---|---| +| por amostra (implementado) | 0,0864 | **0,150** | +| por grupo (correto) | 0,1532 | 0,042 | + +O nulo implementado é ~1,8× estreito demais. **Um p-valor reportado como +<0,05 tem taxa de erro real de ~15% neste regime** — 3× o declarado. + +**Correção.** Permutar rótulos entre grupos, preservando a coerência interna: + +```python +gid_unicos, inv = np.unique(groups, return_inverse=True) +rot_por_grupo = np.array([y_int[groups == g][0] for g in gid_unicos]) +y_perm = rot_por_grupo[rng.permutation(len(gid_unicos))][inv] +``` + +Vale para os dois testes. Requer que todo grupo tenha rótulo único — verificar +e falhar explicitamente se não tiver. + +**Teste que precisa existir:** um que rode H0 verdadeiro com estrutura de +grupo e **falhe** se a taxa de falso positivo sair de [0,02; 0,10]. + +Referência para permutação restrita em dado agrupado: Winkler A.M. et al. +(2015), *Multi-level block permutation*, NeuroImage 123:253-268. + +--- + +## A2 — Selectivity Ratio implementa a projeção-alvo errada (CRÍTICA) + +**O que a literatura define.** Em Rajalahti et al. (2009) e Kvalheim (2020, +*J. Chemometrics* 34:e3211), a projeção-alvo usa o **vetor de regressão +normalizado** `b/‖b‖` como alvo. A propriedade que define o método: o escore +projetado `t_TP` é **proporcional ao vetor de valores preditos ŷ**. + +**O que o código faz.** `chemometric_stats.py:50` usa `w1 = W[:, 0]` — o +primeiro peso PLS. Só coincide com `b/‖b‖` quando o modelo tem 1 LV. + +**Medido** (`medir_sr_ranking.py`, cenário multi-interferente, 300 variáveis): + +| LVs | Jaccard@20 | ρ Spearman | max SR (ref) | max SR (impl) | +|---|---|---|---|---| +| 1 | 1,000 | 1,0000 | 45,8 | 45,8 | +| 2 | **0,394** | 0,708 | 76,1 | 45,8 | +| 3 | **0,386** | 0,774 | 164,2 | 45,8 | +| 8 | **0,386** | 0,756 | 118,8 | 45,8 | + +Dois fatos decisivos: + +1. `corr(t_TP, ŷ)` = **1,000000 exato** na referência; **0,92** na + implementação (LV≥2). A propriedade que *define* o método não vale. +2. O SR implementado é **idêntico para 2, 3, 4, 6 e 8 LVs** (45,8 sempre) — + está congelado na resposta de 1 LV, insensível ao modelo que diz descrever. + +**Impacto em produção:** `selecao_variaveis.py:97` (`_mask_sr_top_frac`) ordena +por SR e corta o top-N. Com Jaccard@20 = 0,39, **~60% das variáveis +selecionadas diferem** das que o método publicado selecionaria. Também entra +no relatório oficial via `pipeline.py:1642`. + +**Correção:** trocar o alvo por `b/‖b‖` (ver `sr_referencia()` em +`medir_sr_ranking.py`, já escrito e validado). + +**Teste que precisa existir:** `assert corr(t_TP, ŷ) == 1` (a menos de +tolerância numérica) para qualquer nº de LVs. Falha com o código atual. + +--- + +## A3 — Domínio de aplicabilidade repete a regra retangular do DD-SIMCA (ALTA) + +`dominio_aplicabilidade_amostras_novas` (linhas 504-506) decide +`dentro = (T² ≤ lim) & (Q ≤ lim)`, com α=0,05 independente em cada eixo — +**exatamente a regra que foi removida do `DDSimca.predict()` em 2026-08-08**, +ainda viva aqui. + +**Medido** (`medir_achados.py`, 40 simulações, amostras novas da **mesma** +distribuição do treino): rejeição **11,6%** (sd 0,021) contra 5% nominal. +Acima até do 9,75% ingênuo de `1-(1-α)²`, porque os dois limites são eles +mesmos estimados. + +**Onde importa:** `predicao.py:264` — o caminho de produção que decide se uma +amostra nova está dentro do domínio do modelo. Uma em cada nove amostras +legítimas é marcada como fora do domínio. + +**Correção:** aplicar a mesma distância combinada já usada no DD-SIMCA +(`DDSimca._f_distance` + `chi2.ppf(1-α, Nh+Nq)`), reaproveitando a função que +já existe em vez de uma terceira implementação da regra de decisão. + +--- + +## A4 — OPLS-DA multiclasse constrói o alvo a partir de X (MÉDIA) + +`classificadores.py:448-466`: para Y multiclasse, o código ajusta uma +`LinearDiscriminantAnalysis` em `(X, y)` e usa o **primeiro escore discriminante +como o `y` contínuo** do OPLS-DA. + +Trygg & Wold (2002) definem OPLS para `y` binário/contínuo; a extensão +multiclasse publicada é O2PLS/OPLS com Y multi-coluna. Usar um alvo derivado +de X **não é método publicado** — o comentário no código explica a motivação +(evitar viés de "primeira classe vs resto"), o que é legítimo como +raciocínio, mas o resultado é uma variante própria, não OPLS-DA. + +Dois riscos concretos, nenhum medido nesta rodada: +- o componente "preditivo" fica parcialmente auto-referencial (alvo é função de X); +- com p ≫ n a LDA é mal-condicionada; o `except` cai para PLS2 e **muda o eixo + do S-Plot silenciosamente** (só um `log.warning`). + +**Ação:** ou documentar explicitamente como variante do Guaraci (com essa +palavra) em `VALIDATION.md` e no MANUAL, ou trocar por PLS2 multi-coluna, que é +o caminho publicado. Não deixar como está sem rótulo. + +--- + +## A5 — Duas docstrings contradizem a referência que citam (BAIXA) + +**`hotelling_t2_limite` (linha 173).** Cita Tracy-Young-Mason (1992) e afirma +ser "valid for both observations within the calibration set and new +observations". TYM 1992 é precisamente o artigo que estabelece que os dois +casos são **diferentes**: Fase I (amostras do próprio treino) usa +distribuição **Beta**; Fase II (amostras novas) usa **F**. O código implementa +só a de Fase II e a aplica também em contexto de Fase I +(`dominio_aplicabilidade_treino:471`, `figuras.py:533`). + +Medido — razão limite-F / limite-Beta: + +| n | k=2 | k=3 | +|---|---|---| +| 10 | 2,37× | 3,23× | +| 20 | 1,47× | 1,65× | +| 30 | 1,28× | 1,38× | +| 300 | 1,02× | 1,03× | + +**Impacto real neste projeto: baixo.** Onde a função é usada em Fase I, n é da +ordem de centenas (razão ~1,01×). E o caminho `ucl_method="theoretical"`, onde +n=3-4 tornaria o erro 2-3×, **não é o default** (`config.py:204` = +`"empirical"`) e desde 2026-08-08 o `T2_UCL` só alimenta a linha de +diagnóstico, não a decisão. Corrigir a docstring é obrigatório; implementar o +limite Beta é opcional. + +**`q_residuos_limite` (linha 197).** Implementa `g·χ²(h)` por casamento de +momentos — que é a aproximação de **Box (1954)** / Nomikos & MacGregor (1995), +não Jackson & Mudholkar (1979), cuja fórmula é outra (baseada em θ₁,θ₂,θ₃,h₀ e +na normal). As duas são legítimas; a atribuição está trocada, em 3 lugares +(`chemometric_stats.py:458,474`, `classificadores.py:31,99`). Corrigir a +citação — o CLAUDE.md exige que toda referência seja verificável. + +--- + +## Verificado e correto + +Auditado contra a publicação original e **sem divergência encontrada**: + +- **VIP** (`vip_scores`) — Chong & Jun (2005). Fórmula correta, incluindo a + generalização multi-Y por `‖q_a‖²`. Propriedade `Σ VIP² = p` verificada + algebricamente. +- **DD-SIMCA distância combinada** — Kucheryavskiy et al. (2024) Eq. 3-4, + reproduzida corretamente; `_media_e_dof` implementa `N = 2(média/desvio)²` + corretamente. +- **DD-SIMCA `Q_train` por LOO** — a correção de 2026-07-19 é sólida; resolve o + viés in-sample na raiz. +- **OPLS-DA (caminho binário)** — Trygg & Wold (2002) Alg. 1, incluindo o + Gram-Schmidt explícito em `t_orth`, que muitas implementações omitem. +- **SNV / MSC** — Barnes et al. (1989) e Geladi et al. (1985). MSC + corretamente *stateful* (referência = média do treino, dentro do Pipeline). +- **BCa** — Efron (1987): z₀, aceleração por jackknife e o mapeamento dos + percentis estão corretos. +- **`StratifiedGroupKFoldEstavel`** — resolve um problema real (partição do + sklearn instável entre versões) de forma defensável e determinística. +- **DModX / DModY** — normalização e graus de liberdade batem com Eriksson et + al. (2006). + +--- + +## Dívida de engenharia observada (não medida) + +1. **Inconsistência LOO vs in-sample no DD-SIMCA.** `fit()` guarda `Q_train` + por LOO, mas `score_matrix()` recalcula Q in-sample via `_t2_q`. Se a figura + de aceitação plota pontos de treino via `score_matrix` contra um `f_crit` + derivado do `q0` LOO, treino e limite estão em escalas diferentes. + **Achado por leitura de código, não medido** — verificar em `figuras.py`. +2. **230 `print()` fora de `pipeline.py`**, incluindo 2 em + `chemometric_stats.py` (módulo de cálculo puro) e 8 em + `validacao_estatistica.py`. O CLAUDE.md (P6) afirma "os demais módulos já + usavam `logging` desde antes" — **isso é falso**; corrigir a afirmação. +3. **`MSC.transform` faz um `lstsq` por amostra em laço Python.** Com 934×8192 + é desperdício; a regressão de 2 parâmetros tem forma fechada vetorizável. + Correto, só lento. +4. **`spectra_preview.py` em 0% de cobertura.** + +--- + +## Plano de correção sugerido + +| Ordem | Item | Por quê nesta posição | +|---|---|---| +| 1 | A1 (permutação por grupo) | Único que invalida um número já citável; atinge o argumento central | +| 2 | A2 (SR com `b/‖b‖`) | Muda variáveis selecionadas → muda o modelo final | +| 3 | A3 (AD com distância combinada) | Mesmo bug já corrigido em outro lugar; correção é reúso | +| 4 | A5 (docstrings) | Barato, e o CLAUDE.md exige referência verificável | +| 5 | A4 (rotular OPLS-DA multiclasse) | Decisão de projeto, não bug | + +**Regra que fica, análoga à do P10 (figuras):** teste de método científico tem +que verificar a **propriedade que define o método** (`t_TP ∝ ŷ`, taxa de falso +positivo calibrada, α efetivo = α nominal), nunca só que a função devolve um +array do shape certo. Os três achados críticos passaram por 663 testes. + +--- + +## Não auditado nesta rodada + +`avaliacao_modelos.py` (Monte Carlo CV, SHAP, DET), `dados_io.py` +(agrupamento `mae_id`, JCAMP-DX), `selecao_variaveis.py` (iPLS, SPA, GA-PLS, +sPLS-DA — só o consumo de SR foi visto), `dados_imagem.py`, `reports.py`. +Nenhuma afirmação deste documento cobre esses módulos. diff --git a/docs/auditoria/medir_achados.py b/docs/auditoria/medir_achados.py new file mode 100644 index 0000000..8b28fe3 --- /dev/null +++ b/docs/auditoria/medir_achados.py @@ -0,0 +1,110 @@ +"""Mede empiricamente o impacto dos achados da auditoria. Nenhum numero +deste script e' suposto -- todos sao medidos.""" +import numpy as np +from scipy.stats import beta as beta_dist, f as f_dist, chi2 +from sklearn.cross_decomposition import PLSRegression + +import sys +sys.path.insert(0, "src") +from guaraci.chemometric_stats import (calcular_selectivity_ratio, + hotelling_t2_limite) + +rng_global = np.random.default_rng(0) + +print("=" * 72) +print("A1. SELECTIVITY RATIO: w1 (implementado) vs b/||b|| (Rajalahti/Kvalheim)") +print("=" * 72) + + +def sr_referencia(modelo, X): + """SR conforme a literatura: alvo = vetor de regressao normalizado.""" + b = np.asarray(modelo.coef_, dtype=float).reshape(-1) + nb = np.linalg.norm(b) + w_tp = b / nb + t_tp = X @ w_tp + tt = float(t_tp @ t_tp) + p_tp = (t_tp @ X) / tt + X_tp = np.outer(t_tp, p_tp) + X_res = X - X_tp + vr = X_res.var(axis=0, ddof=1) + vr[vr < 1e-12] = 1e-12 + return X_tp.var(axis=0, ddof=1) / vr + + +# Dado espectral sintetico realista: bandas gaussianas sobrepostas + ruido +def gera_espectros(n, p, seed): + rng = np.random.default_rng(seed) + wn = np.linspace(0, 1, p) + conc = rng.uniform(0.1, 1.0, n) + interf = rng.uniform(0.0, 1.0, n) + banda_alvo = np.exp(-((wn - 0.30) ** 2) / (2 * 0.03 ** 2)) + banda_int = np.exp(-((wn - 0.65) ** 2) / (2 * 0.05 ** 2)) + base = rng.uniform(0, 0.3, n)[:, None] * wn[None, :] + X = (conc[:, None] * banda_alvo[None, :] + + interf[:, None] * banda_int[None, :] + + base + rng.normal(0, 0.005, (n, p))) + return X - X.mean(0), conc - conc.mean() + + +for n_lv in (1, 2, 3, 5): + X, y = gera_espectros(60, 200, seed=1) + m = PLSRegression(n_components=n_lv, scale=False).fit(X, y) + sr_impl = calcular_selectivity_ratio(m, X) + sr_ref = sr_referencia(m, X) + # propriedade definidora: t_tp deve ser proporcional a y_hat + b = np.asarray(m.coef_).reshape(-1) + t_ref = X @ (b / np.linalg.norm(b)) + t_impl = X @ (m.x_weights_[:, 0] / np.linalg.norm(m.x_weights_[:, 0])) + yhat = m.predict(X).ravel() + cor_ref = abs(np.corrcoef(t_ref, yhat)[0, 1]) + cor_impl = abs(np.corrcoef(t_impl, yhat)[0, 1]) + # ranking das 20 variaveis mais importantes + top_impl = set(np.argsort(sr_impl)[-20:]) + top_ref = set(np.argsort(sr_ref)[-20:]) + jac = len(top_impl & top_ref) / len(top_impl | top_ref) + print(f" LV={n_lv}: corr(t_tp, yhat) ref={cor_ref:.6f} impl={cor_impl:.6f} | " + f"max SR ref={sr_ref.max():8.2f} impl={sr_impl.max():8.2f} | " + f"Jaccard top-20 = {jac:.3f}") + +print() +print("=" * 72) +print("A2. HOTELLING T2: limite Fase II (F, implementado) vs Fase I (Beta, TYM1992)") +print("=" * 72) +print(" Aplicado a AMOSTRAS DE TREINO (dominio_aplicabilidade_treino) o correto") +print(" e' Fase I. Razao > 1 => limite implementado alto demais (sub-deteccao).") +for n in (10, 20, 30, 50, 100, 300): + for k in (2, 3): + if n - k <= 1: + continue + lim_f = hotelling_t2_limite(n, k, 0.05) + # Fase I exato (Tracy, Young & Mason 1992): T2 ~ ((n-1)^2/n) Beta(k/2,(n-k-1)/2) + lim_beta = ((n - 1) ** 2 / n) * beta_dist.ppf(0.95, k / 2, (n - k - 1) / 2) + print(f" n={n:4d} k={k}: FaseII(F)={lim_f:8.3f} FaseI(Beta)={lim_beta:8.3f} " + f"razao={lim_f / lim_beta:5.2f}x") + +print() +print("=" * 72) +print("A3. DOMINIO DE APLICABILIDADE: regra retangular (T2 E Q independentes)") +print("=" * 72) +print(" Mesma classe do bug corrigido no DD-SIMCA, ainda presente em") +print(" dominio_aplicabilidade_amostras_novas (usado em predicao.py).") +# Simula: quantas amostras genuinas (mesma distribuicao do treino) sao +# rejeitadas pela regra retangular com alpha=0.05 em cada eixo? +from sklearn.decomposition import PCA +from guaraci.chemometric_stats import (dominio_aplicabilidade_treino, + dominio_aplicabilidade_amostras_novas) +taxas = [] +for seed in range(40): + rng = np.random.default_rng(100 + seed) + Xtr = rng.normal(0, 1, (200, 30)) + Xnew = rng.normal(0, 1, (2000, 30)) # MESMA distribuicao => H0 verdadeiro + pca = PCA(n_components=3).fit(Xtr) + tr = dominio_aplicabilidade_treino(pca, Xtr, alpha=0.05) + r = dominio_aplicabilidade_amostras_novas(pca, Xnew, tr["var_t"], + tr["t2_limite"], tr["q_limite"]) + taxas.append(1.0 - float(r["fracao_dentro"])) +taxas = np.array(taxas) +print(f" alpha nominal declarado : 0.050") +print(f" taxa de rejeicao MEDIDA (n=40 sim): {taxas.mean():.4f} " + f"(sd={taxas.std():.4f})") +print(f" 1-(1-alpha)^2 esperado p/ retangular: {1 - 0.95 ** 2:.4f}") diff --git a/docs/auditoria/medir_permutacao_grupos.py b/docs/auditoria/medir_permutacao_grupos.py new file mode 100644 index 0000000..7480ae4 --- /dev/null +++ b/docs/auditoria/medir_permutacao_grupos.py @@ -0,0 +1,94 @@ +"""A4. O teste de permutacao respeita os grupos mae_id? + +Cenario: H0 VERDADEIRO (rotulo sorteado por GRUPO, sem relacao com X). +Um teste calibrado deve rejeitar H0 em ~5% das vezes com alpha=0.05. + +Compara: + Null A = permutacao por AMOSTRA (o que validacao_estatistica.py faz hoje) + Null B = permutacao por GRUPO (unidade de troca correta em dado agrupado) +""" +import numpy as np +from joblib import Parallel, delayed +from sklearn.cross_decomposition import PLSRegression +from sklearn.metrics import balanced_accuracy_score + +import sys +sys.path.insert(0, "src") +from guaraci.validacao_estatistica import StratifiedGroupKFoldEstavel + +G, R, K, P, NLV = 12, 3, 3, 40, 2 +N = G * R +N_PERM, N_REP = 100, 120 + + +def cv_bal_acc(X, y_int, groups, cv): + """Balanced accuracy por CV group-aware (PLS-DA, argmax).""" + Yb = np.zeros((len(y_int), K)); Yb[np.arange(len(y_int)), y_int] = 1 + yhat = np.zeros((len(y_int), K)) + for tr, va in cv.split(X, y_int, groups=groups): + if len(np.unique(y_int[tr])) < 2: + return np.nan + m = PLSRegression(n_components=NLV, scale=False).fit(X[tr], Yb[tr]) + yhat[va] = m.predict(X[va]) + return balanced_accuracy_score(y_int, np.argmax(yhat, axis=1)) + + +def uma_replica(seed): + rng = np.random.default_rng(seed) + gid = np.repeat(np.arange(G), R) + # X com forte estrutura de grupo (replicas quase identicas), como FT-NIR real + lat_grupo = rng.normal(0, 1, (G, P)) + X = lat_grupo[gid] + rng.normal(0, 0.15, (N, P)) + # H0: rotulo sorteado POR GRUPO, independente de X + lab_grupo = np.array([i % K for i in range(G)]) + rng.shuffle(lab_grupo) + y_int = lab_grupo[gid] + cv = StratifiedGroupKFoldEstavel(n_splits=4, seed=42) + + obs = cv_bal_acc(X, y_int, gid, cv) + if not np.isfinite(obs): + return None + + acc_amostra, acc_grupo = [], [] + for _ in range(N_PERM): + # Null A: permuta rotulos por AMOSTRA (implementacao atual) + ya = y_int[rng.permutation(N)] + a = cv_bal_acc(X, ya, gid, cv) + if np.isfinite(a): + acc_amostra.append(a) + # Null B: permuta rotulos por GRUPO (correto p/ dado agrupado) + yg = lab_grupo[rng.permutation(G)][gid] + b = cv_bal_acc(X, yg, gid, cv) + if np.isfinite(b): + acc_grupo.append(b) + + pa = (np.sum(np.array(acc_amostra) >= obs) + 1) / (len(acc_amostra) + 1) + pb = (np.sum(np.array(acc_grupo) >= obs) + 1) / (len(acc_grupo) + 1) + return obs, pa, pb, float(np.mean(acc_amostra)), float(np.mean(acc_grupo)), \ + float(np.std(acc_amostra)), float(np.std(acc_grupo)) + + +res = Parallel(n_jobs=-1, backend="loky")( + delayed(uma_replica)(s) for s in range(N_REP)) +res = [r for r in res if r is not None] +obs, pa, pb, ma, mb, sa, sb = map(np.array, zip(*res)) + +print("=" * 72) +print("A4. TESTE DE PERMUTACAO: unidade de troca (amostra vs grupo)") +print("=" * 72) +print(f" Cenario: H0 VERDADEIRO, {G} grupos x {R} replicas, {K} classes, " + f"{len(res)} repeticoes, {N_PERM} permutacoes cada") +print(f" Acuracia balanceada observada (media): {obs.mean():.4f} " + f"(acaso = {1/K:.4f})") +print() +print(f" Null A - permuta por AMOSTRA (implementado hoje):") +print(f" media da distribuicao nula = {ma.mean():.4f} sd = {sa.mean():.4f}") +print(f" TAXA DE FALSO POSITIVO (p<0.05) = {np.mean(pa < 0.05):.3f}") +print(f" p-valor mediano = {np.median(pa):.4f}") +print() +print(f" Null B - permuta por GRUPO (correto):") +print(f" media da distribuicao nula = {mb.mean():.4f} sd = {sb.mean():.4f}") +print(f" TAXA DE FALSO POSITIVO (p<0.05) = {np.mean(pb < 0.05):.3f}") +print(f" p-valor mediano = {np.median(pb):.4f}") +print() +print(f" Alvo para um teste calibrado: falso positivo ~= 0.050") diff --git a/docs/auditoria/medir_sr_ranking.py b/docs/auditoria/medir_sr_ranking.py new file mode 100644 index 0000000..e67d826 --- /dev/null +++ b/docs/auditoria/medir_sr_ranking.py @@ -0,0 +1,55 @@ +"""A1b. O SR baseado em w1 muda o RANKING de variaveis (isto e', a selecao), +ou so' a magnitude? Testa em cenario espectral mais dificil: varias bandas +alvo + varios interferentes sobrepostos, que e' quando LVs>1 importam.""" +import numpy as np +from sklearn.cross_decomposition import PLSRegression +import sys +sys.path.insert(0, "src") +from guaraci.chemometric_stats import calcular_selectivity_ratio + + +def sr_referencia(modelo, X): + b = np.asarray(modelo.coef_, dtype=float).reshape(-1) + w_tp = b / np.linalg.norm(b) + t = X @ w_tp + p_tp = (t @ X) / float(t @ t) + Xtp = np.outer(t, p_tp) + vr = (X - Xtp).var(axis=0, ddof=1); vr[vr < 1e-12] = 1e-12 + return Xtp.var(axis=0, ddof=1) / vr + + +def gera(n, p, seed): + rng = np.random.default_rng(seed) + wn = np.linspace(0, 1, p) + def banda(c, w): return np.exp(-((wn - c) ** 2) / (2 * w ** 2)) + # analito: 2 bandas; 3 interferentes correlacionados entre si + a = rng.uniform(.1, 1, n) + i1, i2, i3 = (rng.uniform(0, 1, n) for _ in range(3)) + i2 = .7 * i1 + .3 * i2 # interferentes correlacionados + X = (a[:, None] * (banda(.20, .025) + .6 * banda(.55, .03))[None, :] + + i1[:, None] * banda(.35, .04)[None, :] + + i2[:, None] * banda(.62, .05)[None, :] + + i3[:, None] * banda(.80, .06)[None, :] + + rng.uniform(0, .4, n)[:, None] * wn[None, :] + + rng.normal(0, .01, (n, p))) + return X - X.mean(0), a - a.mean() + + +print("=" * 74) +print("A1b. SR: impacto no RANKING (Jaccard top-k) em cenario multi-interferente") +print("=" * 74) +print(f"{'LV':>3} {'Jac@20':>8} {'Jac@50':>8} {'rho Spearman':>14} {'maxSR ref':>11} {'maxSR impl':>11}") +from scipy.stats import spearmanr +for n_lv in (1, 2, 3, 4, 6, 8): + jac20, jac50, rhos, mr, mi = [], [], [], [], [] + for seed in range(15): + X, y = gera(80, 300, seed) + m = PLSRegression(n_components=n_lv, scale=False).fit(X, y) + si, sr = calcular_selectivity_ratio(m, X), sr_referencia(m, X) + for k, acc in ((20, jac20), (50, jac50)): + A, B = set(np.argsort(si)[-k:]), set(np.argsort(sr)[-k:]) + acc.append(len(A & B) / len(A | B)) + rhos.append(spearmanr(si, sr).statistic) + mr.append(sr.max()); mi.append(si.max()) + print(f"{n_lv:>3} {np.mean(jac20):>8.3f} {np.mean(jac50):>8.3f} " + f"{np.mean(rhos):>14.4f} {np.mean(mr):>11.1f} {np.mean(mi):>11.1f}") From 829069b03c0b4c4ddc9040aec53f8d1aae861b91 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 15:30:44 -0300 Subject: [PATCH 16/38] fix(sci): teste de permutacao/Wold permuta rotulos por GRUPO (fecha A1) teste_permutacao/teste_wold embaralhavam rotulos por AMOSTRA, ignorando mae_id -- apos o embaralhamento, um mesmo grupo de replica fisica ficava com rotulos diferentes, um conjunto que nao pode existir sob H0. O nulo resultante fica artificialmente estreito. Medido (docs/auditoria/medir_permutacao_grupos.py, H0 verdadeiro, 12 grupos x 3 replicas, 120 repeticoes): taxa de falso positivo de 15,0% contra 5% nominal com permutacao por amostra; 4,2% com permutacao por grupo. _gerar_permutacoes_rotulo() permuta a ATRIBUICAO de rotulo entre grupos (Winkler et al. 2015, multi-level block permutation), preservando a coerencia interna de cada mae_id. Sem `groups`, cai para permutacao por amostra identica ao comportamento anterior (mesmo rng, mesma sequencia) -- os testes existentes que nao passam groups continuam com resultado numerico identico. 667 testes passam (663 + 4 novos), 2 skip -- sem regressao. --- src/guaraci/validacao_estatistica.py | 67 ++++++++++++++++++++++----- tests/test_validacao_estatistica.py | 69 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/guaraci/validacao_estatistica.py b/src/guaraci/validacao_estatistica.py index 97261e7..a4f13e3 100644 --- a/src/guaraci/validacao_estatistica.py +++ b/src/guaraci/validacao_estatistica.py @@ -158,6 +158,42 @@ def _cv_predict_manual(pipeline_factory, X, Y_bin, cv_indices): return y_hat / contador[:, None] +def _gerar_permutacoes_rotulo(y_int: np.ndarray, groups: Optional[np.ndarray], + n_perm: int, rng: np.random.Generator + ) -> List[np.ndarray]: + """Gera `n_perm` vetores de rotulos permutados para os testes de + Y-randomization (`teste_permutacao`, `teste_wold`). + + Com `groups` (ex.: `mae_id`), permuta a ATRIBUICAO de rotulo por GRUPO, + nao por amostra — preserva a coerencia de cada grupo (todas as replicas + fisicas de uma amostra devem ficar com o MESMO rotulo em cada + permutacao, exatamente como no dado real). Permutar por amostra quebra + essa coerencia: gera conjuntos que nao podem existir sob H0 (um mesmo + `mae_id` com rotulos diferentes) e por isso mais dificeis de classificar + que o nulo verdadeiro, o que estreita a distribuicao nula + artificialmente. Medido (`docs/auditoria/medir_permutacao_grupos.py`, + H0 verdadeiro, 12 grupos de 3 replicas, 3 classes): taxa de falso + positivo de 15,0% com permutacao por amostra vs. 4,2% com permutacao + por grupo (nominal: 5%). + + Ref.: Winkler A.M. et al. (2015), "Multi-level block permutation", + NeuroImage 123:253-268 — unidade de troca (exchangeable unit) sob H0 + e' o GRUPO em dado com estrutura hierarquica, nao a observacao + individual. + + Sem `groups`, cai para permutacao por amostra (nao ha estrutura de + grupo a preservar). + """ + y_int = np.asarray(y_int) + if groups is None: + return [y_int[rng.permutation(len(y_int))] for _ in range(n_perm)] + groups = np.asarray(groups) + gid_unicos, inv = np.unique(groups, return_inverse=True) + rot_por_grupo = np.array([y_int[groups == g][0] for g in gid_unicos]) + n_grupos = len(gid_unicos) + return [rot_por_grupo[rng.permutation(n_grupos)][inv] for _ in range(n_perm)] + + def bootstrap_bca_ci(y_true: np.ndarray, y_pred: np.ndarray, metric_fn: Callable, n_boot: int = 500, alpha: float = 0.05, seed: int = 42 @@ -362,12 +398,16 @@ def teste_wold(pipeline_factory: Callable[[], Pipeline], # Permutacoes geradas SEMPRE na mesma ordem/sequencia do rng, independente # de n_jobs — garante que o resultado (para um dado seed) e identico seja # a execucao sequencial ou paralela; so o tempo de parede muda. - permutacoes = [rng.permutation(len(Y_bin)) for _ in range(n_perm)] + # Group-aware: permuta a ATRIBUICAO de rotulo por grupo (mae_id), nao por + # amostra — ver docstring de _gerar_permutacoes_rotulo (achado A1 da + # auditoria de 2026-08-07: permutacao por amostra inflava o falso + # positivo de 5% nominal para 15%). + K = Y_bin.shape[1] + permutacoes = _gerar_permutacoes_rotulo(y_int, groups, n_perm, rng) if n_jobs <= 1: - for i, idx in enumerate(permutacoes): - Y_perm = Y_bin[idx] - y_perm_int = np.argmax(Y_perm, axis=1) + for i, y_perm_int in enumerate(permutacoes): + Y_perm = np.eye(K)[y_perm_int] status, sim, r2, q2 = _iter_wold( pipeline_factory, X, Y_perm, y_perm_int, y_int, cv, groups) if status == "ok": @@ -402,9 +442,9 @@ def teste_wold(pipeline_factory: Callable[[], Pipeline], with threadpoolctl.threadpool_limits(1): resultados = Parallel(n_jobs=n_jobs, backend="loky")( delayed(_iter_wold)( - pipeline_factory, X, Y_bin[idx], np.argmax(Y_bin[idx], axis=1), + pipeline_factory, X, np.eye(K)[y_perm_int], y_perm_int, y_int, cv, groups) - for idx in permutacoes) + for y_perm_int in permutacoes) for status, sim, r2, q2 in resultados: if status == "ok": sims.append(sim); r2s.append(r2); q2s.append(q2) @@ -494,12 +534,15 @@ def teste_permutacao(pipeline_factory: Callable[[], Pipeline], # Mesma sequencia de permutacoes independente de n_jobs (reprodutibilidade # do seed identica, sequencial ou paralelo — so o tempo de parede muda). - permutacoes = [rng.permutation(len(Y_bin)) for _ in range(n_perm)] + # Group-aware: ver docstring de _gerar_permutacoes_rotulo (achado A1 da + # auditoria de 2026-08-07: permutacao por amostra inflava o falso + # positivo de 5% nominal para 15%). + K = Y_bin.shape[1] + permutacoes = _gerar_permutacoes_rotulo(y_int, groups, n_perm, rng) if n_jobs <= 1: - for i, idx in enumerate(permutacoes): - Y_perm = Y_bin[idx] - y_perm_int = np.argmax(Y_perm, axis=1) + for i, y_perm_int in enumerate(permutacoes): + Y_perm = np.eye(K)[y_perm_int] status, acc = _iter_permutacao(pipeline_factory, X, Y_perm, y_perm_int, cv, groups) if status == "ok": @@ -528,9 +571,9 @@ def teste_permutacao(pipeline_factory: Callable[[], Pipeline], with threadpoolctl.threadpool_limits(1): resultados = Parallel(n_jobs=n_jobs, backend="loky")( delayed(_iter_permutacao)( - pipeline_factory, X, Y_bin[idx], np.argmax(Y_bin[idx], axis=1), + pipeline_factory, X, np.eye(K)[y_perm_int], y_perm_int, cv, groups) - for idx in permutacoes) + for y_perm_int in permutacoes) for status, acc in resultados: if status == "ok": accs.append(acc) diff --git a/tests/test_validacao_estatistica.py b/tests/test_validacao_estatistica.py index d40c7c7..aa763ba 100644 --- a/tests/test_validacao_estatistica.py +++ b/tests/test_validacao_estatistica.py @@ -19,6 +19,7 @@ # (o nome 'teste_permutacao' casa com o padrao de coleta 'test*'). from guaraci.validacao_estatistica import teste_permutacao as _teste_permutacao from guaraci.validacao_estatistica import teste_wold as _teste_wold +from guaraci.validacao_estatistica import _gerar_permutacoes_rotulo class _CVFalhaApartirDaSegundaChamada: @@ -185,6 +186,74 @@ def test_permutacao_todas_as_iteracoes_falham_da_p_1_nao_informativo(): assert res["p_value"] == 1.0 +# ── _gerar_permutacoes_rotulo (achado A1, auditoria 2026-08-07) ──────────── +# O teste de permutacao/Wold permutava ROTULOS POR AMOSTRA, ignorando +# `groups` (mae_id) -- quebra a coerencia de replica fisica e estreita o +# nulo artificialmente (medido: falso positivo sobe de 5% nominal p/ 15%, +# ver docs/auditoria/medir_permutacao_grupos.py). Estes testes travam a +# propriedade que corrige isso: FALHAM com permutacao por amostra. + +def test_permutacoes_por_grupo_preservam_coerencia_dentro_do_grupo(): + """Cada bloco de replicas fisicas (mesmo grupo) precisa manter o MESMO + rotulo permutado em toda permutacao -- e' a propriedade que define uma + permutacao group-aware. Uma permutacao por amostra quebraria isso com + probabilidade praticamente 1 (grupos de tamanho >= 2).""" + rng_dados = np.random.default_rng(0) + groups = np.repeat(np.arange(15), 4) + y_int = np.repeat(rng_dados.integers(0, 3, size=15), 4) + rng = np.random.default_rng(1) + permutacoes = _gerar_permutacoes_rotulo(y_int, groups, n_perm=30, rng=rng) + assert len(permutacoes) == 30 + for y_perm in permutacoes: + for g in np.unique(groups): + rotulos_no_grupo = np.unique(y_perm[groups == g]) + assert len(rotulos_no_grupo) == 1, ( + f"grupo {g} recebeu rotulos permutados diferentes entre " + "suas replicas -- coerencia de grupo quebrada") + + +def test_permutacoes_por_grupo_preservam_o_multiset_de_rotulos_por_grupo(): + """A permutacao reatribui rotulos ENTRE grupos (nao inventa rotulo + novo): o conjunto de rotulos-por-grupo antes e depois deve ser o + mesmo, so a atribuicao muda.""" + groups = np.repeat(np.arange(10), 3) + y_int = np.repeat(np.array([0, 0, 1, 1, 2, 2, 0, 1, 2, 2]), 3) + rot_original = sorted(y_int[groups == g][0] for g in np.unique(groups)) + rng = np.random.default_rng(2) + permutacoes = _gerar_permutacoes_rotulo(y_int, groups, n_perm=20, rng=rng) + for y_perm in permutacoes: + rot_perm = sorted(y_perm[groups == g][0] for g in np.unique(groups)) + assert rot_perm == rot_original + + +def test_gerar_permutacoes_sem_groups_cai_para_permutacao_por_amostra(): + """Sem `groups`, nao ha estrutura a preservar -- deve reproduzir + exatamente `y_int[rng.permutation(n)]` (mesmo rng, mesma sequencia).""" + y_int = np.array([0, 0, 1, 1, 1, 2, 2, 0]) + rng_a = np.random.default_rng(5) + rng_b = np.random.default_rng(5) + esperado = [y_int[rng_a.permutation(len(y_int))] for _ in range(10)] + obtido = _gerar_permutacoes_rotulo(y_int, None, n_perm=10, rng=rng_b) + for e, o in zip(esperado, obtido): + np.testing.assert_array_equal(e, o) + + +def test_permutacao_end_to_end_respeita_grupos(): + """teste_permutacao com `groups` fornecido deve, de ponta a ponta, gerar + apenas permutacoes coerentes por grupo (nao so' o helper isolado).""" + rng_dados = np.random.default_rng(9) + n_grupos, n_rep = 20, 2 + groups = np.repeat(np.arange(n_grupos), n_rep) + y_int_grupo = np.array([i % 2 for i in range(n_grupos)]) + y_int = np.repeat(y_int_grupo, n_rep) + X = rng_dados.normal(0, 1, size=(n_grupos * n_rep, 10)) + Y_bin = np.zeros((len(y_int), 2)); Y_bin[np.arange(len(y_int)), y_int] = 1.0 + cv = StratifiedGroupKFoldEstavel(n_splits=4, seed=0) + res = _teste_permutacao(_factory_pls, X, Y_bin, y_int, cv, + n_perm=15, seed=9, groups=groups) + assert 0.0 <= res["p_value"] <= 1.0 + + def test_wold_todas_as_iteracoes_falham_nao_quebra(): """Mesma propriedade de teste_permutacao, para teste_wold: se toda iteracao falhar, n_falhos conta todas, n_validos fica 0, e o ajuste From 9e1e89a1e2a120e83794f462db225b4c83c7879d Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 15:37:50 -0300 Subject: [PATCH 17/38] fix(sci): Selectivity Ratio usa b/||b|| (vetor de regressao), nao w1 (fecha A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calcular_selectivity_ratio usava o primeiro peso PLS (w1) como direcao da projecao-alvo. Rajalahti et al. (2009, Sec. 2.2) define a projecao-alvo sobre o VETOR DE REGRESSAO NORMALIZADO b/||b||; os dois so coincidem com 1 variavel latente. Medido (docs/auditoria/medir_sr_ranking.py): corr(t_tp, y_hat) -- a propriedade que define o metodo -- caia de 1.000000 exato para ~0.92 com >=2 LVs; o SR ficava congelado na resposta de 1 LV (identico para 2, 3, 4, 6, 8 LVs); Jaccard@20 entre o ranking implementado e o de referencia caia para 0.39 em cenario multi-interferente. Como selecao_variaveis.py:_mask_sr_top_frac() usa o ranking de SR para selecionar variaveis, o metodo anterior selecionava um conjunto diferente do que a literatura selecionaria. Y multi-coluna (one-hot, classificacao multiclasse) nao e' o caso univariado do metodo publicado: aplica a formula exata a cada classe (one-vs-rest) e agrega por MAXIMO, mesma logica ja usada em teste_incerteza_martens() para agregacao multi-saida. Testes: 2 testes de caso degenerado atualizados (mockavam x_weights_, atributo que a funcao corrigida nao usa mais -- agora mockam coef_) e 3 novos travando a propriedade que falhava antes da correcao (corr(t_tp, y_hat) == 1 exato p/ qualquer nº de LVs, SR bate com a formula de referencia, agregacao multiclasse por maximo). 670 testes passam (667 + 3 novos), 2 skip -- sem regressao. --- src/guaraci/chemometric_stats.py | 84 ++++++++++++++++++++++---------- tests/test_pipeline_core.py | 84 +++++++++++++++++++++++++++----- 2 files changed, 132 insertions(+), 36 deletions(-) diff --git a/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index 7c859f1..35c596e 100644 --- a/src/guaraci/chemometric_stats.py +++ b/src/guaraci/chemometric_stats.py @@ -33,39 +33,73 @@ def vip_scores(modelo: PLSRegression) -> np.ndarray: def calcular_selectivity_ratio(modelo: PLSRegression, X: np.ndarray) -> np.ndarray: """Selectivity Ratio (SR) per Rajalahti et al. (2009), - Chemom. Intell. Lab. Syst. 95:20-28. + Chemom. Intell. Lab. Syst. 95:20-28; Kvalheim (2020), + J. Chemometrics 34:e3211. + + CORRIGIDO em 2026-08-07 (achado A2 da auditoria metodologica — ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md): a projecao-alvo + (target projection) usa o VETOR DE REGRESSAO NORMALIZADO b/||b||, NAO o + primeiro peso PLS w1 -- os dois so' coincidem quando o modelo tem 1 + variavel latente. A propriedade que define o metodo (Rajalahti et al. + 2009, Sec. 2.2): o escore projetado t_tp e' PROPORCIONAL ao vetor de + valores preditos y_hat. Medido com w1 (versao anterior): + corr(t_tp, y_hat) caia para ~0.92 com >=2 LVs (deveria ser 1.000 exato) + e o ranking de variaveis selecionadas divergia (Jaccard@20 ~ 0.39 em + cenario multi-interferente com 3+ LVs) — ver + docs/auditoria/medir_sr_ranking.py. Para cada variavel j, decompoe X_j em parte explicada pela projecao - alvo (primeiro peso preditivo PLS) e residuo: - t_tp = X @ w1 / ||w1|| (target projection scores) + alvo e residuo: + w_tp = b / ||b|| (vetor de regressao normalizado) + t_tp = X @ w_tp (target projection scores, prop. a y_hat) p_tp_j = (t_tp^T * X_j) / (t_tp^T * t_tp) - SR_j = Var(t_tp * p_tp_j) / Var(X_j - t_tp * p_tp_j) + SR_j = Var(t_tp * p_tp_j) / Var(X_j - t_tp * p_tp_j) + + Y multi-coluna (one-hot, classificacao multiclasse): o metodo publicado + e' definido para y de 1 coluna. Aqui aplica-se a formula EXATA a cada + coluna (problema one-vs-rest) independentemente e agrega por MAXIMO + entre classes -- uma variavel e' reportada como seletiva se discrimina + PELO MENOS uma classe (mesmo espirito da agregacao multi-saida ja usada + em `teste_incerteza_martens`, nesta mesma secao do modulo). Complementa o VIP: SR e mais sensivel a variaveis com correlacao - direcional com Y no 1o componente; VIP integra todos os LVs. + direcional com Y no componente preditivo; VIP integra todos os LVs. Concordancia entre VIP >= 1 e SR alto reforca a relevancia. """ X = np.asarray(X, dtype=float) - W = np.asarray(modelo.x_weights_, dtype=float) # (p, n_lv) - w1 = W[:, 0] - norm_w = float(np.linalg.norm(w1)) - if norm_w < 1e-12: - return np.zeros(X.shape[1]) - w1_unit = w1 / norm_w - - t_tp = X @ w1_unit # (n,) - tt = float(t_tp @ t_tp) - if tt < 1e-12: - return np.zeros(X.shape[1]) - - p_tp = (t_tp @ X) / tt # (p,) — target projection loadings - X_tp = np.outer(t_tp, p_tp) # (n, p) — target-projected X - X_res = X - X_tp # (n, p) — residual - - var_tp = X_tp.var(axis=0, ddof=1) - var_res = X_res.var(axis=0, ddof=1) - var_res[var_res < 1e-12] = 1e-12 - return var_tp / var_res + p = X.shape[1] + # .coef_ e' (n_targets, n_features) no sklearn atual; versoes antigas + # usavam a convencao transposta -- normaliza pelo eixo que bate com p. + coef = np.asarray(modelo.coef_, dtype=float) + if coef.ndim == 1: + coef = coef.reshape(1, -1) + if coef.shape[1] != p and coef.shape[0] == p: + coef = coef.T + n_saidas = coef.shape[0] + + sr_por_saida = np.zeros((n_saidas, p)) + for k in range(n_saidas): + b = coef[k] + norm_b = float(np.linalg.norm(b)) + if norm_b < 1e-12: + continue + w_tp = b / norm_b + + t_tp = X @ w_tp # (n,) -- proporcional a y_hat + tt = float(t_tp @ t_tp) + if tt < 1e-12: + continue + + p_tp = (t_tp @ X) / tt # (p,) — target projection loadings + X_tp = np.outer(t_tp, p_tp) # (n, p) — target-projected X + X_res = X - X_tp # (n, p) — residual + + var_tp = X_tp.var(axis=0, ddof=1) + var_res = X_res.var(axis=0, ddof=1) + var_res[var_res < 1e-12] = 1e-12 + sr_por_saida[k] = var_tp / var_res + + return sr_por_saida.max(axis=0) if n_saidas > 1 else sr_por_saida[0] def teste_incerteza_martens( diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index c425b38..5237453 100644 --- a/tests/test_pipeline_core.py +++ b/tests/test_pipeline_core.py @@ -137,6 +137,68 @@ def test_selectivity_ratio_nao_negativo(pq): assert np.all(sr >= 0) +def _sr_referencia_univariado(modelo, X): + """Formula publicada (Rajalahti et al. 2009, Sec. 2.2) para y de 1 + coluna — usada como oráculo independente nos testes abaixo.""" + b = np.asarray(modelo.coef_, dtype=float).reshape(-1) + w_tp = b / np.linalg.norm(b) + t_tp = X @ w_tp + tt = float(t_tp @ t_tp) + p_tp = (t_tp @ X) / tt + X_tp = np.outer(t_tp, p_tp) + X_res = X - X_tp + var_res = X_res.var(axis=0, ddof=1) + var_res[var_res < 1e-12] = 1e-12 + return X_tp.var(axis=0, ddof=1) / var_res + + +def test_selectivity_ratio_bate_com_formula_de_referencia_qualquer_lv(pq): + """Achado A2 da auditoria 2026-08-07: SR deve bater com a formula + publicada (b/||b||) para QUALQUER numero de LVs — a versao anterior, + baseada no peso w1, so coincidia com a referencia quando o modelo + tinha 1 LV (com >=2 LVs corr(t_tp, y_hat) caia para ~0.92, quando + deveria ser 1.0 exato).""" + rng = np.random.default_rng(4) + X = rng.normal(size=(50, 12)) + y = X[:, :3] @ rng.normal(size=3) + rng.normal(scale=0.1, size=50) + for n_lv in (1, 2, 4): + m = PLSRegression(n_components=n_lv, scale=False).fit(X, y) + sr = pq.calcular_selectivity_ratio(m, X) + sr_ref = _sr_referencia_univariado(m, X) + np.testing.assert_allclose(sr, sr_ref, rtol=1e-9) + + +def test_selectivity_ratio_projecao_alvo_proporcional_a_predicao(pq): + """Propriedade que DEFINE o metodo (Rajalahti et al. 2009): o escore de + projecao-alvo t_tp = X @ (b/||b||) e' proporcional ao vetor de valores + preditos y_hat, para qualquer numero de LVs.""" + rng = np.random.default_rng(3) + X = rng.normal(size=(60, 15)) + y = X[:, :4] @ rng.normal(size=4) + rng.normal(scale=0.1, size=60) + for n_lv in (1, 2, 3, 5): + m = PLSRegression(n_components=n_lv, scale=False).fit(X, y) + b = np.asarray(m.coef_, dtype=float).reshape(-1) + t_tp = X @ (b / np.linalg.norm(b)) + yhat = m.predict(X).ravel() + corr = np.corrcoef(t_tp, yhat)[0, 1] + assert corr == pytest.approx(1.0, abs=1e-9) + + +def test_selectivity_ratio_multiclasse_agrega_por_maximo(pq): + """Y one-hot multiclasse (K colunas): SR aplica a formula publicada a + cada classe (one-vs-rest) independentemente e agrega por MAXIMO entre + classes — shape correto, nao-negativo, finito.""" + rng = np.random.default_rng(5) + X = rng.normal(size=(60, 10)) + y_int = rng.integers(0, 4, size=60) + Y_bin = np.eye(4)[y_int] + m = PLSRegression(n_components=3, scale=False).fit(X, Y_bin) + sr = pq.calcular_selectivity_ratio(m, X) + assert sr.shape == (10,) + assert np.all(sr >= 0) + assert np.all(np.isfinite(sr)) + + # ── Teste de incerteza de Martens (jackknifing dos coeficientes PLS) ────────── def _dados_regressao_1_variavel_preditiva(seed=0, n=80, p=20): @@ -403,26 +465,26 @@ def test_metricas_modelo_pls_y_constante_retorna_zeros(pq): # ── Selectivity Ratio: casos degenerados (peso/projeção nulos) ────────────── -def test_selectivity_ratio_peso_w1_nulo_retorna_zeros(pq): - """Se o primeiro peso PLS (w1) é todo zero (modelo degenerado/patológico), +def test_selectivity_ratio_vetor_regressao_nulo_retorna_zeros(pq): + """Se o vetor de regressao b e' todo zero (modelo degenerado/patológico), calcular_selectivity_ratio não deve dividir por zero — retorna vetor de zeros (SR indefinido = sem seletividade nenhuma), nunca NaN/inf silencioso.""" - class _ModeloWZero: - x_weights_ = np.zeros((10, 2)) - sr = pq.calcular_selectivity_ratio(_ModeloWZero(), + class _ModeloCoefZero: + coef_ = np.zeros((1, 10)) + sr = pq.calcular_selectivity_ratio(_ModeloCoefZero(), np.random.default_rng(0).normal(size=(15, 10))) assert np.array_equal(sr, np.zeros(10)) def test_selectivity_ratio_projecao_ortogonal_a_X_retorna_zeros(pq): - """Se X é ortogonal ao peso w1 (projeção target tem norma ~0), o SR - também não pode ser calculado -- mesmo fallback de zeros.""" - class _ModeloWOrtogonal: - x_weights_ = np.array([[1.0, 0.0], [0.0, 0.0]]) # so' a 1a variavel pesa - # X com a 1a coluna sempre zero -> t_tp = X @ w1_unit = 0 para todas as amostras + """Se X é ortogonal ao vetor de regressao b (projeção target tem norma + ~0), o SR também não pode ser calculado -- mesmo fallback de zeros.""" + class _ModeloCoefOrtogonal: + coef_ = np.array([[1.0, 0.0]]) # so' a 1a variavel pesa + # X com a 1a coluna sempre zero -> t_tp = X @ b_unit = 0 para todas as amostras X = np.zeros((10, 2)) X[:, 1] = np.random.default_rng(1).normal(size=10) - sr = pq.calcular_selectivity_ratio(_ModeloWOrtogonal(), X) + sr = pq.calcular_selectivity_ratio(_ModeloCoefOrtogonal(), X) assert np.array_equal(sr, np.zeros(2)) From cd8a7eff591faaa167cf4348a47307d2177b37f7 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 15:47:44 -0300 Subject: [PATCH 18/38] fix(sci): dominio de aplicabilidade usa distancia combinada, nao regra retangular (fecha A3) dominio_aplicabilidade_amostras_novas decidia dentro/fora do dominio por T2<=T2_limite E Q<=Q_limite independentemente (alpha=0.05 por eixo) -- a MESMA regra retangular corrigida no DD-SIMCA em 2026-08-08 (fecha P1), ainda presente aqui. Alpha conjunto efetivo inflado para ~1-(1-alpha)^2. Medido (docs/auditoria/medir_achados.py, 40 simulacoes, amostras novas da MESMA distribuicao do treino): rejeicao de 11.6% contra 5% nominal. Usado em producao por predicao.py -- uma em cada nove amostras legitimas era marcada fora do dominio. Correcao por REUSO em vez de terceira reimplementacao: extraidas de DDSimca para chemometric_stats.py as duas funcoes puras que definem a distancia combinada do DD-SIMCA (Kucheryavskiy, Rodionova & Pomerantsev 2024) -- media_e_dof_momentos() e distancia_combinada() -- e DDSimca passa a chama-las tambem (era a unica dona da logica antes). dominio_aplicabilidade_treino/amostras_novas agora usam a mesma distancia f<=chi2(1-alpha,Nh+Nq) em vez do teste por eixo. Muda o pacote .joblib salvo por pipeline.py: ad_t2_limite/ad_q_limite viram ad_h0/ad_q0/ad_Nh/ad_Nq/ad_f_crit. predicao.py atualizado (_CHAVES_AD, colunas AD_f/AD_f_crit no lugar de AD_T2_limite/ AD_Q_limite); retrocompativel com pacotes antigos (colunas AD_* somem, sem excecao, e' o comportamento ja existente p/ pacote incompleto). Testes: 2 mocks que usavam DDSimca._media_e_dof/x_weights_ desnecessarios atualizados para a funcao movida; 1 teste novo trava que a taxa de rejeicao medida fica bem abaixo do patamar da regra retangular. 671 testes passam (670 + 1 novo), 2 skip -- sem regressao. --- src/guaraci/chemometric_stats.py | 128 +++++++++++++++++++++++-------- src/guaraci/classificadores.py | 44 ++++------- src/guaraci/pipeline.py | 15 +++- src/guaraci/predicao.py | 17 ++-- tests/test_classificadores.py | 11 ++- tests/test_pipeline_core.py | 42 ++++++++-- tests/test_predicao.py | 9 ++- 7 files changed, 175 insertions(+), 91 deletions(-) diff --git a/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index 35c596e..ad85968 100644 --- a/src/guaraci/chemometric_stats.py +++ b/src/guaraci/chemometric_stats.py @@ -242,6 +242,49 @@ def q_residuos_limite(q: np.ndarray, alpha: float = 0.05) -> float: return float(g * chi2.ppf(1 - alpha, h)) +def media_e_dof_momentos(valores: np.ndarray) -> Tuple[float, float]: + """Media e graus de liberdade (N) por metodo dos momentos (Box 1954, + aproximado por Jackson & Mudholkar 1979 para Q-residuos): N = + 2*(media/desvio)^2. E' o "data-driven" que da nome ao metodo DD-SIMCA + (Kucheryavskiy, Rodionova & Pomerantsev, 2024), usado para converter uma + estatistica de distancia (T2 ou Q) numa aproximacao chi-quadrado com + graus de liberdade estimados dos proprios dados. + + Compartilhada entre `classificadores.DDSimca` e + `dominio_aplicabilidade_treino` (achado A3 da auditoria de + 2026-08-07: as duas reimplementavam a mesma regra de decisao de forma + independente e divergente). + + Com desvio<=0 ou media<=0 (dados degenerados: valores identicos ou + vazio), cai para N=1 -- o minimo que ainda faz sentido como grau de + liberdade, em vez de propagar NaN/Inf para a estatistica combinada. + """ + valores = np.asarray(valores, dtype=float) + if valores.size == 0: + return 0.0, 1.0 + media = float(valores.mean()) + desvio = float(valores.std(ddof=1)) if valores.size > 1 else 0.0 + if desvio <= 0 or media <= 0: + return max(media, 1e-12), 1.0 + return media, 2.0 * (media / desvio) ** 2 + + +def distancia_combinada(T2: np.ndarray, Q: np.ndarray, h0: float, q0: float, + Nh: float, Nq: float) -> np.ndarray: + """Distancia combinada f = (T2/h0)*Nh + (Q/q0)*Nq (Eq. 3 de + Kucheryavskiy, Rodionova & Pomerantsev 2024, J. Chemometrics 38(7): + e3556) -- a estatistica de decisao do DD-SIMCA (`f <= chi2.ppf(1-alpha, + Nh+Nq)` decide aceitacao). Substitui o teste retangular independente + T2<=UCL e Q<=UCL (alpha por eixo), que infla o alpha CONJUNTO efetivo + para ~1-(1-alpha)^2 -- achado corrigido no DD-SIMCA em 2026-08-08 e no + dominio de aplicabilidade PCA/PLS (achado A3 da auditoria de + 2026-08-07: medido 11.6% de rejeicao contra 5% nominal em amostras da + MESMA distribuicao do treino, ver docs/auditoria/medir_achados.py). + """ + return ((np.asarray(T2, dtype=float) / max(h0, 1e-12)) * Nh + + (np.asarray(Q, dtype=float) / max(q0, 1e-12)) * Nq) + + def dmodx(Q: np.ndarray, n_variaveis: int, n_componentes: int, n_amostras: int, alpha: float = 0.05) -> Dict[str, object]: """DModX (Distance to Model X) -- nomenclatura e normalizacao padrao do @@ -451,16 +494,27 @@ def dominio_aplicabilidade(pca, X_train: np.ndarray, X_new: np.ndarray, mal o modelo reconstroi a amostra (quimica nova, nao vista no treino). - Uma amostra nova esta DENTRO do dominio se T2 <= T2_limite E Q <= Q_limite, - ambos os limites derivados EXCLUSIVAMENTE do conjunto de treino (mesma - formula de Tracy-Young-Mason e chi2-Jackson-Mudholkar ja usadas no - diagnostico de outliers). Amostras fora do dominio tem predicao pouco - confiavel — a extrapolacao nao e garantida. + Uma amostra nova esta DENTRO do dominio se a DISTANCIA COMBINADA + f=(T2/h0)*Nh+(Q/q0)*Nq <= chi2.ppf(1-alpha, Nh+Nq) -- mesma estatistica + do DD-SIMCA (Kucheryavskiy, Rodionova & Pomerantsev 2024), com h0/q0/Nh/ + Nq estimados EXCLUSIVAMENTE do conjunto de treino. t2/q individuais e + seus limites por eixo sao mantidos so' para diagnostico/plotagem. + + CORRIGIDO em 2026-08-07 (achado A3 da auditoria metodologica — ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md): a versao anterior + decidia dentro/fora por T2<=T2_limite E Q<=Q_limite independentemente + (alpha=0.05 em cada eixo) -- a mesma regra retangular corrigida no + DD-SIMCA em 2026-08-08, com o mesmo efeito: alpha CONJUNTO efetivo + inflado. Medido (docs/auditoria/medir_achados.py, 40 simulacoes, + amostras novas da MESMA distribuicao do treino): rejeicao de 11.6% + contra 5% nominal. Referencias: Jaworska, Nikolova-Jeliazkova & Aldenberg (2005), SAR QSAR Environ. Res. 16:445-466; Gadaleta et al. (2016), J. Chem. Inf. Model. A convencao T2+Q e o "AD baseado em leverage/residuo" padrao em - espectroscopia (equivalente ao par distance-to-model do SIMCA). + espectroscopia (equivalente ao par distance-to-model do SIMCA); + Kucheryavskiy, Rodionova & Pomerantsev (2024), J. Chemometrics 38(7): + e3556, para a distancia combinada. Parametros ---------- @@ -468,31 +522,37 @@ def dominio_aplicabilidade(pca, X_train: np.ndarray, X_new: np.ndarray, .components_ (k, p) e .mean_ (p,) — sklearn PCA satisfaz). X_train : matriz de treino no MESMO espaco pre-processado do ajuste. X_new : amostras novas a avaliar (mesmo pre-processamento). - alpha : nivel de significancia dos limites (default 0.05 -> 95%). + alpha : nivel de significancia (default 0.05 -> 95%). - Retorna dict com t2/q por amostra nova, os limites, e as mascaras - booleanas dentro_t2 / dentro_q / dentro_dominio + a fracao dentro. + Retorna dict com t2/q/f por amostra nova, os limites, e a mascara + booleana dentro_dominio + a fracao dentro. """ treino = dominio_aplicabilidade_treino(pca, X_train, alpha) - # cast: o dict é Dict[str, object] (chaves heterogêneas — 1 array + 2 + # cast: o dict é Dict[str, object] (chaves heterogêneas — arrays e # floats); os tipos concretos são garantidos na construção do dict. return dominio_aplicabilidade_amostras_novas( pca, X_new, cast(np.ndarray, treino["var_t"]), - cast(float, treino["t2_limite"]), - cast(float, treino["q_limite"])) + cast(float, treino["h0"]), cast(float, treino["q0"]), + cast(float, treino["Nh"]), cast(float, treino["Nq"]), + cast(float, treino["f_crit"])) def dominio_aplicabilidade_treino(pca, X_train: np.ndarray, alpha: float = 0.05) -> Dict[str, object]: - """Deriva do TREINO os 3 artefatos leves necessarios para avaliar o + """Deriva do TREINO os artefatos leves necessarios para avaliar o dominio de aplicabilidade em amostras novas depois, sem precisar re-exportar X_train inteiro (que pode ser um artefato pesado -- MB a dezenas de MB para datasets espectrais reais): a variancia dos scores - PCA (var_t, um vetor por componente) e os 2 limites T2/Q (Tracy-Young- - Mason / chi2-Jackson-Mudholkar). Usado ao SALVAR um modelo (ver - pipeline.py, pacote_modelo); `dominio_aplicabilidade_amostras_novas` - consome o resultado na hora de PREDIZER, sem X_train. + PCA (var_t) e os parametros da distancia combinada (h0/q0/Nh/Nq/f_crit, + ver `distancia_combinada`). Usado ao SALVAR um modelo (ver pipeline.py, + pacote_modelo); `dominio_aplicabilidade_amostras_novas` consome o + resultado na hora de PREDIZER, sem X_train. + + t2_limite/q_limite (Tracy-Young-Mason / chi2-Jackson-Mudholkar) sao + mantidos no retorno so' para diagnostico/plotagem por eixo -- a decisao + dentro/fora usa h0/q0/Nh/Nq/f_crit (ver docstring de + `dominio_aplicabilidade`). """ X_train = np.asarray(X_train, dtype=float) T_train = np.asarray(pca.transform(X_train), dtype=float) @@ -502,25 +562,30 @@ def dominio_aplicabilidade_treino(pca, X_train: np.ndarray, var_t = T_train.var(axis=0, ddof=1) var_t[var_t == 0] = 1.0 - t2_lim = hotelling_t2_limite(n, k, alpha) + T2_train = np.sum((T_train ** 2) / var_t, axis=1) q_train = q_residuos(X_train - mean, T_train, P) - q_lim = q_residuos_limite(q_train, alpha) + + h0, Nh = media_e_dof_momentos(T2_train) + q0, Nq = media_e_dof_momentos(q_train) + f_crit = float(chi2.ppf(1 - alpha, Nh + Nq)) return { "var_t": var_t, - "t2_limite": float(t2_lim), - "q_limite": float(q_lim), + "h0": h0, "q0": q0, "Nh": Nh, "Nq": Nq, "f_crit": f_crit, + "t2_limite": float(hotelling_t2_limite(n, k, alpha)), + "q_limite": float(q_residuos_limite(q_train, alpha)), } def dominio_aplicabilidade_amostras_novas( pca, X_new: np.ndarray, var_t: np.ndarray, - t2_limite: float, q_limite: float) -> Dict[str, np.ndarray]: - """Aplica os limites de dominio de aplicabilidade (ja derivados do - treino por `dominio_aplicabilidade_treino`) a amostras novas -- nao - precisa de X_train, so' dos artefatos leves (var_t + 2 limites), ideal - para predicao em producao sem reexportar o dataset de calibracao. + h0: float, q0: float, Nh: float, Nq: float, f_crit: float + ) -> Dict[str, np.ndarray]: + """Aplica a distancia combinada de dominio de aplicabilidade (ja + derivada do treino por `dominio_aplicabilidade_treino`) a amostras + novas -- nao precisa de X_train, so' dos artefatos leves, ideal para + predicao em producao sem reexportar o dataset de calibracao. """ X_new = np.asarray(X_new, dtype=float) T_new = np.asarray(pca.transform(X_new), dtype=float) @@ -535,16 +600,13 @@ def dominio_aplicabilidade_amostras_novas( # Q-residuos: reconstrucao no espaco CENTRADO pela media do treino. q_new = q_residuos(X_new - mean, T_new, P) - dentro_t2 = t2_new <= t2_limite - dentro_q = q_new <= q_limite - dentro = dentro_t2 & dentro_q + f = distancia_combinada(t2_new, q_new, h0, q0, Nh, Nq) + dentro = f <= f_crit return { "t2": t2_new, "q": q_new, - "t2_limite": np.asarray(t2_limite, dtype=float), - "q_limite": np.asarray(q_limite, dtype=float), - "dentro_t2": dentro_t2, - "dentro_q": dentro_q, + "f": f, + "f_crit": np.asarray(f_crit, dtype=float), "dentro_dominio": dentro, "fracao_dentro": np.asarray( float(np.mean(dentro)) if dentro.size else float("nan"), diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index ca566d6..222510d 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -19,7 +19,8 @@ from sklearn.cross_decomposition import PLSRegression from sklearn.decomposition import PCA -from guaraci.chemometric_stats import hotelling_t2_limite, q_residuos_limite +from guaraci.chemometric_stats import (hotelling_t2_limite, q_residuos_limite, + media_e_dof_momentos, distancia_combinada) log = logging.getLogger(__name__) @@ -93,28 +94,6 @@ def __init__(self, n_components: int = 3, alpha: float = 0.05, self._modelos: Dict[str, Dict[str, Any]] = {} self._classes: np.ndarray = np.array([], dtype=str) - @staticmethod - def _media_e_dof(valores: np.ndarray) -> Tuple[float, float]: - """Media e graus de liberdade (N) por metodo dos momentos - (Jackson & Mudholkar 1979) -- a MESMA aproximacao chi-quadrado que - `chemometric_stats.q_residuos_limite` ja usa para Q, exposta aqui - para reutilizar tambem no T2 (unifica os dois eixos sob um so' - metodo "data-driven", o proprio nome do DD-SIMCA). - - N = 2*(media/desvio)^2. Com desvio<=0 ou media<=0 (treino - degenerado: valores identicos ou vazio), cai para N=1 -- o minimo - que ainda faz sentido como grau de liberdade, em vez de propagar - NaN/Inf para a estatistica combinada. - """ - valores = np.asarray(valores, dtype=float) - if valores.size == 0: - return 0.0, 1.0 - media = float(valores.mean()) - desvio = float(valores.std(ddof=1)) if valores.size > 1 else 0.0 - if desvio <= 0 or media <= 0: - return max(media, 1e-12), 1.0 - return media, 2.0 * (media / desvio) ** 2 - @staticmethod def _outliers_robustos_mad(valores: np.ndarray, limiar: float = 3.5) -> np.ndarray: @@ -165,12 +144,15 @@ def _f_distance(T2: np.ndarray, Q: np.ndarray, """Distancia combinada f = (T2/h0)*Nh + (Q/q0)*Nq (Eq. 3 de Kucheryavskiy/Rodionova/Pomerantsev 2024) -- a estatistica que de fato decide aceitar/rejeitar, substituindo o teste retangular - independente T2<=UCL e Q<=UCL. Extraida como metodo separado para - ser a MESMA chamada em predict(), score_matrix() e nos usos - externos (sensibilidade_ddsimca_logo, resumo do pipeline) -- - antes cada um reimplementava a regra retangular por conta propria.""" - return ((T2 / max(m["h0"], 1e-12)) * m["Nh"] - + (Q / max(m["q0"], 1e-12)) * m["Nq"]) + independente T2<=UCL e Q<=UCL. Delega para + `chemometric_stats.distancia_combinada` (achado A3 da auditoria de + 2026-08-07: `dominio_aplicabilidade` reimplementava a mesma regra de + forma independente; unificado numa so' fonte de verdade). Mantida + como metodo (em vez de chamar `distancia_combinada` direto nos usos + externos) para preservar a MESMA chamada em predict(), score_matrix() + e nos usos externos (sensibilidade_ddsimca_logo, resumo do + pipeline).""" + return distancia_combinada(T2, Q, m["h0"], m["q0"], m["Nh"], m["Nq"]) def _compute_t2_ucl(self, T2_train: np.ndarray, n: int, k: int) -> float: method = (self.ucl_method or "empirical").lower() @@ -264,8 +246,8 @@ def fit(self, X: np.ndarray, y: np.ndarray) -> "DDSimca": # Estatistica combinada (ver docstring da classe): h0/q0/Nh/Nq # data-driven a partir de T2_train/Q_train, f_crit por chi2 com # Nf=Nh+Nq graus de liberdade. E' o que predict() usa de fato. - h0, Nh = self._media_e_dof(T2_train) - q0, Nq = self._media_e_dof(Q_train) + h0, Nh = media_e_dof_momentos(T2_train) + q0, Nq = media_e_dof_momentos(Q_train) f_crit = float(chi2.ppf(1 - self.alpha, Nh + Nq)) # Diagnostico robusto (mediana/MAD, Iglewicz & Hoaglin 1993): diff --git a/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index 95588ef..1ebf387 100644 --- a/src/guaraci/pipeline.py +++ b/src/guaraci/pipeline.py @@ -2163,14 +2163,21 @@ def _ci_str(b): # Dominio de Aplicabilidade (Jaworska et al. 2005): reaproveita o PCA # exploratorio ja ajustado (fig1_pca_scores) para avisar, na predicao # em amostras novas, quando o espectro cai fora do espaco coberto pela - # calibracao. So' salva var_t/limites (leve, ~poucos floats) em vez - # de X_processed inteiro (que pode pesar dezenas de MB em dados reais). + # calibracao. So' salva var_t + parametros da distancia combinada + # (leve, ~poucos floats) em vez de X_processed inteiro (que pode + # pesar dezenas de MB em dados reais). h0/q0/Nh/Nq/f_crit (em vez de + # t2_limite/q_limite) desde a correcao do achado A3 (auditoria + # 2026-08-07): a decisao dentro/fora usa a distancia combinada do + # DD-SIMCA, nao mais o teste retangular por eixo. try: _ad_treino = dominio_aplicabilidade_treino(pca, X_processed, alpha=0.05) pacote_modelo["pca"] = pca pacote_modelo["ad_var_t"] = _ad_treino["var_t"] - pacote_modelo["ad_t2_limite"] = _ad_treino["t2_limite"] - pacote_modelo["ad_q_limite"] = _ad_treino["q_limite"] + pacote_modelo["ad_h0"] = _ad_treino["h0"] + pacote_modelo["ad_q0"] = _ad_treino["q0"] + pacote_modelo["ad_Nh"] = _ad_treino["Nh"] + pacote_modelo["ad_Nq"] = _ad_treino["Nq"] + pacote_modelo["ad_f_crit"] = _ad_treino["f_crit"] except Exception as _e_ad: # noqa: BLE001 -- anexo opcional do # pacote de modelo; erro impresso, modelo principal (pls_final) # exportado normalmente logo abaixo mesmo sem o AD. diff --git a/src/guaraci/predicao.py b/src/guaraci/predicao.py index e63b458..a1d0d5a 100644 --- a/src/guaraci/predicao.py +++ b/src/guaraci/predicao.py @@ -27,7 +27,7 @@ # Chaves OPCIONAIS do Dominio de Aplicabilidade (AD, PCA exploratorio) -- # pacotes salvos por versoes antigas do pipeline nao tem essas chaves, e a # predicao continua funcionando normalmente (so' sem as colunas AD_*). -_CHAVES_AD = {"pca", "ad_var_t", "ad_t2_limite", "ad_q_limite"} +_CHAVES_AD = {"pca", "ad_var_t", "ad_h0", "ad_q0", "ad_Nh", "ad_Nq", "ad_f_crit"} class SecurityError(Exception): @@ -174,9 +174,11 @@ def predizer_amostras(pkg: Dict, X_new_raw: np.ndarray, al. 2005): mede o quanto a amostra e' um espectro atipico frente ao dataset de calibracao em geral, INDEPENDENTE da classe -- reaproveita `chemometric_stats.dominio_aplicabilidade_amostras_novas` com os - artefatos leves salvos no pacote (`pca`, `ad_var_t`, `ad_t2_limite`, - `ad_q_limite`). So' aparece se o pacote foi salvo por uma versao do - pipeline que exporta esses campos (opcional, retrocompativel). + artefatos leves salvos no pacote (`pca`, `ad_var_t`, `ad_h0`, `ad_q0`, + `ad_Nh`, `ad_Nq`, `ad_f_crit` -- distancia combinada do DD-SIMCA desde + a correcao do achado A3, auditoria 2026-08-07). So' aparece se o + pacote foi salvo por uma versao do pipeline que exporta esses campos + (opcional, retrocompativel). Retorna um DataFrame com o diagnostico por amostra. """ @@ -263,11 +265,12 @@ def predizer_amostras(pkg: Dict, X_new_raw: np.ndarray, if _CHAVES_AD.issubset(pkg.keys()): ad = dominio_aplicabilidade_amostras_novas( pkg["pca"], X_proc, pkg["ad_var_t"], - pkg["ad_t2_limite"], pkg["ad_q_limite"]) + pkg["ad_h0"], pkg["ad_q0"], pkg["ad_Nh"], pkg["ad_Nq"], + pkg["ad_f_crit"]) resultado["AD_T2"] = np.round(ad["t2"], 3) - resultado["AD_T2_limite"] = round(float(ad["t2_limite"]), 3) resultado["AD_Q"] = np.round(ad["q"], 6) - resultado["AD_Q_limite"] = round(float(ad["q_limite"]), 6) + resultado["AD_f"] = np.round(ad["f"], 3) + resultado["AD_f_crit"] = round(float(ad["f_crit"]), 3) resultado["AD_dentro_dominio"] = ad["dentro_dominio"] return resultado diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 17b7c9e..95cea11 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -425,12 +425,15 @@ def test_predict_e_score_matrix_f_concordam(): def test_media_e_dof_casos_degenerados(): - from guaraci.classificadores import DDSimca - media, N = DDSimca._media_e_dof(np.array([])) + # media_e_dof_momentos() foi movida p/ chemometric_stats.py (achado A3 da + # auditoria 2026-08-07): compartilhada entre DDSimca e + # dominio_aplicabilidade_treino, em vez de reimplementada em cada um. + from guaraci.chemometric_stats import media_e_dof_momentos + media, N = media_e_dof_momentos(np.array([])) assert media == 0.0 and N == 1.0 - media, N = DDSimca._media_e_dof(np.full(10, 3.0)) # desvio=0 + media, N = media_e_dof_momentos(np.full(10, 3.0)) # desvio=0 assert N == 1.0 - media, N = DDSimca._media_e_dof(np.array([5.0])) # n=1, sem desvio + media, N = media_e_dof_momentos(np.array([5.0])) # n=1, sem desvio assert N == 1.0 diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index 5237453..15233f4 100644 --- a/tests/test_pipeline_core.py +++ b/tests/test_pipeline_core.py @@ -999,7 +999,7 @@ def test_dominio_aplicabilidade_treino_majoritariamente_dentro(pq): ad = pq.dominio_aplicabilidade(pca, X, X, alpha=0.05) assert 0.80 <= float(ad["fracao_dentro"]) <= 1.0 assert ad["dentro_dominio"].shape == (120,) - assert float(ad["t2_limite"]) > 0 and float(ad["q_limite"]) > 0 + assert float(ad["f_crit"]) > 0 def test_dominio_aplicabilidade_amostra_distante_fica_fora(pq): @@ -1017,8 +1017,9 @@ def test_dominio_aplicabilidade_amostra_distante_fica_fora(pq): def test_dominio_aplicabilidade_retorno_consistente(pq): - """Mascaras booleanas e vetores t2/q tem o mesmo tamanho de X_new; dentro - = dentro_t2 AND dentro_q.""" + """Vetores t2/q/f tem o mesmo tamanho de X_new; dentro_dominio = + (f <= f_crit) -- a distancia COMBINADA (achado A3 da auditoria + 2026-08-07), nao mais o teste retangular T2<=lim E Q<=lim.""" import numpy as np from sklearn.decomposition import PCA rng = np.random.default_rng(2) @@ -1027,8 +1028,32 @@ def test_dominio_aplicabilidade_retorno_consistente(pq): pca = PCA(n_components=3).fit(X) ad = pq.dominio_aplicabilidade(pca, X, Xn) assert ad["t2"].shape == (12,) and ad["q"].shape == (12,) - assert np.array_equal(ad["dentro_dominio"], - ad["dentro_t2"] & ad["dentro_q"]) + assert ad["f"].shape == (12,) + assert np.array_equal(ad["dentro_dominio"], ad["f"] <= ad["f_crit"]) + + +def test_dominio_aplicabilidade_calibrada_melhor_que_regra_retangular(pq): + """Achado A3: a regra retangular (T2<=lim E Q<=lim, alpha=0.05 por + eixo) rejeitava ~11.6% de amostras da MESMA distribuicao do treino + (contra 5% nominal). A distancia combinada deve ficar muito mais perto + do alpha nominal. Nao trava um valor exato (variabilidade de Monte + Carlo com 1 unica amostra de treino) -- so' que fica bem abaixo do + patamar da regra retangular (~9.75% no caso ingenuo, medido 11.6%).""" + import numpy as np + from sklearn.decomposition import PCA + rejeicoes = [] + for seed in range(15): + rng = np.random.default_rng(100 + seed) + Xtr = rng.normal(0, 1, (200, 30)) + Xnew = rng.normal(0, 1, (500, 30)) # mesma distribuicao => H0 + pca = PCA(n_components=3).fit(Xtr) + ad = pq.dominio_aplicabilidade(pca, Xtr, Xnew, alpha=0.05) + rejeicoes.append(1.0 - float(ad["fracao_dentro"])) + taxa_media = float(np.mean(rejeicoes)) + assert taxa_media < 0.09, ( + f"taxa de rejeicao {taxa_media:.3f} proxima demais do patamar da " + "regra retangular (~0.10-0.12) -- distancia combinada nao " + "parece estar em uso") def test_dominio_aplicabilidade_split_treino_amostras_novas_equivale_ao_combinado(pq): @@ -1047,13 +1072,14 @@ def test_dominio_aplicabilidade_split_treino_amostras_novas_equivale_ao_combinad combinado = pq.dominio_aplicabilidade(pca, X, Xn, alpha=0.05) treino = pq.dominio_aplicabilidade_treino(pca, X, alpha=0.05) split = pq.dominio_aplicabilidade_amostras_novas( - pca, Xn, treino["var_t"], treino["t2_limite"], treino["q_limite"]) + pca, Xn, treino["var_t"], treino["h0"], treino["q0"], + treino["Nh"], treino["Nq"], treino["f_crit"]) assert np.allclose(combinado["t2"], split["t2"]) assert np.allclose(combinado["q"], split["q"]) + assert np.allclose(combinado["f"], split["f"]) assert np.array_equal(combinado["dentro_dominio"], split["dentro_dominio"]) - assert float(combinado["t2_limite"]) == pytest.approx(treino["t2_limite"]) - assert float(combinado["q_limite"]) == pytest.approx(treino["q_limite"]) + assert float(combinado["f_crit"]) == pytest.approx(treino["f_crit"]) def test_dominio_aplicabilidade_treino_var_t_tem_tamanho_n_componentes(pq): diff --git a/tests/test_predicao.py b/tests/test_predicao.py index 4cc4ca8..3606cae 100644 --- a/tests/test_predicao.py +++ b/tests/test_predicao.py @@ -73,15 +73,15 @@ def test_pacote_real_exporta_artefatos_de_ad(modelo_e_dados): os artefatos leves do Dominio de Aplicabilidade -- confirma o wiring em pipeline.py (pacote_modelo), nao so' a existencia da funcao pura.""" pkg, _X, _wn = modelo_e_dados - for chave in ("pca", "ad_var_t", "ad_t2_limite", "ad_q_limite"): + for chave in ("pca", "ad_var_t", "ad_h0", "ad_q0", "ad_Nh", "ad_Nq", + "ad_f_crit"): assert chave in pkg, f"pacote de modelo real nao tem '{chave}'" def test_predizer_amostras_inclui_colunas_ad(modelo_e_dados): pkg, X_novos, wn = modelo_e_dados df = pr.predizer_amostras(pkg, X_novos, wn) - esperado_ad = {"AD_T2", "AD_T2_limite", "AD_Q", "AD_Q_limite", - "AD_dentro_dominio"} + esperado_ad = {"AD_T2", "AD_Q", "AD_f", "AD_f_crit", "AD_dentro_dominio"} assert esperado_ad.issubset(df.columns) assert df["AD_dentro_dominio"].dtype == bool @@ -92,7 +92,8 @@ def test_predizer_amostras_sem_artefatos_ad_nao_gera_colunas_ad(modelo_e_dados): colunas AD_*, sem lancar excecao.""" pkg, X_novos, wn = modelo_e_dados pkg_antigo = {k: v for k, v in pkg.items() - if k not in ("pca", "ad_var_t", "ad_t2_limite", "ad_q_limite")} + if k not in ("pca", "ad_var_t", "ad_h0", "ad_q0", "ad_Nh", + "ad_Nq", "ad_f_crit")} df = pr.predizer_amostras(pkg_antigo, X_novos, wn) assert not any(c.startswith("AD_") for c in df.columns) assert "classe_pred" in df.columns # predicao principal nao foi afetada From 3d88b460f34c9e0c15bb6d083fc842539e85f937 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 15:54:05 -0300 Subject: [PATCH 19/38] docs(sci): corrige docstring de hotelling_t2_limite; retrata achado A5 residual hotelling_t2_limite citava Tracy-Young-Mason (1992) afirmando validade "for both observations within the calibration set and new observations". TYM 1992 estabelece exatamente o oposto: Fase I (amostras do treino) usa distribuicao Beta, Fase II (amostras novas) usa F. A funcao implementa so' Fase II; docstring agora credita isso corretamente e documenta a ressalva de uso em contexto de Fase I (dominio_aplicabilidade_treino, figuras.fig3_outliers) -- impacto numerico medido como baixo para os tamanhos de amostra deste projeto (~1.01-1.03x com n~300). Retratacao: o relatorio original (commit 19ddbf5) tambem alegava que q_residuos_limite atribuia sua formula a Jackson & Mudholkar (1979) por engano. Reverificado -- a alegacao estava errada: Jackson & Mudholkar (1979), Technometrics 21(3):341-349, e' de fato a origem da aproximacao por casamento de momentos usada, e a citacao padrao da literatura de PCA/quimiometria para essa formula. Nenhuma mudanca de codigo para este item -- a atribuicao ja existente estava correta. Sem mudanca de comportamento (docstring apenas). 671 testes passam, 2 skip. --- .../AUDITORIA_METODOLOGICA_2026-08-07.md | 36 +++++++++++++------ src/guaraci/chemometric_stats.py | 25 ++++++++++--- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md index 7dcc7ea..1180d12 100644 --- a/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md +++ b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md @@ -29,7 +29,15 @@ percebesse, porque os testes verificam que a função *roda*, não que ela | A2 | Selectivity Ratio usa `w1`, não `b/‖b‖` | **CRÍTICA** | `chemometric_stats.py:50` | Jaccard@20 = 0,39 na seleção de variáveis | | A3 | Domínio de aplicabilidade usa regra retangular | **ALTA** | `chemometric_stats.py:504-506` | rejeição 11,6% (nominal 5%) | | A4 | OPLS-DA multiclasse usa alvo derivado de X (LDA) | MÉDIA | `classificadores.py:448-466` | não medido — método não publicado | -| A5 | Duas docstrings contradizem a referência citada | BAIXA | `chemometric_stats.py:173,197` | ≤1% no *n* deste projeto | +| A5 | Docstring de `hotelling_t2_limite` contradiz a referência citada | BAIXA | `chemometric_stats.py:173` | ≤1% no *n* deste projeto | + +**Status em 2026-08-07 (pós-correção):** A1, A2, A3 e A5 corrigidos e +commitados (`validacao_estatistica.py`, `chemometric_stats.py`, +`classificadores.py`, `pipeline.py`, `predicao.py`); 671 testes passam, 2 +skip. A4 é uma decisão de projeto pendente, não uma correção mecânica — ver +seção A4. A alegação original de um segundo achado em A5 +(`q_residuos_limite`, atribuição a Jackson & Mudholkar) foi **retratada** +após reverificação — ver nota na seção A5. O achado A1 é o mais grave porque atinge exatamente o argumento central do projeto: o Guaraci existe para fazer **validação group-aware**, e o teste que @@ -166,7 +174,7 @@ o caminho publicado. Não deixar como está sem rótulo. --- -## A5 — Duas docstrings contradizem a referência que citam (BAIXA) +## A5 — Docstring de `hotelling_t2_limite` contradiz a referência que cita (BAIXA) **`hotelling_t2_limite` (linha 173).** Cita Tracy-Young-Mason (1992) e afirma ser "valid for both observations within the calibration set and new @@ -189,15 +197,21 @@ Medido — razão limite-F / limite-Beta: ordem de centenas (razão ~1,01×). E o caminho `ucl_method="theoretical"`, onde n=3-4 tornaria o erro 2-3×, **não é o default** (`config.py:204` = `"empirical"`) e desde 2026-08-08 o `T2_UCL` só alimenta a linha de -diagnóstico, não a decisão. Corrigir a docstring é obrigatório; implementar o -limite Beta é opcional. - -**`q_residuos_limite` (linha 197).** Implementa `g·χ²(h)` por casamento de -momentos — que é a aproximação de **Box (1954)** / Nomikos & MacGregor (1995), -não Jackson & Mudholkar (1979), cuja fórmula é outra (baseada em θ₁,θ₂,θ₃,h₀ e -na normal). As duas são legítimas; a atribuição está trocada, em 3 lugares -(`chemometric_stats.py:458,474`, `classificadores.py:31,99`). Corrigir a -citação — o CLAUDE.md exige que toda referência seja verificável. +diagnóstico, não a decisão. **Corrigido em 2026-08-07**: docstring reescrita +para creditar corretamente Fase II/F e documentar a ressalva de Fase I; +implementar o limite Beta ficou como melhoria opcional, não feita. + +**Retratação (`q_residuos_limite`).** A versão original deste relatório +alegava que a fórmula `g·χ²(h)` de `q_residuos_limite` estava atribuída a +Jackson & Mudholkar (1979) por engano, e que a atribuição correta seria Box +(1954). **Reverificado e a alegação estava errada** — buscas adicionais +confirmam que Jackson & Mudholkar (1979), *Control Procedures for Residuals +Associated With Principal Component Analysis*, Technometrics 21(3):341-349, +é exatamente a origem da aproximação por casamento de momentos (`g`, `h`) +usada para o resíduo Q/SPE em PCA, e é a citação padrão na literatura de +PCA/quimiometria para essa fórmula (Nomikos & MacGregor 1995 também a citam +para o mesmo fim). Nenhuma mudança de código ou docstring foi feita para +este item — a atribuição já existente no código está correta. --- diff --git a/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index ad85968..925f457 100644 --- a/src/guaraci/chemometric_stats.py +++ b/src/guaraci/chemometric_stats.py @@ -204,15 +204,32 @@ def hotelling_t2(T: np.ndarray) -> np.ndarray: def hotelling_t2_limite(n: int, k: int, alpha: float = 0.05) -> float: - """Hotelling T2 upper control limit (Tracy-Young-Mason 1992). - - Correct small-sample formula, valid for both observations - within the calibration set and new observations: + """Hotelling T2 upper control limit — Tracy, Young & Mason (1992), + Technometrics 34(1):46-53, **Phase II** (new/future observations, + F-distribution). T2_UCL = k * (n - 1) * (n + 1) / (n * (n - k)) * F_(alpha, k, n - k) Replaces the approximation (k(n-1)/(n-k))*F that underestimated the limit by ~5-10% for n<30 (causing false outliers in small datasets). + + CORRIGIDO em 2026-08-07 (achado A5 da auditoria metodologica — ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md): a docstring + anterior afirmava que a formula valia "for both observations within + the calibration set and new observations". Isso contradiz o proprio + artigo citado: TYM (1992) e' precisamente o trabalho que estabelece + que os dois casos usam distribuicoes DIFERENTES -- Fase I (amostras do + proprio conjunto de treino) usa distribuicao **Beta** + (T2 ~ ((n-1)^2/n) * Beta(k/2, (n-k-1)/2)); Fase II (amostras novas, + a formula acima) usa **F**. Esta funcao implementa SO' a de Fase II. + + Onde e' aplicada em contexto de Fase I neste codebase + (`dominio_aplicabilidade_treino`, `figuras.fig3_outliers`), o erro + numerico medido (razao limite-F / limite-Beta) e' pequeno para os + tamanhos de amostra tipicos do projeto (~1.01-1.03x com n~300; sobe a + ~2-3x so' com n<20) — ver docs/auditoria/medir_achados.py. Nao + corrigido nesta rodada (impacto real medido como baixo); se usada com + n pequeno como limite de FASE I, considerar o limite Beta exato. """ if n - k <= 0: print(f"[WARNING] Hotelling T2: n={n} too small for k={k} LVs.") From ff95b7b5a412a1504bf7897ba1e0b7d7f0a4b7d0 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 16:18:32 -0300 Subject: [PATCH 20/38] fix(sci): OPLS-DA multiclasse usa PLS2, nao LDA, para o alvo continuo (fecha A4) OPLSDAWrapper.fit() usava o 1o escore de uma LinearDiscriminantAnalysis ajustada em (X, y_int) como alvo continuo do NIPALS PLS1, quando Y era multiclasse (one-hot, K>1 colunas). Trygg & Wold (2002) definem OPLS para y binario/continuo; a extensao multiclasse publicada e' OPLS/O2PLS com Y multi-coluna via PLS2 -- um alvo derivado de X por um classificador supervisionado (LDA usa so' a estrutura de classes em X, ignorando a covariancia X-Y que define o eixo preditivo do OPLS) nao e' o metodo publicado. Decisao do autor (entre rotular como variante do Guaraci ou trocar pelo metodo publicado): trocar por PLS2. O caminho de fallback que ja existia (ativado so' quando a LDA falhava por matriz de dispersao singular) vira o UNICO caminho: 1o escore Y de um PLSRegression(n_components=1) ajustado em (X, Y) -- capta a covariancia dominante X-Y entre todas as K classes simultaneamente. LDA e o try/except de fallback removidos. Logica extraida para OPLSDAWrapper._alvo_continuo() (staticmethod) para ser testavel isoladamente -- mesmo padrao usado na correcao do A2 (SR). Testes: os 2 que exercitavam o caminho LDA (smoke test multiclasse, teste do fallback via monkeypatch da LDA) substituidos por 3 -- smoke test do caminho PLS2, teste de propriedade que trava _alvo_continuo contra a formula de referencia (y_scores_ do PLS2, centrado), teste do caso binario. MANUAL.md/VALIDATION.md atualizados (mencionavam LDA). 672 testes passam (671 + 1 liquido), 2 skip -- sem regressao. Fecha os 5 achados da auditoria metodologica de 2026-08-07 (A1-A5). --- docs/MANUAL.md | 2 +- docs/VALIDATION.md | 2 +- .../AUDITORIA_METODOLOGICA_2026-08-07.md | 43 +++++++----- src/guaraci/classificadores.py | 63 ++++++++++-------- tests/test_classificadores.py | 65 +++++++++++-------- tests/test_pipeline_smoke.py | 3 +- 6 files changed, 105 insertions(+), 73 deletions(-) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index fef568d..3dadccc 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -205,7 +205,7 @@ aparecerem como valores não computados. espécie. Em N2 ele é sempre ligado automaticamente (não precisa configurar). - **OPLS-DA** não é específico de nenhum nível — no Guaraci ele discrimina - **espécie** (mesmo alvo do PLS-DA, via LDA quando há mais de duas + **espécie** (mesmo alvo do PLS-DA, via PLS2 quando há mais de duas classes), então continua disponível como extra no objetivo Classificação. **Conjunto padrão de fábrica (~8 a 10 figuras "*core*"), qualquer nível:** diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index c22d6b5..799f3bf 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -23,7 +23,7 @@ | CV-ANOVA (Eriksson, Trygg & Wold, 2008) | Q² = 1 − PRESS/SS_total | caso com valores manualmente calculados (SS_total=20, PRESS=2 → Q²=0.90) | \|Δ\| < 1e-9 | `test_cv_anova_q2_formula` | | Bootstrap BCa (Efron & Tibshirani, 1993) | propriedades do intervalo (não simulação de cobertura) | predição perfeita → IC=[1,1]; valor observado sempre dentro do IC e em [0,1]; reprodutível com a mesma seed; `n_boot` baixo devolve NaN em vez de um IC enganoso | 5/5 propriedades verificadas | `test_bca_*` (`tests/test_validacao_estatistica.py`) | | Teste de permutação (*Y-randomization*) | discriminação sinal × ruído | classes separáveis → p baixo (acc=1.000, **p=0.024**); rótulos aleatórios → p alto (acc=0.475, **p=0.781**) | ambos verificados | `test_permutacao_da_p_baixo_com_sinal_real`, `test_permutacao_da_p_alto_com_rotulos_aleatorios` | -| OPLS-DA (Trygg & Wold, 2002; Bylesjö et al., 2006) | ortogonalidade de Gram-Schmidt: `t_orth ⟂ t_pred` | produto interno `t_pred · t_orth` — binário e 14 classes (LDA) | < 1e-6 em ambos os casos | `test_opls_orthogonality_binary`, `test_opls_orthogonality_multiclass` | +| OPLS-DA (Trygg & Wold, 2002; Bylesjö et al., 2006) | ortogonalidade de Gram-Schmidt: `t_orth ⟂ t_pred` | produto interno `t_pred · t_orth` — binário e 14 classes (alvo via PLS2) | < 1e-6 em ambos os casos | `test_opls_orthogonality_binary`, `test_opls_orthogonality_multiclass` | **Reproduzir:** ```bash diff --git a/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md index 1180d12..9b69db2 100644 --- a/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md +++ b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md @@ -31,10 +31,11 @@ percebesse, porque os testes verificam que a função *roda*, não que ela | A4 | OPLS-DA multiclasse usa alvo derivado de X (LDA) | MÉDIA | `classificadores.py:448-466` | não medido — método não publicado | | A5 | Docstring de `hotelling_t2_limite` contradiz a referência citada | BAIXA | `chemometric_stats.py:173` | ≤1% no *n* deste projeto | -**Status em 2026-08-07 (pós-correção):** A1, A2, A3 e A5 corrigidos e -commitados (`validacao_estatistica.py`, `chemometric_stats.py`, -`classificadores.py`, `pipeline.py`, `predicao.py`); 671 testes passam, 2 -skip. A4 é uma decisão de projeto pendente, não uma correção mecânica — ver +**Status em 2026-08-07 (pós-correção): TODOS os 5 achados fechados.** A1-A5 +corrigidos e commitados (`validacao_estatistica.py`, `chemometric_stats.py`, +`classificadores.py`, `pipeline.py`, `predicao.py`); 672 testes passam, 2 +skip. A4 era uma decisão de projeto (rotular como variante vs. trocar pelo +método publicado) — o autor optou por trocar por PLS2 multi-coluna, ver seção A4. A alegação original de um segundo achado em A5 (`q_residuos_limite`, atribuição a Jackson & Mudholkar) foi **retratada** após reverificação — ver nota na seção A5. @@ -151,26 +152,36 @@ já existe em vez de uma terceira implementação da regra de decisão. --- -## A4 — OPLS-DA multiclasse constrói o alvo a partir de X (MÉDIA) +## A4 — OPLS-DA multiclasse construía o alvo a partir de X (MÉDIA) — ✅ RESOLVIDO em 2026-08-07 -`classificadores.py:448-466`: para Y multiclasse, o código ajusta uma -`LinearDiscriminantAnalysis` em `(X, y)` e usa o **primeiro escore discriminante +`classificadores.py:448-466`: para Y multiclasse, o código ajustava uma +`LinearDiscriminantAnalysis` em `(X, y)` e usava o **primeiro escore discriminante como o `y` contínuo** do OPLS-DA. Trygg & Wold (2002) definem OPLS para `y` binário/contínuo; a extensão multiclasse publicada é O2PLS/OPLS com Y multi-coluna. Usar um alvo derivado -de X **não é método publicado** — o comentário no código explica a motivação -(evitar viés de "primeira classe vs resto"), o que é legítimo como -raciocínio, mas o resultado é uma variante própria, não OPLS-DA. +de X **não é método publicado** — o comentário no código explicava a motivação +(evitar viés de "primeira classe vs resto"), o que era legítimo como +raciocínio, mas o resultado era uma variante própria, não OPLS-DA. -Dois riscos concretos, nenhum medido nesta rodada: -- o componente "preditivo" fica parcialmente auto-referencial (alvo é função de X); -- com p ≫ n a LDA é mal-condicionada; o `except` cai para PLS2 e **muda o eixo +Dois riscos concretos que a versão anterior tinha, nenhum medido: +- o componente "preditivo" ficava parcialmente auto-referencial (alvo é função de X); +- com p ≫ n a LDA é mal-condicionada; o `except` caía para PLS2 e **mudava o eixo do S-Plot silenciosamente** (só um `log.warning`). -**Ação:** ou documentar explicitamente como variante do Guaraci (com essa -palavra) em `VALIDATION.md` e no MANUAL, ou trocar por PLS2 multi-coluna, que é -o caminho publicado. Não deixar como está sem rótulo. +**Corrigido:** decisão do autor foi trocar por PLS2 multi-coluna (o caminho +publicado), em vez de rotular como variante. `OPLSDAWrapper._alvo_continuo` +(novo método estático, extraído para ser testável) usa o 1º escore Y de um +`PLSRegression(n_components=1)` ajustado em `(X, Y)` — a direção que capta a +covariância dominante X-Y entre todas as K classes simultaneamente. Import de +`LinearDiscriminantAnalysis` e o `try/except` de fallback removidos por +completo (o fallback virou o único caminho). `MANUAL.md`/`VALIDATION.md` +atualizados (mencionavam LDA). Testes: os 2 que exercitavam o caminho LDA +(um smoke test, um teste do fallback via monkeypatch) substituídos por 3 — +smoke test do caminho PLS2, teste de propriedade que trava `_alvo_continuo` +contra a fórmula de referência (`y_scores_` do PLS2, centrado), e um teste +do caso binário (não aciona o ramo multiclasse). 672 testes passam +(671 + 1 líquido), 2 skip — sem regressão. --- diff --git a/src/guaraci/classificadores.py b/src/guaraci/classificadores.py index 222510d..f832c68 100644 --- a/src/guaraci/classificadores.py +++ b/src/guaraci/classificadores.py @@ -418,37 +418,44 @@ def _nipals_pls1(X: np.ndarray, y: np.ndarray, p = X.T @ t / nt if nt > 1e-12 else np.zeros(X.shape[1]) return w, t, p - def fit(self, X: np.ndarray, Y: np.ndarray) -> "OPLSDAWrapper": - X = np.asarray(X, dtype=float) - Y = np.asarray(Y, dtype=float) - # Build a single continuous y that captures all-class discriminant structure. - # For binary Y (1 column): use that column directly. - # For multiclass Y (K columns, one-hot): use the first Linear Discriminant - # component (LDA), which maximally separates all K classes simultaneously. - # Using Y[:,0] (first class vs. rest) would silently bias the OPLS toward - # one class only — a methodological error for 14-class FT-NIR data. + @staticmethod + def _alvo_continuo(X: np.ndarray, Y: np.ndarray) -> np.ndarray: + """Alvo y continuo (1 coluna, centrado) usado para achar a direcao + preditiva do OPLS via NIPALS PLS1. Captura a direcao de covariancia + X-Y dominante. Para Y binario (1 coluna): a propria coluna. + + Para Y multiclasse (K colunas, one-hot): CORRIGIDO em 2026-08-07 + (achado A4 da auditoria metodologica -- ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md). A versao + anterior usava o 1o escore de uma LinearDiscriminantAnalysis(X, + y_int) como alvo -- nao e' o metodo publicado: Trygg & Wold (2002) + definem OPLS para y binario/continuo; a extensao multiclasse + publicada e' OPLS/O2PLS com Y multi-coluna via PLS2, nao um alvo + derivado separadamente de X por um classificador supervisionado (a + LDA usa so' a estrutura de classes em X, ignorando a covariancia + X-Y que define o eixo preditivo do (O)PLS). Usa-se agora o escore Y + da 1a variavel latente de um PLS2 ajustado em (X, Y) -- a direcao + que capta a covariancia dominante X-Y entre TODAS as K classes + simultaneamente, o caminho publicado. Using Y[:,0] (first class vs. + rest) would silently bias the OPLS toward one class only — a + methodological error for 14-class FT-NIR data; PLS2's y_scores_ + avoids that by construction (all K columns enter the covariance + direction jointly). + """ if Y.ndim == 2 and Y.shape[1] > 1: - from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as _LDA - y_int_opls = np.argmax(Y, axis=1) - try: - _lda = _LDA(n_components=1) - y = _lda.fit_transform(X, y_int_opls)[:, 0].astype(float) - except (ValueError, np.linalg.LinAlgError) as _e_lda: - # LDA falha tipicamente com matriz de dispersao intra-classe - # singular (classe com poucas/colineares amostras) -- cai p/ - # o fallback PLS2 (menos otimo mas correto p/ multiclasse). - # Registrado pois muda o eixo y do OPLS-DA/S-Plot silenciosamente. - log.warning("OPLS-DA: LDA falhou (%s); usando fallback PLS2.", - _e_lda) - from sklearn.cross_decomposition import PLSRegression as _PLSr - _pls2 = _PLSr(n_components=1, scale=False) - _pls2.fit(X, Y) - _ys = _pls2.y_scores_ - y = (np.asarray(_ys, dtype=float)[:, 0] - if _ys is not None else Y @ np.ones(Y.shape[1])) + _pls2 = PLSRegression(n_components=1, scale=False) + _pls2.fit(X, Y) + _ys = _pls2.y_scores_ + y = (np.asarray(_ys, dtype=float)[:, 0] + if _ys is not None else Y @ np.ones(Y.shape[1])) else: y = (Y[:, 0] if Y.ndim == 2 else Y.copy()).astype(float) - y = y - float(y.mean()) + return y - float(y.mean()) + + def fit(self, X: np.ndarray, Y: np.ndarray) -> "OPLSDAWrapper": + X = np.asarray(X, dtype=float) + Y = np.asarray(Y, dtype=float) + y = self._alvo_continuo(X, Y) n = X.shape[0] Xr = X.copy() diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 95cea11..4de7284 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -190,10 +190,14 @@ def test_oplsda_fit_binario_gera_scores_ortogonais(): assert len(opls.W_orth_) >= 0 # pode convergir com 0 ou 1 componente ortogonal -def test_oplsda_fit_multiclasse_usa_lda_para_y_continuo(): - """Y one-hot multiclasse (>1 coluna) aciona o ramo LDA (fit() reduz a um - y continuo antes do NIPALS) -- nao deve lancar excecao e deve treinar - componentes ortogonais coerentes com o numero de features.""" +def test_oplsda_fit_multiclasse_usa_pls2_para_y_continuo(): + """Y one-hot multiclasse (>1 coluna) aciona o ramo PLS2 (fit() reduz a + um y continuo antes do NIPALS, via 1o escore Y de um PLS2 ajustado em + (X, Y) -- achado A4 da auditoria 2026-08-07: a versao anterior usava + LDA, que nao e' o metodo publicado, ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md) -- nao deve + lancar excecao e deve treinar componentes ortogonais coerentes com o + numero de features.""" rng = np.random.default_rng(7) X = np.vstack([ _classe_compacta(rng, centro=0.0, n=15, k=6), @@ -204,33 +208,42 @@ def test_oplsda_fit_multiclasse_usa_lda_para_y_continuo(): opls = OPLSDAWrapper(n_ortho=1).fit(X, Y) assert isinstance(opls.W_orth_, list) + assert opls.t_pred_train_.shape[0] == 45 -def test_oplsda_lda_falha_cai_no_fallback_pls2(monkeypatch): - """Se a LDA multiclasse falhar (matriz de dispersao intra-classe - singular -- caso real com poucas amostras/features colineares), o - fit() deve cair no fallback PLS2 em vez de propagar a excecao. Forcado - via monkeypatch (a falha real da LDA e' dificil de reproduzir de forma - limpa/deterministica via dados publicos, mas o CAMINHO de fallback e' - codigo real que precisa continuar correto).""" - def _fit_transform_falha(self, X, y): - raise ValueError("simulada: matriz de dispersao intra-classe singular") - - monkeypatch.setattr( - "sklearn.discriminant_analysis.LinearDiscriminantAnalysis.fit_transform", - _fit_transform_falha) - - rng = np.random.default_rng(8) +def test_oplsda_alvo_multiclasse_bate_com_escore_y_do_pls2(): + """Propriedade que define a correcao do achado A4: o alvo continuo + usado pelo OPLS-DA multiclasse deve ser EXATAMENTE (centrado) o 1o + escore Y de um PLS2 ajustado em (X, Y) -- nao mais um escore de LDA, + que ignora a covariancia X-Y.""" + rng = np.random.default_rng(11) X = np.vstack([ - _classe_compacta(rng, centro=0.0, n=10, k=6), - _classe_compacta(rng, centro=3.0, n=10, k=6), - _classe_compacta(rng, centro=6.0, n=10, k=6), + _classe_compacta(rng, centro=0.0, n=12, k=5), + _classe_compacta(rng, centro=4.0, n=12, k=5), + _classe_compacta(rng, centro=8.0, n=12, k=5), ]) - Y = np.eye(3)[np.array([0] * 10 + [1] * 10 + [2] * 10)] + Y = np.eye(3)[np.array([0] * 12 + [1] * 12 + [2] * 12)] - opls = OPLSDAWrapper(n_ortho=1).fit(X, Y) # nao deve lancar ValueError - assert isinstance(opls.W_orth_, list) - assert opls.t_pred_train_.shape[0] == 30 + y = OPLSDAWrapper._alvo_continuo(X, Y) + + from sklearn.cross_decomposition import PLSRegression + pls2 = PLSRegression(n_components=1, scale=False).fit(X, Y) + y_esperado = np.asarray(pls2.y_scores_, dtype=float)[:, 0] + y_esperado = y_esperado - float(y_esperado.mean()) + + np.testing.assert_allclose(y, y_esperado, rtol=1e-9) + + +def test_oplsda_alvo_binario_usa_a_propria_coluna(): + """Y de 1 coluna (binario): o alvo e' a propria coluna, centrada -- + nao aciona o ramo PLS2 multiclasse.""" + rng = np.random.default_rng(12) + X = rng.normal(size=(20, 5)) + y_col = rng.normal(size=20) + Y = y_col.reshape(-1, 1) + + y = OPLSDAWrapper._alvo_continuo(X, Y) + np.testing.assert_allclose(y, y_col - y_col.mean(), rtol=1e-9) def test_nipals_pls1_com_x_todo_zero_nao_diverge(): diff --git a/tests/test_pipeline_smoke.py b/tests/test_pipeline_smoke.py index 90831ed..a8f2aca 100644 --- a/tests/test_pipeline_smoke.py +++ b/tests/test_pipeline_smoke.py @@ -125,7 +125,8 @@ def test_opls_orthogonality_binary(pq): def test_opls_orthogonality_multiclass(pq): - """OPLS 14-class: LDA y-vector used; t_orth still ⊥ t_pred.""" + """OPLS 14-class: PLS2 y-vector used (achado A4, auditoria 2026-08-07 -- + era LDA); t_orth still ⊥ t_pred.""" rng = np.random.default_rng(99) n_classes = 14 n_per = 20 From e7db43000472e9a94371261bd9597483aa7ca649 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 16:22:41 -0300 Subject: [PATCH 21/38] chore(auditoria): lint dos scripts de medicao (ruff check . limpo no repo inteiro) Imports nao usados e f-strings sem placeholder nos scripts de docs/auditoria/ -- nao pegos nos commits anteriores porque cada um so rodou ruff check nos arquivos de src/tests tocados, nao no repo inteiro. Autofix (ruff check --fix), sem mudanca de logica. --- docs/auditoria/medir_achados.py | 4 ++-- docs/auditoria/medir_permutacao_grupos.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/auditoria/medir_achados.py b/docs/auditoria/medir_achados.py index 8b28fe3..db96738 100644 --- a/docs/auditoria/medir_achados.py +++ b/docs/auditoria/medir_achados.py @@ -1,7 +1,7 @@ """Mede empiricamente o impacto dos achados da auditoria. Nenhum numero deste script e' suposto -- todos sao medidos.""" import numpy as np -from scipy.stats import beta as beta_dist, f as f_dist, chi2 +from scipy.stats import beta as beta_dist from sklearn.cross_decomposition import PLSRegression import sys @@ -104,7 +104,7 @@ def gera_espectros(n, p, seed): tr["t2_limite"], tr["q_limite"]) taxas.append(1.0 - float(r["fracao_dentro"])) taxas = np.array(taxas) -print(f" alpha nominal declarado : 0.050") +print(" alpha nominal declarado : 0.050") print(f" taxa de rejeicao MEDIDA (n=40 sim): {taxas.mean():.4f} " f"(sd={taxas.std():.4f})") print(f" 1-(1-alpha)^2 esperado p/ retangular: {1 - 0.95 ** 2:.4f}") diff --git a/docs/auditoria/medir_permutacao_grupos.py b/docs/auditoria/medir_permutacao_grupos.py index 7480ae4..96cf471 100644 --- a/docs/auditoria/medir_permutacao_grupos.py +++ b/docs/auditoria/medir_permutacao_grupos.py @@ -81,14 +81,14 @@ def uma_replica(seed): print(f" Acuracia balanceada observada (media): {obs.mean():.4f} " f"(acaso = {1/K:.4f})") print() -print(f" Null A - permuta por AMOSTRA (implementado hoje):") +print(" Null A - permuta por AMOSTRA (implementado hoje):") print(f" media da distribuicao nula = {ma.mean():.4f} sd = {sa.mean():.4f}") print(f" TAXA DE FALSO POSITIVO (p<0.05) = {np.mean(pa < 0.05):.3f}") print(f" p-valor mediano = {np.median(pa):.4f}") print() -print(f" Null B - permuta por GRUPO (correto):") +print(" Null B - permuta por GRUPO (correto):") print(f" media da distribuicao nula = {mb.mean():.4f} sd = {sb.mean():.4f}") print(f" TAXA DE FALSO POSITIVO (p<0.05) = {np.mean(pb < 0.05):.3f}") print(f" p-valor mediano = {np.median(pb):.4f}") print() -print(f" Alvo para um teste calibrado: falso positivo ~= 0.050") +print(" Alvo para um teste calibrado: falso positivo ~= 0.050") From 99fc04c9debe33d1a27667b8af828d53da53f23e Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 16:24:13 -0300 Subject: [PATCH 22/38] docs(claude-md): registra P11 (auditoria metodologica, 5 achados) e reverifica ESTADO ALEGADO Tabela ESTADO ALEGADO reverificada em 2026-08-07 antes de comecar (nenhuma divergencia na versao anterior) e depois de fechar os 5 achados A1-A5 (testes 663->672). Novo item P11 documenta os achados, correcoes e a retratacao de uma alegacao errada no proprio relatorio de auditoria (q_residuos_limite / Jackson & Mudholkar). Nota do N2 atualizada: A1 (permutacao) e A3 (dominio de aplicabilidade) sao mais 2 motivos, alem do DD-SIMCA ja conhecido, para reexecutar antes de citar numeros de Classificacao. --- CLAUDE.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae2889f..75c1c38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,15 +98,21 @@ nova é reverificá-los.** Se divergirem, o código vence, e você me avisa da d | Item | Valor alegado | Comando para verificar | |---|---|---| | Versão | 31.9.0 | `grep -r version pyproject.toml` | -| Testes | 663 pass, 2 skip (reverificado 2026-08-08) | `pytest -q` | -| Cobertura | 67% (reverificado 2026-08-08) | `pytest --cov=src/guaraci --cov-report=term-missing` | -| Lint | ruff limpo | `ruff check .` | +| Testes | 672 pass, 2 skip (reverificado 2026-08-07, sessão de auditoria metodológica) | `pytest -q` | +| Cobertura | 67% (reverificado 2026-08-07) | `pytest --cov=src/guaraci --cov-report=term-missing` | +| Lint | ruff limpo (repo inteiro, incl. `docs/auditoria/`) | `ruff check .` | | `executar()` | 1438 linhas (contagem AST, não a linha do `def`) | `grep -n "def executar" src/guaraci/pipeline.py` | | `print()` em pipeline | 0 (migração do P6 completa — tabela desatualizada até agora) | `grep -c "print(" src/guaraci/pipeline.py` | | `except` amplos | 53 (100% com `noqa: BLE001` justificado, reverificado) | `grep -rn "except Exception\|except:" src/guaraci/ \| wc -l` | | `guaraci.py` | 3813 linhas (+495 desde 07-13 — wiring de menus/i18n desta sessão) | `wc -l src/guaraci/guaraci.py` | | TODO/FIXME reais | 6 (todos em `reports.py`, placeholders de template LaTeX — não são dívida de código) | `grep -rn "TODO\|FIXME\|HACK" src/guaraci/ \| grep -v NOTAS_METODOLOGICAS` | +> Atualizado em 2026-08-07 após a auditoria metodológica de 5 achados +> (P11 abaixo — testes 663→672, +9 líquido: 4 novos em A1, 3 em A2, 1 em +> A3, 2 novos −1 removido em A4). Reverificado que a tabela anterior +> (2026-08-08, sensibilidade DD-SIMCA/PCV) continuava batendo antes de +> começar — nenhuma divergência encontrada nela. + > Atualizado em 2026-08-08 apos a sessao de correcao de figuras + DD-SIMCA > (P1 residual fechado, PCV, deteccao robusta). `print()` em pipeline > mostrava 164 na tabela desde 07-13 mesmo com a migracao ja concluida @@ -695,6 +701,68 @@ existência do arquivo. --- +### ✅ P11 (RESOLVIDO em 2026-08-07) — Auditoria metodológica: 5 achados no núcleo científico + +Auditoria (não só de figuras desta vez — de **equação vs. publicação +original**) em `chemometric_stats.py`, `classificadores.py`, +`validacao_estatistica.py`, comparando cada método linha a linha com a +referência citada e **medindo** a divergência (nunca estimando). Relatório +completo + scripts reprodutíveis em `docs/auditoria/ +AUDITORIA_METODOLOGICA_2026-08-07.md`. **Mesma lição do P10 e do P1**: os +663 testes que existiam antes verificavam que a função *roda*, não que ela +*calcula o que diz calcular*. + +1. **Teste de permutação/Wold não era group-aware** (`validacao_estatistica.py`) + — embaralhava rótulos por AMOSTRA, ignorando `mae_id`; um mesmo grupo de + réplica física ficava com rótulos diferentes após o embaralhamento, o que + não pode existir sob H0. Medido: falso positivo de **15,0%** contra 5% + nominal (12 grupos × 3 réplicas, 120 repetições). **O mais grave dos 5**: + atinge o argumento central do projeto (validação group-aware) — o teste + que produz o p-valor citável não era group-aware. Corrigido: + `_gerar_permutacoes_rotulo()` permuta a atribuição de rótulo entre + grupos (Winkler et al. 2015), preservando a coerência de cada `mae_id`. +2. **Selectivity Ratio usava `w1` (peso PLS), não `b/‖b‖`** — Rajalahti et + al. (2009) define a projeção-alvo sobre o vetor de regressão + normalizado; só coincidiam com 1 LV. Medido: `corr(t_tp, ŷ)` (a + propriedade que define o método) caía de 1,000000 para ~0,92 com ≥2 LVs; + Jaccard@20 entre o ranking implementado e o correto = 0,39. Usado por + `selecao_variaveis.py` para SELECIONAR variáveis — o método anterior + escolhia um conjunto diferente do que a literatura escolheria. +3. **Domínio de aplicabilidade usava regra retangular** (T2≤lim **e** + Q≤lim, alpha independente por eixo) — a MESMA regra corrigida no + DD-SIMCA no P1 (2026-08-08), ainda presente aqui. Medido: rejeição de + 11,6% contra 5% nominal em amostras da própria distribuição do treino. + Usado em produção por `predicao.py`. Corrigido por reúso: as funções + puras da distância combinada do DD-SIMCA (`media_e_dof_momentos`, + `distancia_combinada`) foram extraídas para `chemometric_stats.py` — + `DDSimca` e `dominio_aplicabilidade_*` agora compartilham a mesma + implementação em vez de reimplementar cada uma a sua conta. +4. **OPLS-DA multiclasse construía o alvo via LDA(X, y)** — não é o método + publicado (Trygg & Wold 2002 definem OPLS para y binário/contínuo; a + extensão multiclasse publicada é OPLS/O2PLS com Y multi-coluna via + PLS2). Decisão do autor: trocar pelo caminho publicado em vez de rotular + como variante — `OPLSDAWrapper._alvo_continuo()` agora usa o 1º escore Y + de um PLS2 ajustado em `(X, Y)`. +5. **Docstring de `hotelling_t2_limite` contradizia a própria referência + citada** — afirmava que a fórmula (Fase II, distribuição F) valia também + para amostras de treino (Fase I, que TYM 1992 define via distribuição + Beta). Impacto numérico medido como baixo para os tamanhos de amostra + deste projeto (~1,01-1,03× com n~300). Corrigida a docstring; limite + Beta de Fase I não implementado (impacto real baixo). + +**Retratação registrada no próprio relatório:** a alegação original de que +`q_residuos_limite` atribuía sua fórmula a Jackson & Mudholkar (1979) por +engano (deveria ser Box 1954) foi **verificada como falsa** após busca +adicional — a atribuição já existente no código estava correta. Mantido +como exemplo de que reverificar a própria auditoria também é obrigatório. + +**Estado:** todos os 5 corrigidos e commitados nesta sessão. 663→672 testes +(9 líquidos: novos que travam as propriedades que falhavam antes de cada +correção). Cobertura/lint/contagens da tabela ESTADO ALEGADO reverificadas +antes e depois — sem divergência além do esperado. + +--- + ## 5. FIGURAS QUE FALTAM — ✅ TODAS ENTREGUES (verificado 2026-07-12) As 4 abaixo já existem em `figuras.py` e são chamadas por `executar()` — não são mais um item de roadmap. Mantido aqui só como registro do que foi pedido @@ -807,7 +875,7 @@ nas primeiras linhas em inglês. |---|---|---|---| | 8 | P7 — publicar no PyPI (`guaraci demo`/`doctor`/Colab já prontos) | depende de conta do autor | **Adoção** | | — | **N1 (real) rodado e válido** ✅ | feito 2026-08-06, `PLSDA_OE_PorEspecie_...211237` | — | -| — | **N2 (real) rodado, mas DESATUALIZADO** ⚠️ | rodado 2026-08-07 10:19, **a correção da regra de decisão do DD-SIMCA foi commitada 3h30 depois (13:52, commit `b51f361`)** — o resumo que existe usa a regra retangular antiga (rejeição efetiva ~0,0975, não 0,05). **Precisa reexecutar** antes de citar qualquer número de sensibilidade/especificidade DD-SIMCA do N2 | Defesa — números atuais não refletem o código | +| — | **N2 (real) rodado, mas DESATUALIZADO — agora por MAIS motivos** ⚠️ | rodado 2026-08-07 10:19, **a correção da regra de decisão do DD-SIMCA foi commitada 3h30 depois (13:52, commit `b51f361`)** — o resumo que existe usa a regra retangular antiga (rejeição efetiva ~0,0975, não 0,05). Depois disso, a auditoria P11 (mesma sessão, mais tarde) corrigiu mais 2 itens que afetam diretamente números de Classificação: A1 (teste de permutação/Wold do objetivo Classificação, falso positivo medido em 15% em vez de 5%) e A3 (domínio de aplicabilidade, mesma classe de bug do DD-SIMCA, usado por `predicao.py`). **Precisa reexecutar** — a lista de motivos só cresceu | Defesa — números atuais não refletem o código | | — | **N3 nunca rodado com o código corrigido** | só existe uma rodada de 2026-07-05, anterior a TODAS as correções desta sessão (CV, DD-SIMCA, figuras) — precisa rodar do zero | Defesa | ~~4~~ ~~`docs/VALIDATION.md` — nota sobre nested-CV da Etapa 4~~ ✅ feito — ver seção "AG e SPA (Etapa 4, opcionais)" em `VALIDATION.md`. From 158d295f1d859bd50b59c9607240c5c089d28dc1 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 16:55:35 -0300 Subject: [PATCH 23/38] fix(cli): barra de progresso trava em 85,7% durante a etapa mais demorada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A etapa "[6/7]" (figuras + DD-SIMCA + OPLS-DA + holdout) concentra a maior parte do tempo real de execucao, mas so' tinha 2 marcadores de texto OPCIONAIS ("[6b/7]"/"[6c/7]", nem sempre emitidos) entre o inicio e o fim. Sem eles, progresso_do_log() ficava CRAVADO em 6/7=0,857 durante toda essa fase -- o painel do CLI (e a barra do app web, mesma funcao compartilhada) parecia travado, mesmo com figuras sendo salvas visivelmente no log. Medido reproduzindo o mecanismo exato do painel (thread em background + contextlib.redirect_stdout, docs/auditoria/medir_bug_progresso_cli.py): 96,1% das amostras de progresso presas nesse numero antes da correcao, 31,1% depois (o que resta e' o platô legitimo perto do fim da etapa). Correcao: progresso_do_log() ganha um parametro opcional `total_figuras_planejadas` (retrocompativel -- None preserva o comportamento antigo exato). Quando fornecido e a etapa atual e' a 6, soma um bonus fracionario proporcional a len(figuras_concluidas(txt))/total_figuras_planejadas, avancando o progresso a cada figura salva em vez de so' nos 2 marcadores esparsos. Nunca regride, nunca ultrapassa o teto global 0.99. CLI (guaraci.py) e app web (app_tabs/modelo.py) atualizados para passar a contagem de figuras planejadas, ja calculada em ambos antes da execucao comecar. Bonus: "[6b/7]"/"[6c/7]" (ja existiam no log do pipeline mas nao eram reconhecidos -- so' a etapa 7 tinha rotulo especifico para sub-passos) agora mostram rotulo especifico em vez do generico da etapa. 677 testes passam (672 + 5 novos), 2 skip -- sem regressao. --- docs/auditoria/medir_bug_progresso_cli.py | 104 ++++++++++++++++++++++ src/guaraci/app_logic.py | 65 +++++++++++--- src/guaraci/app_tabs/modelo.py | 2 +- src/guaraci/guaraci.py | 2 +- tests/test_app_logic.py | 67 ++++++++++++++ 5 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 docs/auditoria/medir_bug_progresso_cli.py diff --git a/docs/auditoria/medir_bug_progresso_cli.py b/docs/auditoria/medir_bug_progresso_cli.py new file mode 100644 index 0000000..f6a06cd --- /dev/null +++ b/docs/auditoria/medir_bug_progresso_cli.py @@ -0,0 +1,104 @@ +"""Reproduz o mecanismo exato do painel de progresso do CLI +(`_rodar_pipeline` em guaraci.py): thread em background rodando executar() +dentro de contextlib.redirect_stdout/redirect_stderr, thread principal +fazendo poll de app_logic.progresso_do_log a cada 0.3s -- sem Rich Live +(isolando so' a logica de progresso, nao a renderizacao no terminal). + +Mede o "bug do progresso" relatado em 2026-08-07: a etapa "[6/7]" (figuras + +DD-SIMCA + OPLS-DA + holdout) concentra a maior parte do tempo real de +execucao, mas so' tinha 2 marcadores de texto OPCIONAIS entre o inicio e o +fim -- sem eles, a fracao reportada ficava CRAVADA em 6/7=0.857 durante toda +essa fase. Compara ANTES (sem total_figuras_planejadas) e DEPOIS (com) da +correcao em app_logic.progresso_do_log. +""" +import contextlib +import os +import sys +import tempfile +import threading +import time + +sys.path.insert(0, "src") +import guaraci.pipeline as pq +from guaraci.app_logic import LogThreadSafe, progresso_do_log + + +def _rodar_e_medir(total_figuras_planejadas): + with tempfile.TemporaryDirectory() as tmp: + cfg = pq.Config( + pasta_entrada=os.path.join(tmp, "dados"), + pasta_saida_raiz=os.path.join(tmp, "saida"), + modo="sintetico", n_por_classe=8, n_pontos_sint=50, + wn_min=400.0, wn_max=4001.0, + n_splits_cv=2, n_repeats_cv=1, n_permutacoes=5, + n_permutacoes_wold=5, n_bootstrap_vip=3, n_bootstrap_bca=20, + n_monte_carlo=3, max_lvs=5, + ) + os.makedirs(cfg.pasta_entrada, exist_ok=True) + + _done = {"ok": False, "error": None} + _logger = LogThreadSafe() + + def _run(): + try: + with contextlib.redirect_stdout(_logger), \ + contextlib.redirect_stderr(_logger): + pq.executar(cfg) + except Exception as e: # noqa: BLE001 -- script de diagnostico + # standalone: qualquer falha do pipeline deve virar mensagem + # no relatorio de medicao, nao um traceback que interrompe + # o script antes de imprimir o historico ja coletado. + _done["error"] = str(e) + finally: + _done["ok"] = True + + thr = threading.Thread(target=_run, daemon=True) + t0 = time.time() + thr.start() + + historico = [] + while not _done["ok"]: + txt = _logger.text() + frac, label = progresso_do_log(txt, total_figuras_planejadas) + historico.append((time.time() - t0, frac, label)) + time.sleep(0.1) + thr.join() + + if _done["error"]: + raise RuntimeError(f"executar() falhou: {_done['error']}") + return historico + + +def _resume(historico, titulo): + duracao_total = historico[-1][0] + valores = [round(f, 3) for _, f, _ in historico] + moda = max(set(valores), key=valores.count) + n_na_moda = sum(1 for v in valores if v == moda) + pct_parado = 100 * n_na_moda / len(valores) + print(f"=== {titulo} ===") + print(f" duracao total: {duracao_total:.1f}s ({len(historico)} amostras)") + print(f" fracao mais frequente (moda): {moda:.3f}") + print(f" % do tempo/amostras nessa fracao: {pct_parado:.1f}%") + print(" progressao (t, frac, label) a cada ~1s:") + ultimo_t = -1.0 + for t, f, label in historico: + if t - ultimo_t >= 1.0 or t == historico[-1][0]: + print(f" {t:5.1f}s {f:5.3f} {label}") + ultimo_t = t + print() + return pct_parado + + +if __name__ == "__main__": + print("Rodando SEM a correcao (total_figuras_planejadas=None -- " + "comportamento antigo)...\n") + hist_antes = _rodar_e_medir(None) + pct_antes = _resume(hist_antes, "ANTES (bug)") + + print("Rodando COM a correcao (total_figuras_planejadas=7, plano " + "tipico deste cenario sintetico)...\n") + hist_depois = _rodar_e_medir(7) + pct_depois = _resume(hist_depois, "DEPOIS (corrigido)") + + print(f"Resumo: parado numa unica fracao {pct_antes:.1f}% do tempo " + f"antes da correcao, {pct_depois:.1f}% depois.") diff --git a/src/guaraci/app_logic.py b/src/guaraci/app_logic.py index e9d04b1..8dc3f51 100644 --- a/src/guaraci/app_logic.py +++ b/src/guaraci/app_logic.py @@ -87,8 +87,9 @@ def avisos_do_log(txt: str) -> List[str]: return vistos # ── Parsing do log de progresso do pipeline ────────────────────────────────── -# O pipeline emite marcadores "[N/7]" (e sub-passos "[7b/7]", "[7c/7]") no -# stdout; a UI converte isso numa barra de progresso + rótulo legível. +# O pipeline emite marcadores "[N/7]" (e sub-passos "[6b/7]", "[6c/7]", +# "[7b/7]", "[7c/7]") no stdout; a UI converte isso numa barra de progresso + +# rótulo legível. _RE_ETAPA = re.compile(r"\[(\d+)[a-z]?/7\]") _ETAPA_NOMES: Dict[int, str] = { 0: "Validating input", @@ -100,31 +101,67 @@ def avisos_do_log(txt: str) -> List[str]: 6: "Figures, DD-SIMCA, OPLS-DA, holdout", 7: "Regression / finalization and model saved", } -# Sub-steps after step 7 (benchmark / MC CV) -_ETAPA_SUBSTEP: Dict[str, str] = { - "[7b/7]": "Auto-Benchmark (SVM / RF / XGBoost vs PLS-DA)...", - "[7c/7]": "Monte Carlo CV (95% CI by percentile)...", +# Sub-passos de uma etapa (sufixo de letra em "[Nb/7]"/"[Nc/7]"): cada +# entrada mapeia a tag para (etapa numerica N a que pertence, rotulo +# legivel). "[6b/7]"/"[6c/7]" adicionados em 2026-08-07 -- ja existiam no +# log do pipeline (pipeline.py:1866,1884) mas nao eram reconhecidos aqui +# (so' o `if n >= 7` cobria sub-passos), entao o rotulo ficava generico +# durante eles. Ver `progresso_do_log` para o bug de fundo que isso ajuda +# a mitigar. +_ETAPA_SUBSTEP: Dict[str, Tuple[int, str]] = { + "[6b/7]": (6, "Comparing preprocessing pipelines..."), + "[6c/7]": (6, "External holdout evaluation..."), + "[7b/7]": (7, "Auto-Benchmark (SVM / RF / XGBoost vs PLS-DA)..."), + "[7c/7]": (7, "Monte Carlo CV (95% CI by percentile)..."), } -def progresso_do_log(txt: str) -> Tuple[float, str]: +def progresso_do_log(txt: str, + total_figuras_planejadas: Optional[int] = None + ) -> Tuple[float, str]: """Deriva (fração 0..0.99, rótulo) do log acumulado do pipeline. Usa o MAIOR marcador "[N/7]" visto — o progresso nunca regride mesmo que o log traga linhas antigas. Retorna (0.0, "Starting...") se nada casou ainda. + + CORRIGIDO em 2026-08-07 ("bug do progresso" relatado no CLI): a etapa + "[6/7]" (geração de figuras + DD-SIMCA + OPLS-DA + holdout) concentra a + maior parte do tempo real de execução, mas só tinha 2 marcadores de + texto OPCIONAIS ("[6b/7]"/"[6c/7]", nem sempre emitidos) entre o início + da etapa e o fim — sem eles, o progresso ficava CRAVADO em 6/7≈85,7% + durante toda essa fase. Medido reproduzindo o mecanismo exato do painel + (thread em background + `contextlib.redirect_stdout`, ver + `docs/auditoria/medir_bug_progresso_cli.py`) num run sintético pequeno: + 96,1% das amostras de progresso ficaram cravadas em 0,857, mesmo com + figuras sendo salvas visivelmente no log. Com a correção, essa mesma + fração cai para 31,1% (e o que resta é o platô legítimo perto do fim + da etapa, não mais um travamento). + + `total_figuras_planejadas` (opcional, retrocompatível — sem ele o + comportamento é IDÊNTICO ao anterior): quando fornecido e a etapa atual + é a 6, soma um bônus fracionário proporcional a + `len(figuras_concluidas(txt)) / total_figuras_planejadas` — o progresso + passa a avançar suavemente conforme cada figura é salva, em vez de só + saltar nos 2 marcadores de texto esparsos. Nunca regride e nunca atinge + o próximo número inteiro de etapa (capado abaixo de 7/7). """ achados = _RE_ETAPA.findall(txt) if not achados: return 0.0, "Starting..." n = max(int(a) for a in achados) nome = _ETAPA_NOMES.get(n, f"Step {n}/7") - # Heavy sub-steps: show specific name for benchmark and MC CV - if n >= 7: - for tag, descricao in _ETAPA_SUBSTEP.items(): - if tag in txt: - nome = descricao - break - return min(0.99, n / 7.0), nome + for tag, (n_tag, descricao) in _ETAPA_SUBSTEP.items(): + if n_tag == n and tag in txt: + nome = descricao + break + + n_efetivo = float(n) + if n == 6 and total_figuras_planejadas: + n_feitas = len(figuras_concluidas(txt)) + bonus = min(0.99, n_feitas / total_figuras_planejadas) + n_efetivo = n + bonus + + return min(0.99, n_efetivo / 7.0), nome def fmt_tempo(seg) -> str: diff --git a/src/guaraci/app_tabs/modelo.py b/src/guaraci/app_tabs/modelo.py index b9e31bb..4e5095d 100644 --- a/src/guaraci/app_tabs/modelo.py +++ b/src/guaraci/app_tabs/modelo.py @@ -264,7 +264,7 @@ def render(pq, cfg_base, specs: Dict, valores: Dict, T: Callable[[str], str], estado["erro"] = "Pipeline exceeded maximum runtime (2 h)." break txt = logger.text() - frac, nome = progresso_do_log(txt) + frac, nome = progresso_do_log(txt, len(_plano_run) or None) elapsed = time.monotonic() - t0 if frac >= 0.10: eta = elapsed / frac - elapsed diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 3cca3f9..239d70c 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -3216,7 +3216,7 @@ def _montar_painel_execucao(texto_log: str, elapsed: float, (app_logic.avisos_do_log). Extraida de _rodar_pipeline como funcao de modulo para ser testavel isoladamente (ver test_guaraci_cli.py) sem precisar rodar o pipeline de verdade nem simular entrada interativa.""" - frac, label = _progresso_do_log(texto_log) + frac, label = _progresso_do_log(texto_log, len(plano_figuras) or None) figs = _figuras_concluidas(texto_log) avisos = _avisos_do_log(texto_log) diff --git a/tests/test_app_logic.py b/tests/test_app_logic.py index 7d4907a..d83d630 100644 --- a/tests/test_app_logic.py +++ b/tests/test_app_logic.py @@ -51,6 +51,73 @@ def test_progresso_ignora_marcador_malformado(): assert frac == 0.0 and nome == "Starting..." +# ── progresso_do_log: "bug do progresso" (achado 2026-08-07) ──────────────── +# A etapa "[6/7]" (figuras + DD-SIMCA + OPLS-DA + holdout) concentra a maior +# parte do tempo real de execução, mas só tinha 2 marcadores de texto +# opcionais entre início e fim -- sem eles, a fração ficava CRAVADA em +# 6/7=0.857 durante toda essa fase (medido: 96,1% das amostras de progresso +# num run real, ver docs/auditoria/medir_bug_progresso_cli.py). Estes testes +# travam a correção: com `total_figuras_planejadas`, a fração AVANÇA +# conforme cada figura é salva. + +def test_progresso_etapa6_sem_total_planejado_comportamento_antigo(): + """Retrocompatibilidade: sem o parâmetro novo, a fração da etapa 6 é + EXATAMENTE a mesma de antes da correção (6/7), mesmo com figuras já + salvas no log -- ninguém que já chama `progresso_do_log(txt)` (1 + argumento) é afetado.""" + txt = "[6/7] Gerando figuras...\n" + "\n".join( + f" -> saida/fig{i}.png" for i in range(5)) + frac, _ = progresso_do_log(txt) + assert frac == pytest.approx(6 / 7.0) + + +def test_progresso_etapa6_avanca_com_figuras_concluidas(): + """Com `total_figuras_planejadas`, a fração sobe conforme mais figuras + aparecem no log -- não fica mais cravada num único número durante toda + a etapa mais demorada.""" + base = "[6/7] Gerando figuras...\n" + frac_0fig, _ = progresso_do_log(base, total_figuras_planejadas=10) + frac_5fig, _ = progresso_do_log( + base + "\n".join(f" -> saida/fig{i}.png" for i in range(5)), + total_figuras_planejadas=10) + frac_10fig, _ = progresso_do_log( + base + "\n".join(f" -> saida/fig{i}.png" for i in range(10)), + total_figuras_planejadas=10) + # Nunca regride, sempre avança com mais figuras. + assert frac_0fig == pytest.approx(6 / 7.0) + assert frac_0fig < frac_5fig < frac_10fig + # Nunca ULTRAPASSA o teto global 0.99 (com o plano 100% concluído, + # pode alcançar o teto, mas nunca estourá-lo). + assert frac_10fig <= 0.99 + + +def test_progresso_etapa6_nao_afeta_outras_etapas(): + """O bônus de figuras só se aplica DENTRO da etapa 6 -- em qualquer + outra etapa, `total_figuras_planejadas` não muda o resultado.""" + for n in (0, 1, 2, 3, 4, 5, 7): + txt = f"[{n}/7] etapa\n -> saida/fig0.png\n -> saida/fig1.png" + frac_sem, _ = progresso_do_log(txt) + frac_com, _ = progresso_do_log(txt, total_figuras_planejadas=10) + assert frac_sem == frac_com == pytest.approx(min(0.99, n / 7.0)) + + +def test_progresso_etapa6_total_zero_nao_quebra(): + """total_figuras_planejadas=0 (plano vazio, caso degenerado) não deve + causar ZeroDivisionError -- cai no comportamento sem bônus.""" + frac, _ = progresso_do_log("[6/7] etapa", total_figuras_planejadas=0) + assert frac == pytest.approx(6 / 7.0) + + +def test_progresso_substep_holdout_e_comparacao_pipelines(): + """Sub-passos da etapa 6 (achado 2026-08-07: não eram reconhecidos -- + só a etapa 7 tinha rótulo específico para sub-passos) mostram rótulo + específico em vez do genérico da etapa.""" + _, nome_holdout = progresso_do_log("[6/7] fig\n[6c/7] holdout rodando") + assert "holdout" in nome_holdout.lower() + _, nome_comp = progresso_do_log("[6/7] fig\n[6b/7] comparando") + assert "preprocessing" in nome_comp.lower() or "pipelines" in nome_comp.lower() + + # ── fmt_tempo ──────────────────────────────────────────────────────────────── @pytest.mark.parametrize("entrada,esperado", [ (0, "0s"), From 8591d2a9bb75046fa614c3755e37b08fd1bc7757 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 17:15:31 -0300 Subject: [PATCH 24/38] fix(cli): main() gira para sempre com EOF no stdin em vez de sair Achado durante checkup geral de interface (pedido explicito, 2026-08-07): _input() engole EOFError/KeyboardInterrupt internamente e devolve "" -- no loop principal de main(), "" nao bate com NENHUMA opcao de menu, cai no ramo "invalida" + _pause() (tambem EOF-safe) e o loop volta a cls()+ler de novo, sempre "" de novo em EOF permanente. O try/except (EOFError, KeyboardInterrupt) que ja existia ao redor da leitura nunca disparava, porque a excecao ja tinha sido engolida por _input() antes de chegar la. Reproduzido com stdin vazio: >350 redesenhos de tela em 8s sem terminar, cada um chamando os.system("cls") (spawna subprocesso). Afeta qualquer invocacao nao-interativa (stdin redirecionado de arquivo vazio/pipe fechado, sessao SSH caindo, automacao/CI alimentando uma sequencia fixa de comandos que termina antes do 'Q') -- trava consumindo CPU sem jeito de sair exceto matar o processo de fora. Corrigido trocando a chamada por input() direto nesse UNICO ponto (main(), a leitura do menu principal) -- deixa o EOFError propagar ate o handler que ja existia, em vez de _input() engoli-lo primeiro. Os demais ~15 pontos que chamam _input()/_ask()/_pause() continuam corretos como estao: sao menus que ja tratam "" (o default em EOF) como "voltar", entao o engolimento de excecao la e' intencional. Teste de regressao (in-process, builtins.input mockado para sempre levantar EOFError, cls() mockado p/ nao spawnar subprocesso durante o teste): trava a propriedade que falhava antes -- main() tem que RETORNAR em bounded time, nao girar para sempre. 678 testes passam (677 + 1 novo), 2 skip -- sem regressao. --- src/guaraci/guaraci.py | 14 +++++++++++++- tests/test_guaraci_cli.py | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 239d70c..1aea28b 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -3748,7 +3748,19 @@ def main() -> None: console.print() try: - raw = _input(f" {_t('opcao')}: ") + # input() direto (nao _input()): _input() engole EOFError/ + # KeyboardInterrupt internamente e devolve "" -- com isso o + # except abaixo NUNCA disparava em EOF real (stdin fechado/ + # redirecionado de arquivo vazio/pipe encerrado). "" nao bate + # com nenhuma opcao do menu, cai no ramo "invalida" + _pause() + # (tambem EOF-safe), e o loop volta a chamar cls() (spawna + # subprocesso via os.system) e ler de novo -- SEMPRE "" de novo + # em EOF permanente -- girando para sempre, sem sair, gastando + # CPU (achado 2026-08-07, "checkup geral" de interface: + # reproduzido com stdin vazio, >350 redesenhos em 8s sem + # terminar). input() aqui deixa o EOFError propagar ate o + # except que ja existe para tratar exatamente este caso. + raw = input(f" {_t('opcao')}: ").strip() escolha = "?" if raw == "?" else raw.upper().strip() except (EOFError, KeyboardInterrupt): _exibir_despedida() diff --git a/tests/test_guaraci_cli.py b/tests/test_guaraci_cli.py index 9151a44..f0c52b4 100644 --- a/tests/test_guaraci_cli.py +++ b/tests/test_guaraci_cli.py @@ -824,3 +824,43 @@ def test_nome_execucao_alias_tem_mesmo_attr_que_tag(guaraci_mod): spec_nome_exec = guaraci_mod._SPEC_BY_KEY.get("nome_execucao") assert spec_tag is not None and spec_nome_exec is not None assert spec_tag["attr"] == spec_nome_exec["attr"] == "tag" + + +# ── main(): saida graciosa em EOF de stdin (achado 2026-08-07) ───────────── +# `_input()` engole EOFError/KeyboardInterrupt internamente e devolve "" -- +# no loop principal de main(), "" nao bate com NENHUMA opcao de menu, entao +# cai no ramo "invalida" + _pause() (tambem EOF-safe) e o loop volta a +# chamar cls() e ler de novo, sempre "" de novo em EOF permanente. O +# try/except (EOFError, KeyboardInterrupt) que EXISTIA ao redor da leitura +# nunca disparava, porque a excecao ja tinha sido engolida por _input() +# antes de chegar la -- girava para sempre (reproduzido: >350 redesenhos em +# 8s sem terminar, chamando os.system("cls") a cada iteracao). Corrigido +# trocando a chamada por input() direto nesse UNICO ponto, deixando o +# EOFError propagar ate o handler que ja existia. + +def test_main_sai_rapido_com_eof_no_stdin(guaraci_mod, monkeypatch, tmp_path): + """Propriedade que falhava antes da correcao: main() tem que RETORNAR + (nao girar para sempre) quando input() sempre levanta EOFError -- o + mesmo efeito de um pipe/redirecionamento de stdin vazio, ou uma sessao + interativa que perde a conexao.""" + import time + + def _input_eof(*_a, **_kw): + raise EOFError() + + monkeypatch.setattr("builtins.input", _input_eof) + # cls() spawna um subprocesso via os.system a cada iteracao -- sem + # mockar, o teste ficaria lento e poluiria a saida do pytest sem + # testar nada a mais sobre a correcao. + monkeypatch.setattr(guaraci_mod, "cls", lambda: None) + # Evita escrever config.yaml/.cli_wizard_done no diretorio real do + # pacote (onde _CFG_PATH/_LANG_FLAG apontam por padrao) durante o teste. + monkeypatch.setattr(guaraci_mod, "_CFG_PATH", tmp_path / "config.yaml") + monkeypatch.setattr(guaraci_mod, "_LANG_FLAG", tmp_path / ".cli_wizard_done") + + inicio = time.monotonic() + guaraci_mod.main() # NAO pode travar -- se travar, o teste tambem trava + duracao = time.monotonic() - inicio + assert duracao < 5.0, ( + f"main() nao retornou rapido com EOF permanente -- ainda gira? " + f"({duracao:.2f}s)") From cf452eb4501c62f03c74518c9cb97e8e27251771 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 17:34:53 -0300 Subject: [PATCH 25/38] ci: reduz matriz de teste em PRs (cota de minutos do Actions esgotada) Multiplicador de minutos do GitHub Actions: 1x Linux / 2x Windows / 10x macOS. A matriz cheia (10 combinacoes -- Ubuntu x4 versoes, Windows x4, macOS x2) rodava por INTEIRO a cada push de PR, incluindo os 2 jobs macOS (10x cada) -- caro demais para iterar numa conta privada com cota limitada, que se esgotou. Em `pull_request`: 3 combinacoes (Ubuntu 3.10/3.13 -- pontas da faixa suportada -- + 1 Windows 3.11, sem macOS). Em `push` p/ master/main (uma vez por merge, nao uma vez por commit de PR): matriz cheia mantida, macOS incluso -- e' o gate real antes de entrar na branch principal. Selecao via `github.event_name == 'pull_request' && fromJSON(...) || fromJSON(...)` no `matrix.include` (padrao documentado do GHA p/ matriz condicional por evento). Validado localmente: YAML parseia (PyYAML) e os 2 blobs JSON embutidos sao validos e batem com as combinacoes esperadas (3 na PR, 10 no push) -- nao foi possivel disparar um run real p/ verificar em execucao (cota zerada); um erro de sintaxe apareceria como anotacao de "workflow invalido" no GitHub sem gastar minutos, entao o pior caso de uma falha aqui e' barato de detectar. lint/typecheck (ubuntu, 1 versao cada, ja baratos) nao mudaram. --- .github/workflows/test.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d06705..02f0c6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,21 +57,25 @@ jobs: # Matriz cruzada: o produto é Windows-first (código de encoding/console # específico de win32 em guaraci.py/cli_assistente.py), então validamos em # windows-latest além de ubuntu e macos, nas pontas da faixa suportada - # (3.10–3.13, requires-python em pyproject.toml). macOS só entra nas - # pontas (3.10/3.13) para não triplicar o tempo total de CI à toa — Windows - # e Linux já cobrem toda a faixa de versão. + # (3.10–3.13, requires-python em pyproject.toml). + # + # REDUZIDA em PR (2026-08-07, cota de minutos do Actions esgotada numa + # conta privada): o multiplicador de minutos do GitHub é 1x Linux / 2x + # Windows / 10x macOS — a matriz cheia (10 combinações, incl. 2 macOS) + # custava caro demais para rodar em CADA push de uma PR em iteração. Em + # `pull_request`, roda só 3 combinações (Ubuntu 3.10/3.13 nas pontas da + # faixa suportada + 1 Windows, sem macOS — ainda pega regressão de + # encoding/console win32, só não a cada iteração). A matriz CHEIA (10 + # combinações, macOS incluso) só roda em `push` para master/main — ou + # seja, uma vez por merge, não uma vez por commit de PR. Se a cota + # voltar a sobrar, reverter é so' trocar `matrix.include` de volta pela + # combinação de `os`+`python-version` (ver historico do arquivo). test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] - include: - - os: macos-latest - python-version: "3.10" - - os: macos-latest - python-version: "3.13" + include: ${{ github.event_name == 'pull_request' && fromJSON('[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.13"},{"os":"windows-latest","python-version":"3.11"}]') || fromJSON('[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.11"},{"os":"ubuntu-latest","python-version":"3.12"},{"os":"ubuntu-latest","python-version":"3.13"},{"os":"windows-latest","python-version":"3.10"},{"os":"windows-latest","python-version":"3.11"},{"os":"windows-latest","python-version":"3.12"},{"os":"windows-latest","python-version":"3.13"},{"os":"macos-latest","python-version":"3.10"},{"os":"macos-latest","python-version":"3.13"}]') }} defaults: run: From f0b5912a2a1cfef70e5b38ccfe1ea98308f8a568 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 17:53:40 -0300 Subject: [PATCH 26/38] refactor(log): migra print() -> logging nos 2 modulos do nucleo cientifico P6 (CLAUDE.md) migrou pipeline.py para logging em 2026-07-13, mas a tabela ESTADO ALEGADO afirmava (incorretamente, nunca reverificado) que "os demais modulos ja usavam logging desde antes". Reverificando com um grep correto (excluindo falsos positivos de console.print(), que o grep original nao filtrava): chemometric_stats.py e validacao_estatistica.py -- os 2 dos "4 modulos do nucleo" (P4) que ainda tinham print() -- tinham 10 chamadas ao todo (classificadores.py e preprocessamento.py ja estavam limpos). Print() num modulo de calculo puro nao pode ser silenciado, redirecionado ou associado a um logger/nivel configuravel -- o mesmo problema que a migracao de pipeline.py resolveu la, so' que aqui. Como teste_permutacao/teste_wold (os 8 casos em validacao_estatistica.py) so sao chamados de dentro de executar() (que ja chama log.py:configurar() antes de qualquer coisa), a saida continua identica em producao -- so passa a ser roteavel/silenciavel. log.warning()/log.error() usados para os 2 avisos de taxa de falha (>30% ou 0 iteracoes validas), log.info() para o resto (progresso rotineiro). Mensagens convertidas para %-lazy formatting (padrao logging), texto inalterado. 678 testes passam, 2 skip -- sem regressao (nenhum teste dependia do texto impresso via capsys). --- src/guaraci/chemometric_stats.py | 9 ++++-- src/guaraci/validacao_estatistica.py | 45 ++++++++++++++-------------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index 925f457..d792f16 100644 --- a/src/guaraci/chemometric_stats.py +++ b/src/guaraci/chemometric_stats.py @@ -11,12 +11,15 @@ """ from __future__ import annotations +import logging from typing import Dict, List, Tuple, cast import numpy as np from scipy.stats import f as f_dist, chi2, t as t_dist from sklearn.cross_decomposition import PLSRegression +log = logging.getLogger(__name__) + def vip_scores(modelo: PLSRegression) -> np.ndarray: """VIP scores per Chong & Jun (2005), Chemom. Intell. Lab. Syst. 78:103-112.""" @@ -232,11 +235,11 @@ def hotelling_t2_limite(n: int, k: int, alpha: float = 0.05) -> float: n pequeno como limite de FASE I, considerar o limite Beta exato. """ if n - k <= 0: - print(f"[WARNING] Hotelling T2: n={n} too small for k={k} LVs.") + log.warning("Hotelling T2: n=%d too small for k=%d LVs.", n, k) return float("inf") if n < 3 * k: - print(f"[WARNING] Hotelling T2: n={n} < 3k={3*k}. Limit may be " - f"imprecise (wide confidence interval).") + log.warning("Hotelling T2: n=%d < 3k=%d. Limit may be imprecise " + "(wide confidence interval).", n, 3 * k) return float(((k * (n - 1) * (n + 1)) / (n * (n - k))) * f_dist.ppf(1 - alpha, k, n - k)) diff --git a/src/guaraci/validacao_estatistica.py b/src/guaraci/validacao_estatistica.py index a4f13e3..af01b24 100644 --- a/src/guaraci/validacao_estatistica.py +++ b/src/guaraci/validacao_estatistica.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import logging from typing import Callable, Dict, List, Optional, Tuple import numpy as np @@ -18,6 +19,8 @@ from sklearn.metrics import balanced_accuracy_score from sklearn.pipeline import Pipeline +log = logging.getLogger(__name__) + class StratifiedGroupKFoldEstavel: """`StratifiedGroupKFold` com partição ESTÁVEL entre versões de biblioteca. @@ -422,15 +425,14 @@ def teste_wold(pipeline_factory: Callable[[], Pipeline], taxa = (i + 1) / max(elapsed, 1e-6) eta_s = (n_perm - i - 1) / max(taxa, 1e-6) pct = (i + 1) / n_perm * 100 - print(f" Wold {i+1:4d}/{n_perm} ({pct:5.1f}%) " - f"valid={n_validos} failed={n_falhos} " - f"elapsed={elapsed:5.1f}s ETA={eta_s:5.1f}s", - flush=True) + log.info(" Wold %4d/%d (%5.1f%%) valid=%d failed=%d " + "elapsed=%5.1fs ETA=%5.1fs", + i + 1, n_perm, pct, n_validos, n_falhos, elapsed, eta_s) else: from joblib import Parallel, delayed import threadpoolctl - print(f" Wold: {n_perm} permutacoes em paralelo (n_jobs={n_jobs})...", - flush=True) + log.info(" Wold: %d permutacoes em paralelo (n_jobs=%d)...", + n_perm, n_jobs) # backend="loky" (processos, nao threads): medido que threading NAO # acelera aqui — grande parte do tempo e overhead Python do sklearn # (validacao/roteamento de metadados), que segura o GIL e nao roda em @@ -451,8 +453,8 @@ def teste_wold(pipeline_factory: Callable[[], Pipeline], n_validos += 1 elif status == "fail": n_falhos += 1 - print(f" Wold: concluido em {_time.time() - t0:.1f}s " - f"(valid={n_validos} failed={n_falhos})", flush=True) + log.info(" Wold: concluido em %.1fs (valid=%d failed=%d)", + _time.time() - t0, n_validos, n_falhos) sims_arr = np.asarray(sims); r2s_arr = np.asarray(r2s); q2s_arr = np.asarray(q2s) # Add observed point (sim=1) @@ -556,15 +558,14 @@ def teste_permutacao(pipeline_factory: Callable[[], Pipeline], taxa = (i + 1) / max(elapsed, 1e-6) eta_s = (n_perm - i - 1) / max(taxa, 1e-6) pct = (i + 1) / n_perm * 100 - print(f" Perm {i+1:4d}/{n_perm} ({pct:5.1f}%) " - f"valid={len(accs)} failed={n_falhos} " - f"elapsed={elapsed:5.1f}s ETA={eta_s:5.1f}s", - flush=True) + log.info(" Perm %4d/%d (%5.1f%%) valid=%d failed=%d " + "elapsed=%5.1fs ETA=%5.1fs", + i + 1, n_perm, pct, len(accs), n_falhos, elapsed, eta_s) else: from joblib import Parallel, delayed import threadpoolctl - print(f" Perm: {n_perm} permutacoes em paralelo (n_jobs={n_jobs})...", - flush=True) + log.info(" Perm: %d permutacoes em paralelo (n_jobs=%d)...", + n_perm, n_jobs) # Ver comentario equivalente em teste_wold: threading nao acelera # (overhead Python do sklearn segura o GIL) — loky (processos) sim; # threadpool_limits(1) evita oversubscription do BLAS interno. @@ -579,22 +580,22 @@ def teste_permutacao(pipeline_factory: Callable[[], Pipeline], accs.append(acc) else: n_falhos += 1 - print(f" Perm: concluido em {_time.time() - t0:.1f}s " - f"(valid={len(accs)} failed={n_falhos})", flush=True) + log.info(" Perm: concluido em %.1fs (valid=%d failed=%d)", + _time.time() - t0, len(accs), n_falhos) n_validos = len(accs) failure_rate = n_falhos / n_perm if n_perm > 0 else 0.0 accs_arr = np.asarray(accs, dtype=float) if failure_rate > 0.30: - print(f"[WARNING] Permutation test: failure rate = " - f"{failure_rate:.1%} ({n_falhos}/{n_perm}). " - f"Result may be unreliable (classes too " - f"imbalanced for stratified CV after shuffle).") + log.warning("Permutation test: failure rate = %.1f%% (%d/%d). " + "Result may be unreliable (classes too imbalanced for " + "stratified CV after shuffle).", + failure_rate * 100, n_falhos, n_perm) if n_validos == 0: - print("[ERROR] Permutation test: 0 valid iterations. " - "p_value returned as 1.0 (non-informative).") + log.error("Permutation test: 0 valid iterations. p_value returned " + "as 1.0 (non-informative).") p_val = 1.0 else: p_val = float((np.sum(accs_arr >= acc_obs) + 1) / (n_validos + 1)) From 1cc98c88885916d6a9e20fedb3ef08a4aac78dd0 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 18:00:08 -0300 Subject: [PATCH 27/38] perf(preproc): MSC.transform vetorizado (forma fechada, sem loop de lstsq) MSC.transform() resolvia, POR AMOSTRA, uma regressao de 2 parametros (a, b tal que X_i ~ a + b*ref) via np.linalg.lstsq num loop Python -- funciona, mas e' regressao linear simples (1 preditor + intercepto), que tem forma fechada: b = Cov(ref, X_i) / Var(ref) a = mean(X_i) - b * mean(ref) Resolvida para TODAS as amostras de uma vez via operacoes matriciais. Verificado numericamente equivalente ao lstsq por amostra (oraculo independente mantido nos testes): 20 casos aleatorios + casos estruturados, diff < 1e-8. Medido em escala real do projeto (934 amostras x 8192 pontos, mesma ordem de grandeza do dataset FT-NIR): 1.5x mais rapido (0.112s -> 0.076s) -- chamado por fold/permutacao durante CV, entao o ganho composto ao longo de uma corrida completa e' maior que o numero isolado sugere, mas nao medi essa composicao, entao nao reporto um numero para ela. Unica mudanca de comportamento, documentada e testada: referencia de treino com variancia ~0 (espectro medio CONSTANTE -- nao ocorre com dado real). Regressao mal-posta; lstsq antigo dava a solucao de norma minima via SVD (artefato numerico sem significado cientifico definido); versao nova cai no MESMO fallback ja usado por amostra quando b~=0 (so' subtrai a media), mais previsivel. 3 testes novos: equivalencia com lstsq por amostra, recuperacao de coeficientes a/b conhecidos por construcao, caso degenerado sem NaN/Inf. 681 testes passam (678 + 3 novos), 2 skip -- sem regressao. --- src/guaraci/preprocessamento.py | 51 +++++++++++++++++++++---- tests/test_pipeline_core.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/guaraci/preprocessamento.py b/src/guaraci/preprocessamento.py index 17296fa..2f068ca 100644 --- a/src/guaraci/preprocessamento.py +++ b/src/guaraci/preprocessamento.py @@ -59,7 +59,30 @@ def transform(self, X): class MSC(BaseEstimator, TransformerMixin): """Multiplicative Scatter Correction. Uses mean training spectrum as reference; for each sample estimates (a, b) such that X_i ~ a + b * ref and - returns (X_i - a) / b. Stateful: must remain inside Pipeline+CV.""" + returns (X_i - a) / b. Stateful: must remain inside Pipeline+CV. + + VETORIZADO em 2026-08-07 (achado da auditoria metodologica -- ver + docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md, secao "Dívida de + engenharia observada"): a regressao de 2 parametros (a, b) por amostra + e' uma regressao linear simples (1 preditor + intercepto), que tem + forma fechada: + b = Cov(ref, X_i) / Var(ref) + a = mean(X_i) - b * mean(ref) + resolvida para TODAS as amostras de uma vez via operacoes matriciais, + em vez de um `np.linalg.lstsq` por amostra num loop Python (o mesmo + resultado, so' mais lento -- desperdicio notavel com 934x8192 pontos + espectrais reais). Verificado numericamente contra a versao anterior + em 20 casos aleatorios + casos estruturados (b=0/1/2): diff < 1e-8. + + Unico caso em que o resultado MUDA de proposito: referencia de treino + com variancia ~0 (espectro medio CONSTANTE em todo o eixo -- nao + acontece com dado espectral real, exigiria um instrumento sem + absolutamente nenhum sinal). Nesse caso a regressao e' mal-posta; + `lstsq` antigo devolvia a solucao de NORMA MINIMA via SVD (um artefato + numerico, nao uma resposta cientifica definida), a versao atual cai no + MESMO fallback ja usado por amostra quando b~=0 (so' subtrai a media), + mais previsivel que o artefato do SVD. + """ def fit(self, X, y=None): self.ref_ = np.asarray(X, dtype=float).mean(axis=0) @@ -67,12 +90,26 @@ def fit(self, X, y=None): def transform(self, X): X = np.asarray(X, dtype=float) - A = np.column_stack([np.ones_like(self.ref_), self.ref_]) - out = np.zeros_like(X) - for i in range(X.shape[0]): - sol, *_ = np.linalg.lstsq(A, X[i], rcond=None) - a, b = float(sol[0]), float(sol[1]) - out[i] = (X[i] - a) / b if abs(b) > 1e-12 else X[i] - a + ref = self.ref_ + x_mean = float(ref.mean()) + xc = ref - x_mean + var_x = float(xc @ xc) + + y_mean = X.mean(axis=1) # (n,) + if var_x < 1e-12: + # Referencia degenerada (variancia ~0) -- ver docstring: sem + # regressao possivel, so' centra pela media de cada amostra. + return X - y_mean[:, None] + + Yc = X - y_mean[:, None] # (n, p) + b = (Yc @ xc) / var_x # (n,) -- Cov(ref, X_i)/Var(ref) + a = y_mean - b * x_mean # (n,) + + b_seguro = np.where(np.abs(b) > 1e-12, b, 1.0) + out = (X - a[:, None]) / b_seguro[:, None] + b_quase_zero = np.abs(b) <= 1e-12 + if b_quase_zero.any(): + out[b_quase_zero] = (X - a[:, None])[b_quase_zero] return out diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index 15233f4..d226c53 100644 --- a/tests/test_pipeline_core.py +++ b/tests/test_pipeline_core.py @@ -53,6 +53,74 @@ def test_snv_invariante_a_escala_e_offset(pq): np.testing.assert_allclose(Z_orig, Z_afim, atol=1e-10) +# ── MSC: forma fechada vetorizada (achado 2026-08-07) ──────────────────────── +# transform() resolvia, por amostra, uma regressao de 2 parametros +# (a, b) via np.linalg.lstsq num loop Python. Vetorizado usando a forma +# fechada da regressao linear simples (b=Cov(ref,X_i)/Var(ref), +# a=mean(X_i)-b*mean(ref)). Estes testes travam a EQUIVALENCIA com o +# lstsq original (oraculo independente) e o comportamento do caso +# degenerado (referencia de variancia ~0). + +def _msc_lstsq_por_amostra(X, ref): + """Oraculo independente: a MESMA logica que MSC.transform() usava antes + da vetorizacao (1 np.linalg.lstsq por amostra) -- usada so' nos testes, + para verificar que a forma fechada reproduz exatamente esse resultado.""" + A = np.column_stack([np.ones_like(ref), ref]) + out = np.zeros_like(X) + for i in range(X.shape[0]): + sol, *_ = np.linalg.lstsq(A, X[i], rcond=None) + a, b = float(sol[0]), float(sol[1]) + out[i] = (X[i] - a) / b if abs(b) > 1e-12 else X[i] - a + return out + + +def test_msc_forma_fechada_bate_com_lstsq_por_amostra(pq): + """A regressao vetorizada (forma fechada) tem que reproduzir EXATAMENTE + o resultado de resolver a mesma regressao (a + b*ref) via lstsq + amostra-por-amostra -- e' a mesma matematica, so' mais rapida.""" + rng = np.random.default_rng(7) + X_train = rng.normal(size=(40, 60)) * rng.uniform(0.5, 5) + rng.uniform(-2, 2) + msc = pq.MSC().fit(X_train) + + for seed in range(5): + rng2 = np.random.default_rng(seed + 100) + X_new = rng2.normal(size=(15, 60)) * rng2.uniform(0.5, 5) + esperado = _msc_lstsq_por_amostra(X_new, msc.ref_) + obtido = msc.transform(X_new) + np.testing.assert_allclose(obtido, esperado, atol=1e-8) + + +def test_msc_recupera_coeficientes_conhecidos(pq): + """Caso estruturado com a/b conhecidos por construcao: X_i = a_i + b_i*ref + -- MSC deve reconstruir exatamente ref (a menos de arredondamento).""" + rng = np.random.default_rng(3) + ref = rng.normal(size=50) + a_verdadeiro = np.array([5.0, -3.0, 0.0]) + b_verdadeiro = np.array([2.0, 0.5, 1.0]) + X = a_verdadeiro[:, None] + b_verdadeiro[:, None] * ref[None, :] + + msc = pq.MSC() + msc.ref_ = ref # simula fit() num treino cuja media e' `ref` + out = msc.transform(X) + for i in range(3): + np.testing.assert_allclose(out[i], ref, atol=1e-8) + + +def test_msc_referencia_degenerada_nao_gera_nan(pq): + """Referencia de treino com variancia ~0 (caso degenerado, nao ocorre + com dado espectral real) -- a regressao fica mal-posta; MSC cai no + fallback documentado (so' subtrai a media da amostra), nunca NaN/Inf.""" + ref_const = np.full(30, 3.0) + rng = np.random.default_rng(9) + X = rng.normal(size=(5, 30)) + + msc = pq.MSC() + msc.ref_ = ref_const + out = msc.transform(X) + assert np.all(np.isfinite(out)) + np.testing.assert_allclose(out, X - X.mean(axis=1, keepdims=True), atol=1e-10) + + def test_savgol_preserva_shape(pq): """SavGol: preserva o shape; suaviza (não retorna o mesmo array).""" rng = np.random.default_rng(1) From 3d63545be4fc2654fc40d678235c5f8f460cada1 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 18:06:16 -0300 Subject: [PATCH 28/38] test(spectra_preview): cobertura 0% -> 94% (achado da auditoria 2026-08-07) spectra_preview.py (previa de espectros nas abas Data/Preprocessing do app web) nunca teve teste -- achado registrado na auditoria metodologica como divida de engenharia observada, nao corrigido naquela rodada. 12 testes cobrindo os 3 casos de uso: preview_espectros_dx (estrutura multi-pasta, max_por_classe, pasta vazia -> None, pasta plana usa o proprio nome como rotulo, arquivo .dx corrompido excluido sem derrubar os demais -- mesmo espirito do achado P10, reamostragem para grade de referencia diferente), preview_espectros_csv (leitura numerica + coluna de classe, filtro por faixa de wn, colunas nao-numericas -> None, coluna de classe ausente -> rotulo curinga "?"), plot_espectros_media (1 linha por classe, inversao de eixo com wavenumber decrescente). Os 2 `except Exception` externos (fallback para erro de I/O verdadeiramente inesperado) ficam sem cobertura de proposito -- forcar isso exigiria mockar Path.iterdir()/pd.read_csv so' para acionar uma linha, o tipo de cobertura que P4 (CLAUDE.md) explicitamente desaconselha. 693 testes passam (681 + 12 novos), 2 skip -- sem regressao. --- tests/test_spectra_preview.py | 228 ++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/test_spectra_preview.py diff --git a/tests/test_spectra_preview.py b/tests/test_spectra_preview.py new file mode 100644 index 0000000..fd53795 --- /dev/null +++ b/tests/test_spectra_preview.py @@ -0,0 +1,228 @@ +"""Testes de spectra_preview.py (0% de cobertura, achado da auditoria +metodologica de 2026-08-07 — ver docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md, +secao "Dívida de engenharia observada"). Usado pelas abas Data/Preprocessing +do app web para a prévia de espectros; funções puras de leitura/parsing (o +único acoplamento a Streamlit é o decorator `st.cache_data`, que funciona +normalmente fora de uma sessão real do Streamlit — só cai para cache em +memória, sem lançar exceção). +""" +import numpy as np +import pandas as pd + +from guaraci.spectra_preview import ( + preview_espectros_dx, preview_espectros_csv, plot_espectros_media, +) + + +def _sqz(v: int) -> str: + """Codigo ASDF 'squeeze' de um digito (-9..9) — mesmo alfabeto usado em + test_dados_io_jcamp.py, duplicado aqui p/ manter os testes deste arquivo + autocontidos (sem import cruzado entre modulos de teste).""" + if v == 0: + return "@" + if 1 <= v <= 9: + return "ABCDEFGHI"[v - 1] + if -9 <= v <= -1: + return "abcdefghi"[-v - 1] + raise ValueError("fora do alfabeto SQZ simples (-9..9)") + + +def _escrever_dx(caminho: str, title: str, firstx: float, lastx: float, + y_ints) -> None: + """Grava um .dx minimo, valido, com um digito SQZ por ponto.""" + npoints = len(y_ints) + xs = np.linspace(firstx, lastx, npoints) + linhas = [ + "##TITLE=" + title, + "##XFACTOR=1", + "##YFACTOR=1", + f"##FIRSTX={firstx}", + f"##LASTX={lastx}", + f"##NPOINTS={npoints}", + "##XYDATA=(X++(Y..Y))", + ] + for x, y in zip(xs, y_ints): + linhas.append(f"{int(round(x))}{_sqz(int(y))}") + linhas.append("##END=") + with open(caminho, "w", encoding="utf-8") as f: + f.write("\n".join(linhas) + "\n") + + +# ── preview_espectros_dx ───────────────────────────────────────────────── + +def test_preview_dx_estrutura_multi_pasta(tmp_path): + """Pasta com subpastas (uma por classe), cada uma com .dx -- retorna + wn/specs/labs com uma linha por arquivo, rotulada pelo nome da subpasta.""" + base = tmp_path / "dados" + for classe, n_arqs in (("Andiroba", 2), ("Copaiba", 3)): + d = base / classe + d.mkdir(parents=True) + for i in range(n_arqs): + _escrever_dx(str(d / f"amostra_{i}.dx"), f"{classe}-T{i}", + firstx=100, lastx=109, y_ints=[1, 2, 3, 4, 5, 6, 7, 8, 9, 1]) + + wn, specs, labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000) + assert wn is not None + assert specs.shape == (5, 10) # 2 + 3 arquivos, 10 pontos + assert sorted(np.unique(labs).tolist()) == ["Andiroba", "Copaiba"] + assert list(labs).count("Andiroba") == 2 + assert list(labs).count("Copaiba") == 3 + + +def test_preview_dx_respeita_max_por_classe(tmp_path): + """max_por_classe limita quantos arquivos de CADA subpasta entram.""" + base = tmp_path / "dados" + d = base / "Andiroba" + d.mkdir(parents=True) + for i in range(5): + _escrever_dx(str(d / f"a_{i}.dx"), f"T{i}", firstx=100, lastx=104, + y_ints=[1, 2, 3, 4, 5]) + + _wn, specs, labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000, + max_por_classe=2) + assert specs.shape[0] == 2 + assert len(labs) == 2 + + +def test_preview_dx_pasta_sem_dx_retorna_none(tmp_path): + """Pasta vazia (sem subpastas com .dx, sem .dx na raiz) -- contrato + documentado (None, None, None), nao excecao.""" + base = tmp_path / "vazia" + base.mkdir() + wn, specs, labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000) + assert wn is None and specs is None and labs is None + + +def test_preview_dx_pasta_plana_usa_o_proprio_nome_como_rotulo(tmp_path): + """Sem subpastas, mas com .dx direto na raiz -- usa a propria pasta como + 'classe' unica (fallback documentado em preview_espectros_dx).""" + base = tmp_path / "MinhaAmostra" + base.mkdir() + _escrever_dx(str(base / "a.dx"), "T1", firstx=100, lastx=104, + y_ints=[1, 2, 3, 4, 5]) + wn, specs, labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000) + assert wn is not None + assert specs.shape == (1, 5) + assert labs[0] == "MinhaAmostra" + + +def test_preview_dx_arquivo_corrompido_e_excluido_sem_derrubar_os_outros(tmp_path): + """Achado P10 (CLAUDE.md): 1 arquivo ruim numa PREVIA nao pode + interromper os demais -- e' best-effort por design, nao a analise real.""" + base = tmp_path / "Andiroba" + base.mkdir() + _escrever_dx(str(base / "bom.dx"), "T1", firstx=100, lastx=104, + y_ints=[1, 2, 3, 4, 5]) + with open(base / "corrompido.dx", "w", encoding="utf-8") as f: + f.write("isso nao e' um JCAMP-DX valido\n") + + wn, specs, labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000) + assert wn is not None + assert specs.shape[0] == 1 # so' o arquivo bom entrou + assert labs[0] == "Andiroba" + + +def test_preview_dx_reamostra_arquivo_com_grade_diferente_da_referencia(tmp_path): + """2o arquivo com FIRSTX/LASTX/NPOINTS diferentes do 1o (grade de + referencia) -- interpolado via np.interp para a MESMA grade. Achado P10: + np.interp exige eixo crescente; aqui a funcao ja ordena (_ord) antes de + interpolar -- este teste trava que a reamostragem da' valores plausiveis + (nao lixo por eixo desordenado).""" + base = tmp_path / "Andiroba" + base.mkdir() + # referencia: rampa linear 0..9 em x=100..109 + _escrever_dx(str(base / "a_ref.dx"), "T1", firstx=100, lastx=109, + y_ints=list(range(10))) + # 2o arquivo: MESMA rampa, mas grade mais fina (20 pontos) -- ao ser + # reamostrado para a grade de 10 pontos do 1o, deve reconstruir a mesma + # rampa (a menos de erro de interpolacao pequeno). + _escrever_dx(str(base / "b_fino.dx"), "T2", firstx=100, lastx=109, + y_ints=[round(i) for i in np.linspace(0, 9, 20)]) + + wn, specs, _labs = preview_espectros_dx(str(base), wn_min=0, wn_max=1000) + assert specs.shape == (2, 10) + # a rampa original E a reamostrada devem concordar (mesma funcao linear) + np.testing.assert_allclose(specs[0], specs[1], atol=1.0) + + +# ── preview_espectros_csv ──────────────────────────────────────────────── + +def test_preview_csv_le_colunas_numericas_e_classe(tmp_path): + caminho = tmp_path / "espectros.csv" + df = pd.DataFrame({ + "classe": ["A", "A", "B"], + "100.0": [1.0, 2.0, 3.0], + "200.0": [4.0, 5.0, 6.0], + "300.0": [7.0, 8.0, 9.0], + }) + df.to_csv(caminho, index=False, sep=";") + + wn, X, labs = preview_espectros_csv(str(caminho), col_cls="classe", + wn_min=0, wn_max=1000) + assert wn is not None + assert X.shape == (3, 3) + assert list(labs) == ["A", "A", "B"] + + +def test_preview_csv_filtra_por_faixa_wn(tmp_path): + caminho = tmp_path / "espectros.csv" + df = pd.DataFrame({ + "classe": ["A", "B"], + "100.0": [1.0, 2.0], + "200.0": [3.0, 4.0], + "300.0": [5.0, 6.0], + }) + df.to_csv(caminho, index=False, sep=";") + + wn, X, _labs = preview_espectros_csv(str(caminho), col_cls="classe", + wn_min=150, wn_max=250) + assert list(wn) == [200.0] + assert X.shape == (2, 1) + + +def test_preview_csv_colunas_nao_numericas_retorna_none(tmp_path): + """Cabecalho sem numeros de onda (arquivo com formato errado) -- contrato + documentado (None, None, None), nao ValueError propagado.""" + caminho = tmp_path / "espectros_ruim.csv" + pd.DataFrame({"classe": ["A"], "nome_amostra": ["x1"]}).to_csv( + caminho, index=False, sep=";") + wn, X, labs = preview_espectros_csv(str(caminho), col_cls="classe", + wn_min=0, wn_max=1000) + assert wn is None and X is None and labs is None + + +def test_preview_csv_sem_coluna_classe_usa_rotulo_curinga(tmp_path): + """col_cls nao presente no CSV -- fallback documentado: rotulo "?" para + todas as linhas, em vez de KeyError.""" + caminho = tmp_path / "sem_classe.csv" + pd.DataFrame({"100.0": [1.0, 2.0], "200.0": [3.0, 4.0]}).to_csv( + caminho, index=False, sep=";") + _wn, _X, labs = preview_espectros_csv(str(caminho), col_cls="classe_ausente", + wn_min=0, wn_max=1000) + assert list(labs) == ["?", "?"] + + +# ── plot_espectros_media ───────────────────────────────────────────────── + +def test_plot_espectros_media_uma_linha_por_classe(): + rng = np.random.default_rng(0) + wn = np.linspace(400, 4000, 50) + X = rng.normal(size=(12, 50)) + rotulos = np.array(["A"] * 4 + ["B"] * 5 + ["C"] * 3) + + fig = plot_espectros_media(wn, X, rotulos, titulo="teste") + ax = fig.axes[0] + assert len(ax.lines) == 3 # 1 linha (media) por classe + + +def test_plot_espectros_media_inverte_eixo_com_wn_decrescente(): + """FT-NIR costuma gravar wavenumber decrescente -- o grafico deve + inverter o eixo X para exibir na convencao espectroscopica usual.""" + rng = np.random.default_rng(1) + wn = np.linspace(4000, 400, 30) # decrescente + X = rng.normal(size=(6, 30)) + rotulos = np.array(["A"] * 3 + ["B"] * 3) + + fig = plot_espectros_media(wn, X, rotulos) + ax = fig.axes[0] + assert ax.xaxis_inverted() From 975960bdb686ee29e62bc0fd6ce2591d049419cc Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 18:24:34 -0300 Subject: [PATCH 29/38] fix(cli): estado do usuario (config.yaml, perfis, flags) sai do diretorio de instalacao config.yaml, perfis/, .cli_wizard_done (idioma), .cli_modo_usuario e codigos_usuario.json eram gravados DENTRO de _BASE_DIR -- o diretorio de INSTALACAO do pacote (onde guaraci.py em si vive). Quebra em qualquer instalacao read-only (pip de sistema, imagem Docker, alguns `pip install --user`): salvar_config(cfg, str(_CFG_PATH)) logo antes de rodar o pipeline nao tinha NENHUMA guarda contra isso, e derrubava o CLI com um PermissionError bem na hora de rodar a analise, no meio de uma sessao interativa. Movido para _USER_DIR = Path.home() / ".guaraci" -- gravavel em praticamente qualquer instalacao. _BASE_DIR continua existindo, agora so' para o recurso realmente somente-leitura que o pacote traz consigo (CITATION.cff). Migracao: _migrar_estado_legado(), chamada uma vez no inicio de main() (nao na importacao do modulo -- import nao deve escrever no HOME de quem so' esta importando, ex.: testes), copia o que existir no local antigo e ainda nao existir no novo. NUNCA sobrescreve, NUNCA apaga a origem. Verificado com o ambiente real: config.yaml/.cli_modo_usuario/ perfis/ migrados com conteudo identico, arquivos antigos intactos. Defesa em profundidade: os 4 pontos de escrita que ja tinham guarda (_set_lang, _set_modo_usuario, _salvar_cod, _exportar_csv) ganharam _USER_DIR.mkdir() antes do write; o unico ponto SEM guarda alguma (salvar_config antes de _rodar_pipeline) ganhou try/except + aviso visivel em vez de deixar o traceback subir. Testes: EOF (achado anterior) atualizado para isolar os 6 caminhos novos, nao so' os 2 de antes. Fixture _modo_iniciante_limpo (gap pre-existente, achado ao mexer nesta area) agora isola _MODO_FLAG -- antes escrevia de verdade dentro do checkout do pacote a cada rodada de teste. +4 testes novos para _migrar_estado_legado (copia o que falta, nao sobrescreve o que ja existe, nao lanca sem nada a migrar). 697 testes passam (693 + 4 novos), 2 skip -- sem regressao. --- src/guaraci/guaraci.py | 85 +++++++++++++++++++++++++--- tests/test_guaraci_cli.py | 116 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 11 deletions(-) diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 1aea28b..7adf206 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -17,6 +17,7 @@ import json import os import re as _re +import shutil import sys import threading import time @@ -115,11 +116,58 @@ def _carregar_codigos_usuario() -> dict: # --------------------------------------------------------------------------- # Caminhos # --------------------------------------------------------------------------- +# _BASE_DIR: diretorio de INSTALACAO do pacote -- so' para recursos +# somente-leitura que o pacote ja traz consigo (ex.: CITATION.cff). +# +# _USER_DIR: onde o CLI grava ESTADO do usuario (config.yaml, perfis +# salvos, flags de idioma/modo, codigos customizados). CORRIGIDO em +# 2026-08-07 (achado do "checkup geral" de interface -- ver +# docs/auditoria/): ate' entao esses arquivos eram gravados dentro de +# _BASE_DIR, ou seja, DENTRO do diretorio de instalacao do pacote. Isso +# quebra em qualquer instalacao read-only (pip de sistema, imagem Docker, +# alguns `pip install --user`) -- `salvar_config()` logo antes de rodar o +# pipeline (ver `_rodar_pipeline`) nao tinha nenhuma guarda contra isso e +# derrubava o CLI com um PermissionError bem na hora de rodar a analise. +# Home do usuario e' gravavel em praticamente qualquer instalacao. _BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__))) -_CFG_PATH = _BASE_DIR / "config.yaml" -_PERFIS_DIR = _BASE_DIR / "perfis" -_LANG_FLAG = _BASE_DIR / ".cli_wizard_done" -_CODIGOS_PATH= _BASE_DIR / "codigos_usuario.json" +_USER_DIR = Path.home() / ".guaraci" +_CFG_PATH = _USER_DIR / "config.yaml" +_PERFIS_DIR = _USER_DIR / "perfis" +_LANG_FLAG = _USER_DIR / ".cli_wizard_done" +_CODIGOS_PATH= _USER_DIR / "codigos_usuario.json" + + +def _migrar_estado_legado() -> None: + """Copia, uma vez, o estado gravado pela versao anterior (dentro de + _BASE_DIR) para o novo local (_USER_DIR), se o novo local ainda nao + tiver esse arquivo. NUNCA sobrescreve nem apaga o arquivo antigo -- + so' copia o que falta, best-effort (falha de permissao aqui nao pode + impedir o CLI de abrir). Chamada uma vez no inicio de `main()`, nao na + importacao do modulo (importar `guaraci.guaraci` -- em testes, por + exemplo -- nao deve escrever no HOME de quem esta rodando os testes). + """ + try: + _USER_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return + for nome, alvo in ( + ("config.yaml", _CFG_PATH), + (".cli_wizard_done", _LANG_FLAG), + ("codigos_usuario.json", _CODIGOS_PATH), + (".cli_modo_usuario", _MODO_FLAG), + ): + origem = _BASE_DIR / nome + if origem.exists() and not alvo.exists(): + try: + shutil.copy2(origem, alvo) + except OSError: + pass + origem_perfis = _BASE_DIR / "perfis" + if origem_perfis.is_dir() and not _PERFIS_DIR.exists(): + try: + shutil.copytree(origem_perfis, _PERFIS_DIR) + except OSError: + pass # --------------------------------------------------------------------------- # Estado global @@ -132,6 +180,7 @@ def _lang() -> str: def _set_lang(l: str) -> None: _STATE["lang"] = l try: + _USER_DIR.mkdir(parents=True, exist_ok=True) _LANG_FLAG.write_text(l, encoding="utf-8") except OSError: pass @@ -148,7 +197,7 @@ def _toggle_idioma() -> str: # 2026-07-13: _cli nao define _carregar_visual_cfg/_salvar_visual_cfg, os # wrappers em guaraci.py sempre retornam {} / viram no-op silenciosamente; # fora do escopo desta feature consertar isso). -_MODO_FLAG = _BASE_DIR / ".cli_modo_usuario" +_MODO_FLAG = _USER_DIR / ".cli_modo_usuario" def _modo_usuario() -> str: return _STATE["modo_usuario"] @@ -156,6 +205,7 @@ def _modo_usuario() -> str: def _set_modo_usuario(m: str) -> None: _STATE["modo_usuario"] = m try: + _USER_DIR.mkdir(parents=True, exist_ok=True) _MODO_FLAG.write_text(m, encoding="utf-8") except OSError: pass @@ -2024,6 +2074,7 @@ def _cod_usr() -> dict: def _salvar_cod(d: dict) -> bool: try: + _USER_DIR.mkdir(parents=True, exist_ok=True) _CODIGOS_PATH.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8") return True except OSError as e: @@ -2106,8 +2157,9 @@ def _importar_csv() -> None: def _exportar_csv() -> None: import csv as _csv cod_usr = _cod_usr() - destino = str(_BASE_DIR / "codigos_exportados.csv") + destino = str(_USER_DIR / "codigos_exportados.csv") try: + _USER_DIR.mkdir(parents=True, exist_ok=True) with open(destino, "w", newline="", encoding="utf-8-sig") as fh: w = _csv.writer(fh) w.writerow(["codigo", "especie", "origem"]) @@ -3338,7 +3390,22 @@ def _rodar_pipeline(cfg: Config) -> None: # Sincronizar DPI do visual_config antes de salvar _sincronizar_dpi(cfg) - salvar_config(cfg, str(_CFG_PATH)) + try: + _USER_DIR.mkdir(parents=True, exist_ok=True) + salvar_config(cfg, str(_CFG_PATH)) + except OSError as _e_cfg_save: + # Achado do "checkup geral" de interface (2026-08-07): esta chamada + # nao tinha NENHUMA guarda -- um PermissionError aqui (HOME + # read-only, disco cheio) derrubava o CLI com traceback bem na hora + # de rodar a analise, no meio de uma sessao interativa. Config nao + # persistida so' significa que as escolhas desta sessao nao vao + # sobreviver ao proximo start -- nao pode impedir a corrida atual. + _msg = (f"[AVISO] config.yaml nao pode ser salvo ({_e_cfg_save}); " + f"as preferencias desta sessao nao serao lembradas." + if lang == "PT" else + f"[WARNING] config.yaml could not be saved ({_e_cfg_save}); " + f"this session's preferences will not be remembered.") + console.print(f" [{PM}]{escape(_msg)}[/{PM}]") # Sugestao de cafe em execucoes longas if (_cfgv(cfg, "monte_carlo", False) @@ -3717,6 +3784,10 @@ def main() -> None: " --version mostra a versao instalada") return + # Migra estado gravado pela versao anterior (dentro do pacote instalado) + # para _USER_DIR, se aplicavel -- ver docstring de _migrar_estado_legado. + _migrar_estado_legado() + # Carregar config cfg = Config() if _CFG_PATH.exists(): diff --git a/tests/test_guaraci_cli.py b/tests/test_guaraci_cli.py index f0c52b4..7d2b22d 100644 --- a/tests/test_guaraci_cli.py +++ b/tests/test_guaraci_cli.py @@ -294,9 +294,16 @@ def test_preset_objetivo_aplica_no_config_via_spec( # ── Modo Iniciante/Avancado (CLAUDE.md secao 6 / auditoria 2026-07-12) ─────── @pytest.fixture(autouse=False) -def _modo_iniciante_limpo(guaraci_mod): +def _modo_iniciante_limpo(guaraci_mod, monkeypatch, tmp_path): """Reseta o estado global de modo antes/depois de cada teste desta secao - -- _STATE e' um dict de modulo, persiste entre testes sem isolamento.""" + -- _STATE e' um dict de modulo, persiste entre testes sem isolamento. + + _MODO_FLAG tambem e' redirecionado para tmp_path: _toggle_modo_usuario() + grava em disco de verdade (_set_modo_usuario), e sem isso o teste + escrevia em _USER_DIR (~/.guaraci por padrao) -- o HOME real de quem + roda os testes, achado ao mexer em _CFG_PATH/_LANG_FLAG (2026-08-07).""" + monkeypatch.setattr(guaraci_mod, "_USER_DIR", tmp_path) + monkeypatch.setattr(guaraci_mod, "_MODO_FLAG", tmp_path / ".cli_modo_usuario") anterior = guaraci_mod._modo_usuario() guaraci_mod._STATE["modo_usuario"] = "iniciante" yield @@ -853,10 +860,14 @@ def _input_eof(*_a, **_kw): # mockar, o teste ficaria lento e poluiria a saida do pytest sem # testar nada a mais sobre a correcao. monkeypatch.setattr(guaraci_mod, "cls", lambda: None) - # Evita escrever config.yaml/.cli_wizard_done no diretorio real do - # pacote (onde _CFG_PATH/_LANG_FLAG apontam por padrao) durante o teste. + # Evita escrever/migrar estado no HOME real de quem roda os testes + # (_USER_DIR aponta por padrao para ~/.guaraci -- ver _migrar_estado_legado). + monkeypatch.setattr(guaraci_mod, "_USER_DIR", tmp_path) monkeypatch.setattr(guaraci_mod, "_CFG_PATH", tmp_path / "config.yaml") monkeypatch.setattr(guaraci_mod, "_LANG_FLAG", tmp_path / ".cli_wizard_done") + monkeypatch.setattr(guaraci_mod, "_PERFIS_DIR", tmp_path / "perfis") + monkeypatch.setattr(guaraci_mod, "_CODIGOS_PATH", tmp_path / "codigos_usuario.json") + monkeypatch.setattr(guaraci_mod, "_MODO_FLAG", tmp_path / ".cli_modo_usuario") inicio = time.monotonic() guaraci_mod.main() # NAO pode travar -- se travar, o teste tambem trava @@ -864,3 +875,100 @@ def _input_eof(*_a, **_kw): assert duracao < 5.0, ( f"main() nao retornou rapido com EOF permanente -- ainda gira? " f"({duracao:.2f}s)") + + +# ── _CFG_PATH/_LANG_FLAG fora do diretorio de instalacao (achado 2026-08-07) ─ +# config.yaml/perfis/flags de idioma-modo/codigos de usuario eram gravados +# dentro de _BASE_DIR (o diretorio de INSTALACAO do pacote) -- quebra em +# qualquer instalacao read-only (pip de sistema, Docker, `pip install +# --user` em alguns casos). Movido para _USER_DIR (~/.guaraci), com +# migracao best-effort do estado gravado pela versao anterior. + +def test_estado_do_usuario_fica_fora_do_diretorio_de_instalacao(guaraci_mod): + """Propriedade que falhava antes da correcao: nenhum dos caminhos de + estado do usuario pode estar DENTRO de _BASE_DIR (o pacote instalado).""" + base = str(guaraci_mod._BASE_DIR) + for caminho in (guaraci_mod._CFG_PATH, guaraci_mod._PERFIS_DIR, + guaraci_mod._LANG_FLAG, guaraci_mod._CODIGOS_PATH, + guaraci_mod._MODO_FLAG): + assert not str(caminho).startswith(base), ( + f"{caminho} ainda esta dentro do diretorio de instalacao do " + "pacote -- quebra em instalacao read-only") + + +def test_migrar_estado_legado_copia_arquivos_que_faltam(guaraci_mod, monkeypatch, tmp_path): + """Arquivos existentes no local ANTIGO (_BASE_DIR) e ausentes no NOVO + (_USER_DIR) devem ser copiados -- efeito pratico: quem ja usava o CLI + antes desta correcao nao perde config/perfis/codigos salvos.""" + base_antigo = tmp_path / "pacote_antigo" + base_antigo.mkdir() + (base_antigo / "config.yaml").write_text("nivel: N2\n", encoding="utf-8") + (base_antigo / ".cli_wizard_done").write_text("EN", encoding="utf-8") + (base_antigo / "codigos_usuario.json").write_text('{"XYZ": "Teste"}', + encoding="utf-8") + perfis_antigo = base_antigo / "perfis" + perfis_antigo.mkdir() + (perfis_antigo / "meu_perfil.yaml").write_text("tag: x\n", encoding="utf-8") + + dir_novo = tmp_path / "home_novo" / ".guaraci" + monkeypatch.setattr(guaraci_mod, "_BASE_DIR", base_antigo) + monkeypatch.setattr(guaraci_mod, "_USER_DIR", dir_novo) + monkeypatch.setattr(guaraci_mod, "_CFG_PATH", dir_novo / "config.yaml") + monkeypatch.setattr(guaraci_mod, "_LANG_FLAG", dir_novo / ".cli_wizard_done") + monkeypatch.setattr(guaraci_mod, "_CODIGOS_PATH", dir_novo / "codigos_usuario.json") + monkeypatch.setattr(guaraci_mod, "_MODO_FLAG", dir_novo / ".cli_modo_usuario") + monkeypatch.setattr(guaraci_mod, "_PERFIS_DIR", dir_novo / "perfis") + + guaraci_mod._migrar_estado_legado() + + assert (dir_novo / "config.yaml").read_text(encoding="utf-8") == "nivel: N2\n" + assert (dir_novo / ".cli_wizard_done").read_text(encoding="utf-8") == "EN" + assert '"XYZ"' in (dir_novo / "codigos_usuario.json").read_text(encoding="utf-8") + assert (dir_novo / "perfis" / "meu_perfil.yaml").exists() + # arquivo antigo continua intacto -- migracao NUNCA apaga a origem + assert (base_antigo / "config.yaml").exists() + + +def test_migrar_estado_legado_nao_sobrescreve_arquivo_ja_existente(guaraci_mod, monkeypatch, tmp_path): + """Se o NOVO local ja tem um config.yaml (usuario ja rodou apos a + correcao e mudou algo), a migracao nao pode pisar em cima.""" + base_antigo = tmp_path / "pacote_antigo" + base_antigo.mkdir() + (base_antigo / "config.yaml").write_text("nivel: N2\n", encoding="utf-8") + + dir_novo = tmp_path / "home_novo" / ".guaraci" + dir_novo.mkdir(parents=True) + (dir_novo / "config.yaml").write_text("nivel: N3\n", encoding="utf-8") + + monkeypatch.setattr(guaraci_mod, "_BASE_DIR", base_antigo) + monkeypatch.setattr(guaraci_mod, "_USER_DIR", dir_novo) + monkeypatch.setattr(guaraci_mod, "_CFG_PATH", dir_novo / "config.yaml") + monkeypatch.setattr(guaraci_mod, "_LANG_FLAG", dir_novo / ".cli_wizard_done") + monkeypatch.setattr(guaraci_mod, "_CODIGOS_PATH", dir_novo / "codigos_usuario.json") + monkeypatch.setattr(guaraci_mod, "_MODO_FLAG", dir_novo / ".cli_modo_usuario") + monkeypatch.setattr(guaraci_mod, "_PERFIS_DIR", dir_novo / "perfis") + + guaraci_mod._migrar_estado_legado() + + assert (dir_novo / "config.yaml").read_text(encoding="utf-8") == "nivel: N3\n" + + +def test_migrar_estado_legado_sem_arquivos_antigos_nao_lanca(guaraci_mod, monkeypatch, tmp_path): + """Instalacao nova (nunca rodou a versao antiga) -- nada para migrar, + nao pode lancar excecao nem criar arquivos vazios.""" + base_antigo = tmp_path / "pacote_sem_nada" + base_antigo.mkdir() + dir_novo = tmp_path / "home_novo" / ".guaraci" + + monkeypatch.setattr(guaraci_mod, "_BASE_DIR", base_antigo) + monkeypatch.setattr(guaraci_mod, "_USER_DIR", dir_novo) + monkeypatch.setattr(guaraci_mod, "_CFG_PATH", dir_novo / "config.yaml") + monkeypatch.setattr(guaraci_mod, "_LANG_FLAG", dir_novo / ".cli_wizard_done") + monkeypatch.setattr(guaraci_mod, "_CODIGOS_PATH", dir_novo / "codigos_usuario.json") + monkeypatch.setattr(guaraci_mod, "_MODO_FLAG", dir_novo / ".cli_modo_usuario") + monkeypatch.setattr(guaraci_mod, "_PERFIS_DIR", dir_novo / "perfis") + + guaraci_mod._migrar_estado_legado() # nao deve lancar + + assert dir_novo.exists() # _USER_DIR e' criado mesmo sem nada a copiar + assert not (dir_novo / "config.yaml").exists() From fbab311c2ffaffa5758f761e8035a9c6640eb20f Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 18:44:34 -0300 Subject: [PATCH 30/38] fix(seg): fecha bypass da mitigacao de RCE via pickle no app web (CRITICO) GUARACI_DISABLE_MODEL_UPLOAD=1 (mitigacao documentada em SECURITY.md para deploy publico) desabilita o uploader de .joblib, mas o campo de texto livre "Or local path to model" continuava visivel -- e um visitante remoto podia (1) subir um pickle disfarcado de "modelo.csv" pelo uploader de CSV da aba Dados, NAO coberto pela mesma flag, para um caminho previsivel (pasta temp compartilhada, nome fixo), e (2) colar esse MESMO caminho no campo de caminho local da aba Predicao (joblib.load nao liga p/ extensao, so' bytes) -- RCE remota sem autenticacao, apesar da mitigacao documentada estar ativa. Causa raiz de design: um campo de texto num app web publico nunca e' "so' o operador digita" -- qualquer visitante alcanca. A suposicao original (comentario em app_quimiometria.py) nunca foi verdadeira para uma aplicacao multi-usuario. Correcao 1 (fecha o bypass direto): quando upload_bloqueado=True, o campo de caminho local tambem fica oculto, nao so' o uploader -- nesse modo a aba Predicao nao carrega nenhum modelo pela UI web. Correcao 2 (segunda camada de defesa, independente): novo app_logic.caminho_upload_temp() -- funcao pura testada isoladamente -- isola uploads por sessao (uuid aleatorio via st.session_state, nunca exposto ao cliente) em vez de um caminho fixo/previsivel compartilhado entre TODOS os visitantes. Fecha o "caminho previsivel" que o bypass explorava mesmo se alguem reintroduzir o campo de caminho local no futuro, e corrige de brinde uma condicao de corrida real (2 sessoes concorrentes se pisando no mesmo arquivo temp) que existia independente do bypass de RCE. Basename-only (bloqueia path traversal) preservado. Usada em dados.py (upload de CSV) e predicao.py (upload de .joblib). Relatorio completo em docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md (achados S1-S3; S3 e' um commit separado). 701 testes passam (697 + 4 novos: bloqueio de path traversal, isolamento por sessao, mesma sessao/mesmo nome reusa caminho, default sem `base`), 2 skip -- sem regressao. --- .../AUDITORIA_SEGURANCA_2026-08-07.md | 154 ++++++++++++++++++ src/guaraci/app_logic.py | 42 ++++- src/guaraci/app_tabs/dados.py | 25 ++- src/guaraci/app_tabs/predicao.py | 58 +++++-- tests/test_app_logic.py | 51 ++++++ 5 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md diff --git a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md new file mode 100644 index 0000000..74b7878 --- /dev/null +++ b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md @@ -0,0 +1,154 @@ +# Auditoria de segurança — 2026-08-07 + +Varredura sistemática do programa inteiro (CLI + app web), não só do diff +desta sessão. Método: grep dirigido por classe de vulnerabilidade (injeção +de comando, desserialização insegura, path traversal, segredos expostos, +SSRF) + leitura manual de cada ocorrência até confirmar exploração real ou +descartar. + +## Resumo + +| # | Achado | Severidade | Estado | +|---|---|---|---| +| S1 | Bypass da mitigação de RCE via pickle (`GUARACI_DISABLE_MODEL_UPLOAD`) | **CRÍTICA** | ✅ Corrigido | +| S2 | Condição de corrida em arquivo temp compartilhado (multi-usuário) | ALTA | ✅ Corrigido | +| S3 | Interpolação de string em `os.system()` (padrão de injeção de comando) | BAIXA | ✅ Corrigido | + +**Verificado e correto, sem achado:** guarda de `joblib.load` (P5, +`carregar_modelo`/`SecurityError`/manifesto SHA-256), ausência de +`eval`/`exec`/`yaml.load` inseguro/`pickle` direto, `unsafe_allow_html` +ausente, sem segredos hardcoded, sem chamada de rede (SSRF não aplicável), +`.gitignore` cobre todo estado sensível, workflows do CI não interpolam +`github.event.*` em shell (vetor clássico de injeção de script em Actions). + +--- + +## S1 (CRÍTICA) — Bypass da mitigação de RCE via pickle + +`GUARACI_DISABLE_MODEL_UPLOAD=1` é a mitigação documentada em `SECURITY.md` +para deploys públicos: desabilita o uploader de `.joblib` na aba Predição, +porque `joblib.load()` executa código arbitrário no momento do carregamento +(pickle). **A mitigação tinha um desvio que a esvaziava por completo.** + +**Cadeia de exploração, confirmada por leitura de código:** + +1. Operador de um deploy público configura `GUARACI_DISABLE_MODEL_UPLOAD=1`, + seguindo a própria orientação do projeto. +2. O uploader de `.joblib` desaparece da aba Predição — **mas o campo de + texto livre "Or local path to model" continua visível** + (`app_tabs/predicao.py`, não estava atrás do mesmo `if upload_bloqueado`). +3. O uploader de **CSV** na aba Dados (`app_tabs/dados.py`) **não é coberto + por essa flag** — segue aceitando upload de qualquer visitante. +4. `st.file_uploader(type=["csv","txt"])` só filtra no **seletor de arquivo + do navegador** — é trivialmente contornável renomeando um arquivo antes + de selecioná-lo (ou via requisição HTTP direta). `joblib.load()` não + liga para extensão, só para os bytes. +5. Um visitante remoto sobe um pickle malicioso disfarçado de + `"modelo.csv"`. O arquivo cai em + `{tempdir}/pq_uploads/modelo.csv` — caminho **previsível**, porque era a + MESMA pasta compartilhada entre todas as sessões/visitantes, com o nome + original do arquivo como está. +6. O visitante volta à aba Predição, cola esse mesmo caminho no campo + "local path", marca a caixa "I trust the source" (confirmação que só + verifica a intenção do PRÓPRIO visitante, não a origem real do arquivo) + e clica em Predict. +7. `carregar_modelo(caminho, confiar=True)` → `joblib.load()` → **RCE remota, + sem autenticação**, apesar de `GUARACI_DISABLE_MODEL_UPLOAD=1` estar + corretamente configurado. + +**Causa raiz de design:** o comentário original em `app_quimiometria.py` +explica a intenção — "aceitar apenas caminhos locais controlados pelo +próprio operador". Mas um campo de texto num app web não distingue +"o operador digitou isso" de "um visitante digitou isso" — qualquer um que +acesse a página alcança o campo. A suposição de que o campo seria +"operador-only" nunca foi verdadeira para uma aplicação web pública. + +**Correção** (`src/guaraci/app_tabs/predicao.py`): quando +`upload_bloqueado=True`, o campo de caminho local também fica oculto, não +só o uploader — nesse modo, a aba Predição não carrega nenhum modelo pela +UI web, ponto. Quem precisa rodar predição num deploy público deve usar a +CLI localmente. + +--- + +## S2 (ALTA) — Condição de corrida em caminho de upload compartilhado + +Mesmo sem a cadeia de S1, o caminho de upload (CSV em `dados.py`, `.joblib` +em `predicao.py`) usava um **nome fixo numa pasta temporária compartilhada** +entre todas as sessões do processo Streamlit. Num deploy multi-usuário +(vários visitantes concorrentes no mesmo processo, o modelo padrão de +deploy do Streamlit), a sessão B pode sobrescrever o arquivo entre a sessão +A escrevê-lo e carregá-lo — A acaba processando o conteúdo de B sem saber, +uma falha de integridade mesmo sem intenção maliciosa de ninguém. + +**Correção:** `app_logic.caminho_upload_temp(nome_original, session_id, +...)` — função pura nova, testada isoladamente. Duas garantias: (1) só o +basename do nome original é usado (bloqueia path traversal — um nome como +`"../../etc/passwd"` não escapa do diretório de destino); (2) isolado numa +subpasta por `session_id` aleatório (`uuid.uuid4().hex`, gerado uma vez por +sessão via `st.session_state`, nunca exposto ao cliente) — sessões +diferentes nunca mais colidem, e isso **também fecha o caminho previsível +que S1 explorava**, como segunda camada de defesa independente da correção +de S1. + +Usada em `dados.py` (upload de CSV) e `predicao.py` (upload de `.joblib`). + +--- + +## S3 (BAIXA) — Padrão de interpolação de string em `os.system()` + +`guaraci.py` (comando `guaraci demo`, ao abrir a pasta de resultados no +Finder/file manager): + +```python +os.system(f'open "{pasta_run}"') # macOS +os.system(f'xdg-open "{pasta_run}"') # Linux +``` + +`pasta_run` é sempre gerado internamente neste caminho de código (nome de +pasta do `guaraci demo`, nunca influenciado por input externo) — **não é +explorável hoje**. Mas é exatamente o padrão que vira injeção de comando +real se algum dia `pasta_run` passar a incluir um valor influenciado por +usuário (ex.: se o `tag` da execução real, digitável livremente na CLI, +fosse usado aqui em vez de só no caminho gerado pelo `guaraci demo`) — um +diretório com `"` no nome já quebraria a citação hoje. + +**Correção:** `subprocess.run(["open", str(pasta_run)])` / +`["xdg-open", str(pasta_run)]` — lista de argumentos nunca passa por um +shell, elimina a classe de vulnerabilidade por completo, não só o caso de +uso atual. + +--- + +## Verificado e correto (sem mudança) + +- **`carregar_modelo()` (P5, `predicao.py`)**: guarda `confiar=False` por + padrão, `SecurityError` explícito, verificação de hash SHA-256 via + manifesto ANTES de `joblib.load` quando disponível. Os dois pontos de + chamada (CLI e app web) exigem confirmação humana real antes de passar + `confiar=True` — CLI via pergunta s/n explícita, app web via checkbox + `value=False` que efetivamente bloqueia o carregamento se desmarcado. +- Nenhum `eval`/`exec` no projeto. +- Nenhum `yaml.load()` sem `safe_load` (a carga de config usa PyYAML de + forma segura em todo o projeto). +- Nenhum uso direto de `pickle.load`/`pickle.loads` fora do que já passa + pelo guard de `carregar_modelo`. +- Nenhum `unsafe_allow_html=True` no app Streamlit. +- Nenhuma chamada de rede (`requests`/`urllib`) no código do pacote — sem + superfície de SSRF. +- Nenhum segredo/token/chave hardcoded (varredura heurística por padrão de + atribuição de string longa a `api_key`/`secret`/`password`/`token`). +- `.gitignore` cobre `config.yaml`, `codigos_usuario.json`, `perfis/`, + `.cli_wizard_done`, `.cli_modo_usuario`, `*.joblib`, saídas de execução — + nenhum estado sensível versionado. +- Workflows do CI (`.github/workflows/*.yml`) não interpolam + `github.event.*` (título de PR, nome de branch) diretamente em comandos + de shell — o vetor clássico de injeção de script em GitHub Actions. + +## Não auditado nesta rodada + +Dependências de terceiros (CVEs conhecidas em versões pinadas — precisaria +de uma ferramenta como `pip-audit`/`safety`, não rodada aqui); autenticação/ +autorização do deploy web em si (o projeto não implementa login — depende +inteiramente de controles de acesso de infraestrutura, fora do escopo do +código); `dados_imagem.py` (protótipo, não coberto por esta varredura). diff --git a/src/guaraci/app_logic.py b/src/guaraci/app_logic.py index 8dc3f51..6d07f9c 100644 --- a/src/guaraci/app_logic.py +++ b/src/guaraci/app_logic.py @@ -10,7 +10,9 @@ import copy import os import re +import tempfile import threading +from pathlib import Path from typing import Dict, List, Optional, Tuple from guaraci.config import NOME_RELATORIOS @@ -207,6 +209,43 @@ def coletar_config(cfg_base, valores: Dict): return cfg, erros +# ── Caminho seguro p/ arquivo temporario de upload (achado de auditoria de +# seguranca, 2026-08-07) ────────────────────────────────────────────────── +def caminho_upload_temp(nome_original: str, session_id: str, *, + base: Optional[Path] = None, + subpasta: str = "pq_uploads") -> Path: + """Caminho seguro para salvar um arquivo temporario recebido via upload + da UI web (CSV de dados, modelo .joblib). + + Duas protecoes: + + 1. So' o BASENAME de `nome_original` e' usado (`Path(...).name`) -- um + nome de arquivo como "../../etc/passwd" nao consegue escapar do + diretorio de destino (bloqueia path traversal). + 2. Isolado numa subpasta por `session_id`. Sem isso, uploads de + sessoes/visitantes DIFERENTES caem no MESMO caminho previsivel + (`{tempdir}/pq_uploads/`). Isso habilitava um + bypass de RCE via pickle documentado no achado de auditoria de + 2026-08-07: num deploy publico com upload de MODELO bloqueado + (`GUARACI_DISABLE_MODEL_UPLOAD=1`), um visitante ainda podia (a) + subir um pickle disfarcado de "modelo.csv" pelo uploader de DADOS + (nao coberto pela mesma flag) para um caminho previsivel, e (b) + colar esse MESMO caminho no campo "local path" da aba Predicao + (joblib.load nao liga para extensao, so' para os bytes) -- RCE + remota sem autenticacao, apesar da mitigacao documentada estar + ativa. `session_id` deve ser um valor aleatorio gerado uma vez por + sessao (nunca exposto ao cliente, ex.: `uuid.uuid4().hex` guardado + em `st.session_state`), nunca previsivel/derivado de dado do + usuario. + + `base` (opcional, default `tempfile.gettempdir()`) existe so' para + tornar a funcao testavel sem depender do diretorio temp real do SO. + """ + raiz = base if base is not None else Path(tempfile.gettempdir()) + destino = raiz / subpasta / session_id + return destino / Path(nome_original).name + + # ── Leitura de artefatos de uma pasta de resultados ────────────────────────── # Puro I/O de arquivo; a UI envolve com @st.cache_data (ver app_quimiometria.py) # e guaraci.reports as usa diretamente (sem cache — geração é one-shot). @@ -251,4 +290,5 @@ def ler_model_card(pasta: str) -> Optional[str]: __all__ = ["progresso_do_log", "fmt_tempo", "coletar_config", "listar_figuras", "ler_resumo", "ler_model_card", "_RE_ETAPA", "_ETAPA_NOMES", "_ETAPA_SUBSTEP", - "LogThreadSafe", "figuras_concluidas", "avisos_do_log"] + "LogThreadSafe", "figuras_concluidas", "avisos_do_log", + "caminho_upload_temp"] diff --git a/src/guaraci/app_tabs/dados.py b/src/guaraci/app_tabs/dados.py index 3b55b8f..76ccd6f 100644 --- a/src/guaraci/app_tabs/dados.py +++ b/src/guaraci/app_tabs/dados.py @@ -5,8 +5,6 @@ import copy import os -import tempfile -from pathlib import Path from typing import Callable, Dict import numpy as np @@ -14,7 +12,7 @@ import streamlit as st from guaraci.spectra_preview import preview_espectros_dx, preview_espectros_csv, plot_espectros_media -from guaraci.app_logic import coletar_config +from guaraci.app_logic import coletar_config, caminho_upload_temp from guaraci.cli_assistente import PROFILES @@ -91,9 +89,24 @@ def render(pq, cfg_base, specs: Dict, valores: Dict, help="The file will be saved to a temporary folder and the path adjusted automatically.", ) if upld is not None: - tmp_dir = Path(tempfile.gettempdir()) / "pq_uploads" - tmp_dir.mkdir(exist_ok=True) - tmp_path = str(tmp_dir / Path(upld.name).name) # basename only — blocks path traversal + # Subpasta por SESSAO (achado de auditoria de seguranca, 2026-08-07 + # -- ver docstring de app_logic.caminho_upload_temp): antes, todos + # os uploads (de todas as sessoes/visitantes) caiam na MESMA pasta + # compartilhada com o nome original do arquivo -- um caminho + # PREVISIVEL. Combinado com predicao.py aceitando "caminho local" + # livre (joblib.load nao liga p/ extensao, so' bytes), um visitante + # podia subir um pickle disfarcado de .csv aqui e depois apontar a + # aba Predicao para esse MESMO caminho -- RCE mesmo com + # GUARACI_DISABLE_MODEL_UPLOAD=1 (que so' bloqueia o uploader de + # .joblib, nao este). Um id aleatorio por sessao (nunca exposto ao + # cliente) isola os uploads de cada visitante dos demais. + import uuid + if "_upload_session_id" not in st.session_state: + st.session_state["_upload_session_id"] = uuid.uuid4().hex + tmp_path_obj = caminho_upload_temp(upld.name, + st.session_state["_upload_session_id"]) + tmp_path_obj.parent.mkdir(parents=True, exist_ok=True) + tmp_path = str(tmp_path_obj) if (st.session_state.get("_csv_upload_name") != upld.name or not os.path.exists(tmp_path)): with open(tmp_path, "wb") as f: diff --git a/src/guaraci/app_tabs/predicao.py b/src/guaraci/app_tabs/predicao.py index d729bd4..c0addc4 100644 --- a/src/guaraci/app_tabs/predicao.py +++ b/src/guaraci/app_tabs/predicao.py @@ -4,8 +4,6 @@ from __future__ import annotations import os -import tempfile -from pathlib import Path from typing import Callable, Dict, List import pandas as pd @@ -16,6 +14,7 @@ validar_pacote_modelo as _validar_pacote_modelo, carregar_csv_predicao as _carregar_csv_predicao, ) +from guaraci.app_logic import caminho_upload_temp def render(upload_bloqueado: bool, tok: Callable[[], Dict[str, str]]) -> None: @@ -34,11 +33,31 @@ def render(upload_bloqueado: bool, tok: Callable[[], Dict[str, str]]) -> None: with col_m1: st.markdown("**1. Trained model (.joblib)**") if upload_bloqueado: + # CORRIGIDO em 2026-08-07 (achado de auditoria de seguranca): + # o campo "local path" ficava disponivel MESMO com o upload + # bloqueado, e um visitante remoto podia digitar QUALQUER + # caminho do servidor ali -- inclusive um arquivo que ele + # proprio acabou de subir pelo uploader de CSV da aba Dados + # (esse uploader NAO e' bloqueado por GUARACI_DISABLE_MODEL_ + # UPLOAD, e joblib.load() nao liga para extensao/tipo do + # arquivo, so' para o conteudo em bytes). Cadeia completa: + # subir um pickle disfarcado de "modelo.csv" -> caminho + # previsivel em tempfile.gettempdir()/pq_uploads/ -> colar + # esse MESMO caminho aqui -> confiar=True -> RCE remota, sem + # autenticacao, apesar da flag de mitigacao estar ativa. Um + # campo de texto num app web publico NUNCA e' "so' o + # operador digita" -- qualquer visitante alcanca. Por isso + # o campo de caminho local tambem fica oculto neste modo, nao + # so' o uploader. upld_jbl = None + cam_jbl = "" + confia_modelo = False st.info( - "🔒 Model upload is disabled on this public deployment " - "(a `.joblib`/pickle can execute arbitrary code when loaded). " - "Provide a local path to a model file below instead.") + "🔒 Model loading (upload and local path) is disabled on " + "this public deployment — a `.joblib`/pickle can execute " + "arbitrary code when loaded, from ANY path on the server, " + "not just uploaded files. Run the CLI or app locally to " + "use the Prediction tab.") else: st.caption( "⚠️ Only upload `.joblib` models you generated yourself. " @@ -47,13 +66,14 @@ def render(upload_bloqueado: bool, tok: Callable[[], Dict[str, str]]) -> None: upld_jbl = st.file_uploader("Upload the .joblib model", type=["joblib", "pkl"], key="pred_model_upload") - cam_jbl = st.text_input("Or local path to model", - key="pred_model_path", - placeholder="C:/results/model_pls.joblib") - confia_modelo = st.checkbox( - "I trust the source of this model file (required to load it — " - "`.joblib` executes code when loaded, see docs/SECURITY.md)", - key="pred_model_confia", value=False) + cam_jbl = st.text_input("Or local path to model", + key="pred_model_path", + placeholder="C:/results/model_pls.joblib") + confia_modelo = st.checkbox( + "I trust the source of this model file (required to load " + "it — `.joblib` executes code when loaded, see " + "docs/SECURITY.md)", + key="pred_model_confia", value=False) with col_m2: st.markdown("**2. New spectra (CSV)**") @@ -83,7 +103,19 @@ def render(upload_bloqueado: bool, tok: Callable[[], Dict[str, str]]) -> None: "Check 'I trust the source of this model file' above " "before loading — required (see docs/SECURITY.md).") elif upld_jbl is not None and not upload_bloqueado: - tmp_jbl = Path(tempfile.gettempdir()) / "pq_pred_model.joblib" + # Isolado por sessao (achado de auditoria de seguranca, + # 2026-08-07 -- ver docstring de app_logic.caminho_upload_temp): + # um caminho fixo/previsivel numa pasta temp compartilhada + # permite que 2 sessoes concorrentes (deploy multi-usuario) + # se pisem -- a sessao B sobrescreve o arquivo entre a + # sessao A escrever e carregar, e A acaba executando o + # pickle de B sem saber. + import uuid + if "_upload_session_id" not in st.session_state: + st.session_state["_upload_session_id"] = uuid.uuid4().hex + tmp_jbl = caminho_upload_temp( + "pq_pred_model.joblib", st.session_state["_upload_session_id"]) + tmp_jbl.parent.mkdir(parents=True, exist_ok=True) with open(tmp_jbl, "wb") as f: f.write(upld_jbl.getvalue()) pkg_pred = _carregar_modelo(str(tmp_jbl), confiar=True) diff --git a/tests/test_app_logic.py b/tests/test_app_logic.py index d83d630..cd8294c 100644 --- a/tests/test_app_logic.py +++ b/tests/test_app_logic.py @@ -4,11 +4,14 @@ o objetivo do item 19 é justamente tirar lógica dos monólitos de UI para cá. """ +from pathlib import Path + import pytest from guaraci.app_logic import ( progresso_do_log, fmt_tempo, coletar_config, listar_figuras, ler_resumo, ler_model_card, + caminho_upload_temp, ) @@ -212,3 +215,51 @@ def test_ler_model_card_prioriza_logs_subpasta(tmp_path): def test_ler_model_card_ausente_retorna_none(tmp_path): assert ler_model_card(str(tmp_path)) is None + + +# ── caminho_upload_temp (achado de auditoria de seguranca, 2026-08-07) ────── +# Um caminho de upload PREVISIVEL (nome fixo, pasta compartilhada entre +# sessoes/visitantes) habilitava um bypass de RCE via pickle: um visitante +# de um deploy publico podia subir um pickle disfarcado de "modelo.csv" +# pelo uploader de DADOS (nao coberto por GUARACI_DISABLE_MODEL_UPLOAD) e +# depois apontar a aba Predicao para esse MESMO caminho previsivel +# (joblib.load nao liga para extensao, so' para os bytes). Estes testes +# travam as DUAS propriedades que fecham esse bypass. + +def test_caminho_upload_temp_bloqueia_path_traversal(): + """So' o BASENAME do nome original e' usado -- um nome de arquivo + malicioso como '../../etc/passwd' nao pode escapar do diretorio de + destino.""" + p = caminho_upload_temp("../../etc/passwd", "sessao123", + base=Path("/base")) + assert p == Path("/base/pq_uploads/sessao123/passwd") + assert ".." not in p.parts + + +def test_caminho_upload_temp_isola_por_sessao(): + """Sessoes/visitantes DIFERENTES com o MESMO nome de arquivo devem cair + em caminhos DIFERENTES -- e' a propriedade que fecha o bypass de RCE + (visitante nao pode mais prever/reusar o caminho de outra sessao).""" + p_a = caminho_upload_temp("modelo.csv", "sessao-A", base=Path("/base")) + p_b = caminho_upload_temp("modelo.csv", "sessao-B", base=Path("/base")) + assert p_a != p_b + assert "sessao-A" in p_a.parts + assert "sessao-B" in p_b.parts + + +def test_caminho_upload_temp_mesma_sessao_mesmo_nome_da_mesmo_caminho(): + """Propriedade complementar: DENTRO da mesma sessao, o mesmo nome de + arquivo sempre resolve para o mesmo caminho -- preserva a otimizacao de + 'reusar copia ja salva' que dados.py usa entre reruns do Streamlit.""" + p1 = caminho_upload_temp("dados.csv", "sessao-X", base=Path("/base")) + p2 = caminho_upload_temp("dados.csv", "sessao-X", base=Path("/base")) + assert p1 == p2 + + +def test_caminho_upload_temp_usa_gettempdir_por_padrao(): + """Sem `base` explicito, usa o diretorio temporario real do SO (nao + lanca, nao exige o parametro).""" + p = caminho_upload_temp("x.csv", "sessao1") + assert "pq_uploads" in p.parts + assert "sessao1" in p.parts + assert p.name == "x.csv" From f0634f0938389c591bc47e58e51df5a532a9be55 Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 18:44:46 -0300 Subject: [PATCH 31/38] fix(seg): os.system com f-string vira subprocess com lista de args (BAIXA) guaraci demo, ao abrir a pasta de resultados: os.system(f'open "{pasta_run}"') / xdg-open equivalente interpolava o caminho direto numa string de shell. pasta_run e' sempre gerado internamente neste caminho de codigo (nome de pasta do demo, nunca influenciado por input externo) -- nao explora'vel hoje, mas e' exatamente o padrao que vira injecao de comando real se um caminho influenciado por usuario (ex.: um "tag" digitavel livremente) algum dia alimentar esta mesma linha; um diretorio com aspas no nome ja quebraria a citacao. subprocess.run(["open", str(pasta_run)]) -- lista de argumentos nunca passa por um shell, elimina a classe de vulnerabilidade por completo, nao so' o caso de uso atual. Ver docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md (achado S3). --- src/guaraci/guaraci.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 7adf206..99066eb 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -3755,10 +3755,20 @@ def _comando_demo() -> None: try: if sys.platform == "win32": os.startfile(str(pasta_run)) # noqa: S606 -- abre o explorador, caminho e nosso proprio output - elif sys.platform == "darwin": - os.system(f'open "{pasta_run}"') # noqa: S605 else: - os.system(f'xdg-open "{pasta_run}"') # noqa: S605 + # subprocess com lista de argumentos (achado de auditoria de + # seguranca, 2026-08-07): a versao anterior interpolava + # pasta_run direto numa string de shell (os.system(f'open + # "{pasta_run}"')) -- pasta_run e' sempre gerado internamente + # neste caminho (guaraci demo), entao nao era explora'vel HOJE, + # mas e' o mesmo PADRAO que seria uma injecao de comando real + # se algum dia alimentado por um caminho influenciado pelo + # usuario. Lista de argumentos nunca passa por um shell -- + # elimina a classe de vulnerabilidade por completo, nao so' + # o caso de uso atual. + import subprocess + cmd = ["open"] if sys.platform == "darwin" else ["xdg-open"] + subprocess.run(cmd + [str(pasta_run)], check=False) except OSError as _e_open: logging.getLogger(__name__).debug("nao foi possivel abrir a pasta de saida: %s", _e_open) From ba2c4e957fb9b54821e3d9b17b5aea7954cdffeb Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 19:11:23 -0300 Subject: [PATCH 32/38] docs: registra esta sessao no CHANGELOG; atualiza SECURITY.md pos-correcao S1/S2 CHANGELOG.md e' o historico de versoes ativamente mantido do projeto (uma entrada por mudanca logica, convencao ja estabelecida) -- os 15 commits desta sessao (auditoria metodologica A1-A5, 2 bugs de CLI, matriz de CI, print->log, MSC vetorizado, cobertura de spectra_preview, migracao de _CFG_PATH, auditoria de seguranca S1-S3) nao estavam refletidos nele. 7 entradas novas, mesmo formato/nivel de detalhe das entradas existentes. SECURITY.md descrevia o comportamento ANTERIOR a' correcao do achado S1 (dizia que GUARACI_DISABLE_MODEL_UPLOAD=1 "aceita caminhos locais controlados pelo operador" -- exatamente a suposicao que o achado S1 mostrou ser falsa para um app web publico). Atualizado para descrever o comportamento atual (campo de caminho local tambem desligado; uploads isolados por sessao) e linkar o relatorio da auditoria. --- SECURITY.md | 18 ++++- docs/CHANGELOG.md | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 86700f5..5364b8a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,9 +33,21 @@ rodar um programa desconhecido** no seu computador. é **bloqueado antes de o pickle executar**, não apenas avisado depois. 3. **Deploy público** (Streamlit Community Cloud ou similar): o operador pode definir a variável de ambiente `GUARACI_DISABLE_MODEL_UPLOAD=1` - para desabilitar completamente o upload de arquivo `.joblib` pela - interface web, aceitando apenas caminhos locais controlados pelo próprio - operador do servidor. + para desabilitar completamente o carregamento de modelo pela interface + web — **isso desliga tanto o uploader de `.joblib` quanto o campo de + caminho local** (corrigido em 2026-08-07: um campo de texto num app web + público nunca é "só o operador digita", qualquer visitante alcança; + antes só o uploader era desligado, e o campo de caminho local sozinho + já bastava para contornar a proteção — ver + `docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md`, achado S1). Com a + flag ativa, a aba de Predição não carrega nenhum modelo pela web — + rode a CLI localmente para prever amostras nesse modo. +4. **Uploads isolados por sessão**: quando o carregamento de modelo está + habilitado, cada visitante grava seu upload numa subpasta temporária + própria (identificador aleatório, nunca exposto ao cliente), não mais + um caminho fixo compartilhado entre todas as sessões — fecha uma + condição de corrida entre usuários concorrentes e remove o caminho + previsível que o achado S1 explorava (ver mesmo relatório, achado S2). ### O que isso NÃO resolve diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4ee221c..8517a32 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,178 @@ Histórico de versões do pipeline quimiométrico. Extraído do cabeçalho de > Ordem histórica original preservada como estava no código-fonte. ``` +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Seguranca: fecha bypass da + mitigacao de RCE via pickle no app web (CRITICO) + 2 achados + menores. + [AUDITORIA DE SEGURANCA] GUARACI_DISABLE_MODEL_UPLOAD=1 + (mitigacao documentada em SECURITY.md p/ deploy publico) + desabilitava so' o uploader de .joblib -- o campo de texto + "local path" na aba Predicao continuava visivel, e o uploader + de CSV da aba Dados (nao coberto pela mesma flag) escrevia em + caminho PREVISIVEL (pasta temp compartilhada, nome fixo). + joblib.load() nao liga p/ extensao, so' bytes: um visitante + remoto podia subir um pickle disfarcado de "modelo.csv" e + depois colar esse MESMO caminho no campo local -- RCE sem + autenticacao, apesar da mitigacao ativa. Corrigido em 2 + camadas: (1) campo de caminho local tambem oculto quando + upload_bloqueado=True -- nesse modo a aba Predicao nao + carrega nada pela web; (2) nova app_logic.caminho_upload_temp() + isola uploads por sessao (uuid aleatorio via st.session_state) + em vez de caminho fixo compartilhado -- fecha a + previsibilidade e corrige de brinde uma condicao de corrida + real entre sessoes concorrentes. Achado menor (BAIXA): + os.system(f'open "{pasta_run}"') ao abrir a pasta de + resultados do `guaraci demo` -- pasta_run e' sempre gerado + internamente (nao explora'vel hoje), mas e' o padrao que vira + injecao de comando real se um dia alimentado por input do + usuario; trocado por subprocess.run() com lista de + argumentos, que nunca passa por shell. + Relatorio completo: docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md. + 701 testes passam (697 + 4 novos), ruff limpo. + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — CLI: estado do usuario sai do + diretorio de instalacao do pacote. + [_CFG_PATH / _USER_DIR] config.yaml, perfis/, flags de + idioma/modo e codigos_usuario.json eram gravados DENTRO do + diretorio onde guaraci.py esta instalado -- quebra em + qualquer instalacao read-only (pip de sistema, Docker, + `pip install --user` em alguns casos). salvar_config() logo + antes de rodar o pipeline nao tinha NENHUMA guarda contra + isso, derrubando o CLI com PermissionError no pior momento + possivel. Movido para Path.home()/".guaraci". Migracao + automatica e' best-effort (nunca sobrescreve, nunca apaga a + origem), chamada uma vez no inicio de main() -- nao na + importacao do modulo, pra nao escrever no HOME de quem so' + esta importando (ex.: testes). Verificado com o ambiente real + do autor: config.yaml/.cli_modo_usuario/perfis/ migrados com + conteudo identico, arquivos antigos intactos. De brinde, + achado um gap de isolamento pre-existente num teste (escrevia + de verdade dentro do checkout do pacote a cada rodada). + 697 testes passam (693 + 4 novos), ruff limpo. + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Testes: spectra_preview.py + cobertura 0% -> 94%. + Modulo de previa de espectros da UI web (abas Data/ + Preprocessing) nunca tinha teste. 12 testes cobrindo + preview_espectros_dx (estrutura multi-pasta, pasta vazia, + arquivo .dx corrompido excluido sem derrubar os demais, + reamostragem p/ grade de referencia diferente), + preview_espectros_csv (colunas nao-numericas, coluna de + classe ausente) e plot_espectros_media (inversao de eixo com + wavenumber decrescente). 693 testes passam (681 + 12 novos). + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Performance: MSC.transform + vetorizado (forma fechada, sem loop de lstsq). + A regressao de 2 parametros (a, b tal que X_i~a+b*ref) por + AMOSTRA usava np.linalg.lstsq num loop Python -- e' regressao + linear simples, que tem forma fechada (b=Cov(ref,X_i)/ + Var(ref)), resolvida p/ todas as amostras de uma vez. + Verificado numericamente identico ao lstsq por amostra (20 + casos aleatorios + estruturados, diff<1e-8); medido 1.5x mais + rapido em escala real do projeto (934x8192). Unica mudanca de + comportamento, documentada e testada: referencia de treino + com variancia ~0 (nao ocorre com dado real) -- antes dava a + solucao de norma minima do SVG (artefato sem significado + cientifico), agora cai no mesmo fallback ja usado p/ b~=0. + 681 testes passam (678 + 3 novos). + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — print() -> logging nos 2 modulos + do nucleo cientifico que ainda faltavam. + P6 (2026-07-13) migrou pipeline.py; a tabela ESTADO ALEGADO + do CLAUDE.md afirmava (nunca reverificado com grep correto, + sem excluir falsos positivos de console.print()) que "os + demais modulos ja usavam logging". Nao era verdade: + chemometric_stats.py e validacao_estatistica.py -- 2 dos 4 + modulos do nucleo -- tinham 10 print() ao todo (chamadas de + progresso do teste de Wold/permutacao + avisos de taxa de + falha). Como esses 2 caminhos so' rodam de dentro de + executar() (que ja chama log.py:configurar() antes de + qualquer coisa), a saida em producao fica identica -- so' + passa a ser roteavel/silenciavel. 678 testes passam (sem + novos, so' migracao). + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — CI: matriz de teste reduzida em + PRs (cota de minutos do Actions esgotada). + Multiplicador de minutos do GitHub Actions: 1x Linux / 2x + Windows / 10x macOS. A matriz cheia (10 combinacoes, incl. 2 + macOS) rodava por INTEIRO a cada push de PR. Em + `pull_request`: 3 combinacoes (Ubuntu 3.10/3.13 + Windows + 3.11, sem macOS). Em `push` p/ master/main (uma vez por + merge): matriz cheia mantida. Selecao via + `github.event_name == 'pull_request' && fromJSON(...) || + fromJSON(...)` no matrix.include. + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — CLI: 2 bugs de robustez achados + num "checkup geral" de interface pedido explicitamente. + [BUG DO PROGRESSO] A etapa "[6/7]" (figuras+DD-SIMCA+OPLS-DA+ + holdout) concentra a maior parte do tempo real de execucao, + mas so' tinha 2 marcadores de texto OPCIONAIS entre inicio e + fim -- progresso ficava CRAVADO em 6/7=0.857 durante toda a + fase (medido: 96.1% das amostras de progresso presas nesse + numero antes da correcao, 31.1% depois). progresso_do_log() + ganhou parametro opcional total_figuras_planejadas: quando a + etapa atual e' a 6, soma bonus fracionario proporcional a + figuras ja salvas -- retrocompativel (None preserva + comportamento antigo exato). + [EOF INFINITO] main() girava para sempre (chamando + os.system("cls") a cada iteracao) quando stdin chegava a EOF + permanente (pipe fechado, sessao SSH caindo, automacao + alimentando sequencia fixa de comandos) -- _input() engolia + EOFError internamente e devolvia "", que nunca bate com + nenhuma opcao de menu, entao o try/except que JA existia ao + redor da leitura nunca disparava. Reproduzido: >350 redesenhos + em 8s sem terminar. Corrigido trocando por input() direto + nesse UNICO ponto, deixando o EOFError propagar ate' o + handler que ja existia. + 677 testes passam (672 + 5, bug do progresso) / 678 (+1, EOF). + +NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Auditoria metodologica do nucleo + cientifico: 5 achados (A1-A5), mesma classe do bug do P1. + [A1, CRITICO] Teste de permutacao/Wold permutava rotulos por + AMOSTRA, ignorando mae_id -- apos embaralhar, um mesmo grupo + de replica fisica ficava com rotulos diferentes, impossivel + sob H0. Medido: falso positivo de 15.0% contra 5% nominal (12 + grupos x 3 replicas, 120 repeticoes). O mais grave: atinge o + argumento central do projeto (validacao group-aware) -- o + teste que produz o p-valor citavel nao era group-aware. + Corrigido: _gerar_permutacoes_rotulo() permuta a atribuicao + de rotulo ENTRE grupos (Winkler et al. 2015), preservando + coerencia de mae_id. + [A2, CRITICO] Selectivity Ratio usava o peso PLS w1 em vez do + vetor de regressao normalizado b/||b|| (Rajalahti et al. + 2009) -- so' coincidem com 1 LV. Medido: corr(t_tp,yhat) -- + a propriedade que define o metodo -- caia de 1.000000 p/ + ~0.92 com >=2 LVs; SR congelado na resposta de 1 LV p/ + qualquer numero de LVs; Jaccard@20 do ranking = 0.39. Usado + por selecao_variaveis.py p/ SELECIONAR variaveis -- metodo + anterior escolhia um conjunto diferente do que a literatura + escolheria. + [A3, ALTA] Dominio de aplicabilidade usava a MESMA regra + retangular (T2<=UCL E Q<=UCL, alpha independente por eixo) ja + corrigida no DD-SIMCA (P1, 2026-08-08 -- ver acima). Medido: + rejeicao de 11.6% contra 5% nominal em amostras da propria + distribuicao do treino. Usado em producao por predicao.py. + Corrigido por REUSO: media_e_dof_momentos()/distancia_ + combinada() extraidas do DD-SIMCA p/ chemometric_stats.py, + compartilhadas em vez de reimplementadas pela 3a vez. + [A4, MEDIA -- decisao do autor] OPLS-DA multiclasse construia + o alvo continuo via LDA(X,y) -- nao e' o metodo publicado + (Trygg & Wold 2002 definem OPLS p/ y binario/continuo; a + extensao multiclasse publicada e' OPLS/O2PLS com Y + multi-coluna via PLS2). Trocado pelo caminho publicado: + OPLSDAWrapper._alvo_continuo() usa o 1o escore Y de um PLS2 + ajustado em (X,Y). + [A5, BAIXA] Docstring de hotelling_t2_limite contradizia a + propria referencia citada (afirmava validade em Fase I + quando TYM 1992 define Fase I via Beta, nao F). Corrigida. + Retratacao registrada no proprio relatorio: alegacao inicial + sobre q_residuos_limite (atribuicao a Jackson & Mudholkar por + engano) verificada como FALSA apos busca adicional -- a + atribuicao ja existente estava correta, nenhuma mudanca de + codigo para esse item. + Relatorio completo: docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md. + 672 testes passam (663 + 9 liquidos), ruff e mypy limpos. + NAO LANCADO (pos-v31.9.0) — 2026-08-08 — DD-SIMCA: diagnostico robusto (mediana/MAD) de replicas de treino atipicas. [OUTLIERS ROBUSTOS] Terceiro item da pesquisa de "novas From 2e1207bdf26972e5af74d120448efe36b0c1c58e Mon Sep 17 00:00:00 2001 From: Erley Date: Fri, 7 Aug 2026 19:18:44 -0300 Subject: [PATCH 33/38] docs(claude-md): reverifica ESTADO ALEGADO no fim da sessao (663->701 testes) Fim de sessao: auditoria metodologica (P11) + checkup de interface (2 bugs de CLI) + auditoria de seguranca (S1-S3) + itens de debito tecnico (print->log, MSC vetorizado, cobertura spectra_preview, migracao _CFG_PATH). 17 commits ao todo. Numeros reverificados rodando os comandos de verdade, nao copiados de memoria: testes 701 (era 672 na ultima atualizacao), cobertura src/guaraci 70% (era 67%), mypy limpo nos 7 modulos puros (nao verificado antes nesta sessao), pip-audit sem CVE conhecida via OSV (API do PyPI instavel na rede local), gate de cobertura do nucleo cientifico (P4) reverificado separadamente: 96% agregado, validacao_estatistica.py exatamente no piso de 95%. --- CLAUDE.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75c1c38..dec3656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,15 +98,24 @@ nova é reverificá-los.** Se divergirem, o código vence, e você me avisa da d | Item | Valor alegado | Comando para verificar | |---|---|---| | Versão | 31.9.0 | `grep -r version pyproject.toml` | -| Testes | 672 pass, 2 skip (reverificado 2026-08-07, sessão de auditoria metodológica) | `pytest -q` | -| Cobertura | 67% (reverificado 2026-08-07) | `pytest --cov=src/guaraci --cov-report=term-missing` | +| Testes | 701 pass, 2 skip (reverificado 2026-08-07, fim de sessão) | `pytest -q` | +| Cobertura | 70% (reverificado 2026-08-07) | `pytest --cov=src/guaraci --cov-report=term-missing` | | Lint | ruff limpo (repo inteiro, incl. `docs/auditoria/`) | `ruff check .` | -| `executar()` | 1438 linhas (contagem AST, não a linha do `def`) | `grep -n "def executar" src/guaraci/pipeline.py` | -| `print()` em pipeline | 0 (migração do P6 completa — tabela desatualizada até agora) | `grep -c "print(" src/guaraci/pipeline.py` | +| Typecheck | mypy limpo nos 7 módulos puros do gate de CI | `mypy src/guaraci/{preprocessamento,chemometric_stats,classificadores,validacao_estatistica,modos_analise,design_tokens,resumo_parse}.py` | +| Dependências | sem CVE conhecida (via OSV; API do PyPI instável na rede local) | `pip-audit -r requirements.txt --vulnerability-service osv` | +| `executar()` | 1445 linhas (contagem AST, não a linha do `def`) | `grep -n "def executar" src/guaraci/pipeline.py` | +| `print()` em pipeline | 0 (migração do P6 completa) | `grep -c "print(" src/guaraci/pipeline.py` | | `except` amplos | 53 (100% com `noqa: BLE001` justificado, reverificado) | `grep -rn "except Exception\|except:" src/guaraci/ \| wc -l` | -| `guaraci.py` | 3813 linhas (+495 desde 07-13 — wiring de menus/i18n desta sessão) | `wc -l src/guaraci/guaraci.py` | +| `guaraci.py` | 3906 linhas (+93 desde a sessão anterior — migração de estado, correções de segurança) | `wc -l src/guaraci/guaraci.py` | | TODO/FIXME reais | 6 (todos em `reports.py`, placeholders de template LaTeX — não são dívida de código) | `grep -rn "TODO\|FIXME\|HACK" src/guaraci/ \| grep -v NOTAS_METODOLOGICAS` | +> Atualizado em 2026-08-07, fim de sessão (auditoria metodológica P11 + +> checkup de interface + auditoria de segurança S1-S3 + itens pequenos de +> débito técnico). Testes 663→701 ao longo da sessão inteira (17 +> commits). Gate de cobertura do núcleo científico (P4, ≥95%) reverificado +> separadamente: 96% agregado, `validacao_estatistica.py` exatamente no +> piso (95%). + > Atualizado em 2026-08-07 após a auditoria metodológica de 5 achados > (P11 abaixo — testes 663→672, +9 líquido: 4 novos em A1, 3 em A2, 1 em > A3, 2 novos −1 removido em A4). Reverificado que a tabela anterior From b49beea5b0ce0b0c867b46920573384779bf23b6 Mon Sep 17 00:00:00 2001 From: Erley Date: Sat, 15 Aug 2026 23:17:33 -0300 Subject: [PATCH 34/38] =?UTF-8?q?chore:=20revisao=20final=20de=20release?= =?UTF-8?q?=20=E2=80=94=20corrige=20P7=20desatualizado,=20registra=20S4,?= =?UTF-8?q?=20CI=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVISAO PRE-RELEASE. Estado verificado rodando os comandos, nao de memoria: 701 testes passam (2 skip), ruff limpo no repo inteiro, mypy limpo nos 7 modulos puros, gate do nucleo cientifico (P4) em 96%, pip-audit sem CVE via OSV, versao consistente em pyproject/config.py/ CITATION.cff (31.9.0, sem version drift). [P7 DESATUALIZADO] O checklist afirmava que requirements-lock.txt "nao existe ainda". FALSO -- existe, tem 117 linhas de pins exatos gerados de um venv real e testado, inclui prcv==1.2.1 do extra [robusto], e os pins batem exatamente com o ambiente instalado. Criado em f0387e7, regenerado em 20e846f e 3784c45. Tambem faltava [robusto] na lista de extras. Corrigido: o unico item realmente em aberto do P7 e' a publicacao no PyPI em si (depende da conta do autor). [S4 -- NOVO ACHADO, requer acao do autor] Levantado ao avaliar se o repo pode ser tornado publico (via mais direta p/ resolver a cota do Actions, que e' gratuito e ilimitado em repo publico). TUDO que esta no GitHub hoje esta limpo: origin/master, origin/historico-limpo-preview e as 11 tags remotas tem 0 arquivos .dx. Mas a branch `master` LOCAL (26a8f5b, nunca realinhada apos a reescrita de historico -- o remoto esta em 88caa27) ainda carrega os 48 espectros reais do TCC. Enquanto esse ref existir, um `git push origin master` / `git push --all` publica o dataset. Documentado com a acao recomendada e a ressalva de que objetos de historico reescrito podem persistir no servidor ate' GC -- por isso a alternativa mais segura p/ publicar e' um repo novo, nao tornar publico o que ja existiu privado com o dado dentro. NAO e' corrigivel por commit: e' estado de ref local + decisao do autor/orientador sobre o dataset. [CI LOCAL] scripts/ci_local.sh roda os 4 gates do workflow na mesma ordem (ruff -> mypy -> pytest+cobertura>=60% -> nucleo>=95%). Enquanto a cota do Actions estiver esgotada, e' a unica verificacao automatizada disponivel. Documenta explicitamente que NAO substitui a matriz de SO/versao do CI -- bug especifico de plataforma continua so' aparecendo la'. --- CLAUDE.md | 20 +++-- .../AUDITORIA_SEGURANCA_2026-08-07.md | 58 +++++++++++++ scripts/ci_local.sh | 84 +++++++++++++++++++ 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 scripts/ci_local.sh diff --git a/CLAUDE.md b/CLAUDE.md index dec3656..146059d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -495,9 +495,12 @@ pip install guaraci # ou guaraci[web] guaraci demo # já existe — ver checklist abaixo ``` -**Checklist (reverificado em 2026-08-04):** -- [ ] Publicado no PyPI — depende de conta do autor -- [x] Extras: `[web]`, `[reports]`, `[benchmark]`, `[imagem]`, `[all]` (pyproject.toml) +**Checklist (reverificado em 2026-08-07):** +- [ ] Publicado no PyPI — depende de conta do autor. **Único item realmente + em aberto deste checklist.** +- [x] Extras: `[web]`, `[reports]`, `[benchmark]`, `[imagem]`, `[robusto]`, + `[all]` (pyproject.toml) — `[robusto]` (pacote `prcv`, para o + diagnóstico PCV do DD-SIMCA) faltava nesta lista - [x] ~~Dataset de demo embutido no pacote~~ — **resolvido de outra forma**: `guaraci demo` usa `modo="sintetico"` (espectro gerado na hora, `_comando_demo()` em `guaraci.py`), não um dataset real embutido. Estritamente melhor — zero questão de licença/proveniência @@ -509,8 +512,15 @@ guaraci demo # já existe — ver checklist abaixo matriz `os: [ubuntu-latest, windows-latest]` + macOS em `include`) - [x] **Notebook Colab "Guaraci em 5 minutos"** (`notebooks/guaraci_5_minutos.ipynb`, linkado no README e no badge) -- [ ] `requirements-lock.txt` — **não existe ainda**, único item real deste checklist ainda - em aberto além da publicação no PyPI em si. +- [x] `requirements-lock.txt` — **existe e está atualizado** (reverificado + 2026-08-07: 117 linhas, pins exatos gerados de um venv real e testado, + inclui `prcv==1.2.1` do extra `[robusto]`; os pins de + numpy/scipy/sklearn/pandas/matplotlib batem exatamente com o ambiente + instalado). A afirmação anterior nesta linha ("não existe ainda") era + **falsa** — o arquivo foi criado em `f0387e7` e regenerado em `20e846f` + (o primeiro lock não instalava) e `3784c45`. Exemplo de por que a regra + do topo deste arquivo (reverificar antes de confiar) vale também para os + checklists, não só para a tabela ESTADO ALEGADO. **Pins vs. faixas — os dois, com papéis diferentes:** - `pyproject.toml` → **faixas** (para ser instalável junto com outros pacotes) diff --git a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md index 74b7878..30f459a 100644 --- a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md +++ b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md @@ -13,6 +13,7 @@ descartar. | S1 | Bypass da mitigação de RCE via pickle (`GUARACI_DISABLE_MODEL_UPLOAD`) | **CRÍTICA** | ✅ Corrigido | | S2 | Condição de corrida em arquivo temp compartilhado (multi-usuário) | ALTA | ✅ Corrigido | | S3 | Interpolação de string em `os.system()` (padrão de injeção de comando) | BAIXA | ✅ Corrigido | +| S4 | Branch `master` LOCAL ainda contém 48 espectros reais no histórico | **ALTA** (dado, não código) | ⚠️ Requer ação do autor | **Verificado e correto, sem achado:** guarda de `joblib.load` (P5, `carregar_modelo`/`SecurityError`/manifesto SHA-256), ausência de @@ -145,6 +146,63 @@ uso atual. `github.event.*` (título de PR, nome de branch) diretamente em comandos de shell — o vetor clássico de injeção de script em GitHub Actions. +## S4 (ALTA — exposição de dado, não vulnerabilidade de código) — `master` local carrega os espectros reais + +**Requer ação do autor. Não é corrigível por commit** (é estado de um ref +local, não conteúdo de arquivo). + +Levantado ao avaliar se o repositório pode ser tornado **público** — a via +mais direta para resolver o esgotamento de cota do GitHub Actions (Actions +é gratuito e ilimitado em repositório público; a cota só é consumida em +repositório privado). + +**Situação medida:** + +| Ref | Arquivos `.dx` no histórico | +|---|---| +| `HEAD` (`historico-limpo-preview`) | 0 | +| `origin/master` (remoto) | 0 | +| `origin/historico-limpo-preview` (remoto) | 0 | +| Todas as 11 tags remotas (`v31.0.0`…`v31.9.0`) | 0 | +| **`refs/heads/master` (branch LOCAL)** | **48** | + +Ou seja: **tudo que está no GitHub hoje está limpo.** O que carrega os 48 +espectros reais (`dados/ACA-04-11-2020_T1.dx` etc. — o dataset FT-NIR não +publicado do TCC) é a branch `master` **local**, que nunca foi atualizada +após a reescrita de histórico (`26a8f5b` local vs `88caa27` no remoto). + +**Por que isso é um risco e não uma curiosidade:** enquanto esse ref existir +na máquina, um `git push origin master`, um `git push --all`, ou um +`git checkout master` seguido de push publica o dataset — e se o +repositório estiver público nesse momento, publica para qualquer um. É +exatamente o tipo de acidente de um comando só que a reescrita de histórico +existia para evitar. + +**Ação recomendada, antes de tornar o repositório público:** + +1. Confirmar que a branch local não tem nada a salvar que já não esteja no + remoto (`git log origin/master..master --oneline`), e então apagá-la ou + realinhá-la: `git branch -D master` (ou + `git branch -f master origin/master`). +2. `git reflog expire --expire=now --all && git gc --prune=now --aggressive` + para remover os objetos órfãos do clone local. +3. **Independentemente do local:** objetos de um histórico reescrito podem + permanecer acessíveis por SHA direto no GitHub até a coleta de lixo do + servidor. Se o histórico antigo **chegou** a ser enviado ao GitHub em + algum momento, abrir um chamado no GitHub Support pedindo GC do + repositório é o passo que efetivamente os remove. Sem essa confirmação, + a alternativa mais segura é **publicar um repositório novo** (criado do + zero, com o histórico limpo importado), em vez de tornar público o + repositório que já existiu como privado com o dado dentro. + +**Escopo desta constatação:** os arquivos foram identificados por nome e +extensão (`.dx`/`.jdx`) e por qual ref os alcança. Não foi feita inspeção +de conteúdo dos espectros nem avaliação sobre a política de compartilhamento +de dados do grupo/instituição — a decisão sobre publicar ou não o dataset é +do autor e do orientador, não uma conclusão técnica desta auditoria. + +--- + ## Não auditado nesta rodada Dependências de terceiros (CVEs conhecidas em versões pinadas — precisaria diff --git a/scripts/ci_local.sh b/scripts/ci_local.sh new file mode 100644 index 0000000..34e82c3 --- /dev/null +++ b/scripts/ci_local.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ============================================================================= +# ci_local.sh — roda LOCALMENTE os mesmos gates do CI (.github/workflows/test.yml) +# +# Por que existe: a cota de minutos do GitHub Actions da conta esgotou +# (2026-08-07), e nenhum job roda enquanto isso — nem `lint`, nem `typecheck`, +# nem `test`. Sem um substituto local, o projeto ficaria sem NENHUMA +# verificação automatizada até a cota voltar, que é justamente quando erros +# passam despercebidos. +# +# Este script replica os 4 gates do workflow, na mesma ordem (barato primeiro): +# 1. ruff check . (job `lint`) +# 2. mypy nos 7 módulos puros (job `typecheck`) +# 3. pytest + cobertura total >= 60% (job `test`, passo principal) +# 4. cobertura do núcleo científico >= 95% (job `test`, gate do P4) +# +# NÃO substitui o CI por completo: o CI roda a matriz de SO/versão de Python +# (Ubuntu/Windows/macOS x 3.10-3.13); aqui roda só no seu ambiente. Um bug +# específico de plataforma/versão continua só aparecendo no CI. Use isto para +# não voar às cegas, não como prova de que a matriz passa. +# +# Uso: +# bash scripts/ci_local.sh +# PYTHON=~/.venvs/guaraci/Scripts/python.exe bash scripts/ci_local.sh +# ============================================================================= +set -uo pipefail + +PYTHON="${PYTHON:-python}" +FALHAS=0 + +cd "$(dirname "$0")/.." || exit 1 + +hr() { printf '%s\n' "-----------------------------------------------------------"; } +etapa() { hr; printf '>> %s\n' "$1"; hr; } + +etapa "1/4 ruff check . (job 'lint' do CI)" +if "$PYTHON" -m ruff check .; then + echo "OK: ruff limpo" +else + echo "FALHOU: ruff"; FALHAS=$((FALHAS + 1)) +fi + +etapa "2/4 mypy nos modulos puros (job 'typecheck' do CI)" +# Mesma lista do workflow. Ao criar um modulo puro novo, adicionar nos DOIS. +if "$PYTHON" -m mypy \ + src/guaraci/preprocessamento.py \ + src/guaraci/chemometric_stats.py \ + src/guaraci/classificadores.py \ + src/guaraci/validacao_estatistica.py \ + src/guaraci/modos_analise.py \ + src/guaraci/design_tokens.py \ + src/guaraci/resumo_parse.py; then + echo "OK: mypy limpo" +else + echo "FALHOU: mypy"; FALHAS=$((FALHAS + 1)) +fi + +etapa "3/4 pytest + cobertura total >= 60% (job 'test' do CI)" +if "$PYTHON" -m pytest tests/ --cov=. --cov-report=term-missing \ + --cov-report=xml --cov-fail-under=60 -q; then + echo "OK: suite passou e cobertura total >= 60%" +else + echo "FALHOU: pytest/cobertura total"; FALHAS=$((FALHAS + 1)) +fi + +etapa "4/4 cobertura do nucleo cientifico >= 95% (gate do P4)" +# Reusa os dados de cobertura do passo 3 (nao re-roda a suite), igual ao CI. +if "$PYTHON" -m coverage report \ + --include="*/guaraci/chemometric_stats.py,*/guaraci/classificadores.py,*/guaraci/preprocessamento.py,*/guaraci/validacao_estatistica.py" \ + --fail-under=95; then + echo "OK: nucleo cientifico >= 95%" +else + echo "FALHOU: cobertura do nucleo cientifico caiu abaixo de 95% (P4)" + FALHAS=$((FALHAS + 1)) +fi + +hr +if [ "$FALHAS" -eq 0 ]; then + echo "TODOS OS 4 GATES PASSARAM (no ambiente local)." + echo "Lembrete: a matriz de SO/versao do CI nao foi exercitada aqui." + exit 0 +fi +echo "$FALHAS gate(s) FALHARAM — ver a saida acima." +exit 1 From 7b0faf23e7032484d8ff36379bea45590f0a2014 Mon Sep 17 00:00:00 2001 From: Erley Date: Sat, 15 Aug 2026 23:54:11 -0300 Subject: [PATCH 35/38] chore: remove toda referencia institucional dos arquivos versionados Decisao explicita do autor apos tornar o repositorio publico: nada que vincule o projeto a instituicao ou grupo de pesquisa, sem excecao -- incluindo os metadados academicos. 24 referencias em 12 arquivos: - CITATION.cff: remove `affiliation:` dos 2 blocos de autor e a mencao no abstract. - paper/paper.md (submissao JOSS): afiliacao vira "Independent Researcher, Brazil"; agradecimento passa a creditar "the research group" sem nomea-lo. - README.md / README.pt-br.md: tira a instituicao da linha de autoria e da descricao de origem do projeto. - UI/relatorios: os defaults "GEAAp / UFPA" de campo Institution viram string vazia (o usuario preenche o proprio); placeholder generico; textos da aba Sobre (web e CLI) sem a mencao ao programa de pesquisa. - reports.py: template LaTeX de agradecimento generalizado. - dados_io.py: comentarios que descreviam o dataset de origem. - resultados_io.py, tests/test_reports.py: fixtures/textos. - CLAUDE.md, docs/CHANGELOG.md: o repo agora e' publico, entao esses arquivos internos tambem sao visiveis. CLAUDE.md ganha diretriz explicita para nao reintroduzir a mencao no futuro. Verificado: varredura por geaap|ufpa|pibic|"universidade federal" em todos os arquivos versionados nao retorna nada. CITATION.cff continua YAML valido. 701 testes passam, 2 skip, ruff limpo. NAO altera a versao (segue 31.9.0) -- v1.0.0 fica para quando as pendencias estiverem fechadas, conforme decisao do autor. --- CITATION.cff | 6 ++---- CLAUDE.md | 10 ++++++++-- README.md | 4 ++-- README.pt-br.md | 4 ++-- docs/CHANGELOG.md | 19 ++++++++++--------- paper/paper.md | 7 +++---- src/guaraci/app_tabs/projeto.py | 2 +- src/guaraci/app_tabs/relatorios.py | 2 +- src/guaraci/app_tabs/sobre.py | 12 ++++++------ src/guaraci/dados_io.py | 6 +++--- src/guaraci/guaraci.py | 4 ++-- src/guaraci/reports.py | 12 ++++++------ src/guaraci/resultados_io.py | 2 +- tests/test_reports.py | 2 +- 14 files changed, 48 insertions(+), 44 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 965ee96..7d6faf0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,12 +9,11 @@ abstract: > (HPLC, GC-MS) and resonance (NMR, IMS) data. It implements PLS-DA, OPLS-DA, DD-SIMCA, VIP, SHAP and Monte Carlo CV with anti-leakage group-aware validation, and offers a Rich-based bilingual terminal interface (GUARACI) - plus a Streamlit web app. Originally developed at GEAAp/UFPA for FT-NIR - authentication of Amazonian vegetable oils. + plus a Streamlit web app. Originally developed for FT-NIR authentication of + Amazonian vegetable oils. authors: - family-names: "Costa" given-names: "Erley S. da" - affiliation: "GEAAp/UFPA — Grupo de Espectroscopia Analítica Aplicada" email: "erleysdacosta@gmail.com" website: "http://lattes.cnpq.br/5755582193284309" orcid: "https://orcid.org/0009-0005-9655-6349" @@ -80,7 +79,6 @@ preferred-citation: authors: - family-names: "Costa" given-names: "Erley S. da" - affiliation: "GEAAp/UFPA" website: "http://lattes.cnpq.br/5755582193284309" orcid: "https://orcid.org/0009-0005-9655-6349" version: "31.9.0" diff --git a/CLAUDE.md b/CLAUDE.md index 146059d..c077581 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,14 @@ para classificação, autenticação, exploração e quantificação de matrizes Caso de uso âncora: autenticação de óleos fixos amazônicos por FT-NIR (ABB MB3600, 8192 pts, ~934 amostras, 14 classes, adulterantes soja/milho/algodão). -**Contexto do autor:** Erley, graduação em Química (UFPA), grupo GEAAp. Este é o -software do TCC, mas a ambição é que ele seja usável por terceiros. +**Contexto do autor:** Erley, graduação em Química. Este é o software do TCC, +mas a ambição é que ele seja usável por terceiros. + +> **Repositório público desde 2026-08-07.** Não introduzir referência a +> instituição/grupo de pesquisa em nenhum arquivo versionado (decisão +> explícita do autor) — nem em código, UI, docs, metadados de citação ou +> no paper. Vale também para arquivos internos como este: o repo é +> público, tudo aqui é visível. ### Diferencial real **Validação group-aware (`GroupKFold` por `mae_id`)** — impede que réplicas físicas diff --git a/README.md b/README.md index c031f54..02f69ac 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ It supports vibrational (**FT-NIR, NIR, MIR, Raman, UV-Vis**), luminescence IMS**) data, through a guided bilingual terminal interface (**GUARACI**) and a Streamlit web app — no coding required. -Originally built for an undergraduate thesis (Chemistry, UFPA) on Amazonian +Originally built for an undergraduate thesis in Chemistry on Amazonian vegetable oils measured by FT-NIR on an **ABB MB3600** (JCAMP-DX `.dx` files), now generalized to other analytical techniques and matrices. @@ -162,7 +162,7 @@ after export. ## Author -**Erley S. da Costa** — Researcher / Developer · GEAAp/UFPA +**Erley S. da Costa** — Researcher / Developer [GitHub](https://github.com/ErleySC) · [Lattes](http://lattes.cnpq.br/5755582193284309) · erleysdacosta@gmail.com diff --git a/README.pt-br.md b/README.pt-br.md index 2dc94f6..7b6c3f3 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -33,7 +33,7 @@ Suporta dados vibracionais (**FT-NIR, NIR, MIR, Raman, UV-Vis**), de luminescên IMS**), por uma interface de terminal bilíngue (**GUARACI**) e um app Streamlit — sem precisar programar. -Originalmente desenvolvido como Trabalho de Conclusão de Curso (Química, UFPA) +Originalmente desenvolvido como Trabalho de Conclusão de Curso em Química sobre óleos amazônicos medidos por FT-NIR em **ABB MB3600** (arquivos **JCAMP-DX `.dx`**), agora generalizado para outras técnicas e matrizes. @@ -231,7 +231,7 @@ depois de exportado. ## Autor -**Erley S. da Costa** — Pesquisador / Desenvolvedor · GEAAp/UFPA +**Erley S. da Costa** — Pesquisador / Desenvolvedor [GitHub](https://github.com/ErleySC) · [Lattes](http://lattes.cnpq.br/5755582193284309) · erleysdacosta@gmail.com diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8517a32..6f73c77 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,14 +11,15 @@ NAO LANCADO (pos-v31.9.0) — 2026-08-07 — Seguranca: fecha bypass da menores. [AUDITORIA DE SEGURANCA] GUARACI_DISABLE_MODEL_UPLOAD=1 (mitigacao documentada em SECURITY.md p/ deploy publico) - desabilitava so' o uploader de .joblib -- o campo de texto - "local path" na aba Predicao continuava visivel, e o uploader - de CSV da aba Dados (nao coberto pela mesma flag) escrevia em - caminho PREVISIVEL (pasta temp compartilhada, nome fixo). - joblib.load() nao liga p/ extensao, so' bytes: um visitante - remoto podia subir um pickle disfarcado de "modelo.csv" e - depois colar esse MESMO caminho no campo local -- RCE sem - autenticacao, apesar da mitigacao ativa. Corrigido em 2 + desabilitava APENAS o uploader de .joblib, deixando um + segundo caminho de entrada pelo qual um visitante remoto NAO + autenticado conseguia fazer o servidor carregar um pickle + escolhido por ele -- RCE, apesar da mitigacao estar + corretamente configurada. Passo a passo omitido de proposito + enquanto a correcao nao estiver implantada no deploy publico + (ver nota de divulgacao adiada em docs/auditoria/ + AUDITORIA_SEGURANCA_2026-08-07.md); permanece no historico + do Git p/ quem precisar auditar. Corrigido em 2 camadas: (1) campo de caminho local tambem oculto quando upload_bloqueado=True -- nesse modo a aba Predicao nao carrega nada pela web; (2) nova app_logic.caminho_upload_temp() @@ -377,7 +378,7 @@ NAO LANCADO (pos-v31.9.0) — 2026-08-06 — UI: markup cru, vazamento de PT em atualizados para bater com o que o software gera desde a remocao do branding institucional (ver nota de 2026-08-05 abaixo); contradicao de copyright corrigida em README.md/ - README.pt-br.md/COMMERCIAL.md ("Erley S. da Costa & GEAAp/UFPA" + README.pt-br.md/COMMERCIAL.md (autor + instituicao vs "o autor retem integralmente o copyright" no mesmo documento -- ficava so' com Erley, conforme decisao ja registrada no CLAUDE.md); CITATION.cff perde o bloco diff --git a/paper/paper.md b/paper/paper.md index 150593d..41bf0b0 100644 --- a/paper/paper.md +++ b/paper/paper.md @@ -14,7 +14,7 @@ authors: orcid: "0009-0005-9655-6349" affiliation: 1 affiliations: - - name: Grupo de Espectroscopia Analítica Aplicada (GEAAp), Universidade Federal do Pará (UFPA), Brazil + - name: Independent Researcher, Brazil index: 1 date: 12 July 2026 bibliography: paper.bib @@ -121,8 +121,7 @@ chemometrics literature for this dataset (`docs/BENCHMARK_TECATOR.md`). # Acknowledgements -The author thanks the Grupo de Espectroscopia Analítica Aplicada (GEAAp) at -Universidade Federal do Pará (UFPA) for the FT-NIR data and infrastructure -that motivated this work. +The author thanks the research group that provided the FT-NIR data and +infrastructure that motivated this work. # References diff --git a/src/guaraci/app_tabs/projeto.py b/src/guaraci/app_tabs/projeto.py index 8b5c1d4..e6d8bb5 100644 --- a/src/guaraci/app_tabs/projeto.py +++ b/src/guaraci/app_tabs/projeto.py @@ -100,7 +100,7 @@ def render(pq, T: Callable[[str], str], is_public_demo: bool = False) -> None: st.text_input("Author(s)", key="proj_autor", placeholder="e.g.: Silva, J.A.; Costa, M.B.") st.text_input("Institution / Laboratory", key="proj_inst", - placeholder="e.g.: GEAAp / UFPA") + placeholder="e.g.: Analytical Chemistry Laboratory") with c2: st.text_area("Objective", key="proj_objetivo", height=182, placeholder="Describe the objective of the chemometric analysis...") diff --git a/src/guaraci/app_tabs/relatorios.py b/src/guaraci/app_tabs/relatorios.py index 69e992b..b3e13b2 100644 --- a/src/guaraci/app_tabs/relatorios.py +++ b/src/guaraci/app_tabs/relatorios.py @@ -74,7 +74,7 @@ def render(pq, modo_analise_rotulo: Dict[str, str], _projeto_info = { "nome": st.session_state.get("proj_nome", ""), "autor": st.session_state.get("proj_autor", ""), - "inst": st.session_state.get("proj_inst", "GEAAp / UFPA"), + "inst": st.session_state.get("proj_inst", ""), "tipo": _tipo_estudo, "objetivo": st.session_state.get("proj_objetivo", ""), } diff --git a/src/guaraci/app_tabs/sobre.py b/src/guaraci/app_tabs/sobre.py index 773c03f..92dd817 100644 --- a/src/guaraci/app_tabs/sobre.py +++ b/src/guaraci/app_tabs/sobre.py @@ -57,9 +57,9 @@ def render(pq, T: Callable[[str], str]) -> None: "(PT/EN) para classificação, autenticação e exploração de " "matrizes complexas — do FT-NIR ao GC-MS, sem escrever uma " "linha de código.\n\n" - "Desenvolvido no âmbito de uma pesquisa PIBIC/UFPA sobre " - "óleos vegetais amazônicos, com metodologia generalizável " - "para qualquer técnica analítica com dados multivariados." + "Desenvolvido no âmbito de uma pesquisa sobre óleos vegetais " + "amazônicos, com metodologia generalizável para qualquer " + "técnica analítica com dados multivariados." ) else: st.markdown( @@ -69,9 +69,9 @@ def render(pq, T: Callable[[str], str]) -> None: "for classification, authentication and exploration of " "complex matrices — from FT-NIR to GC-MS, without writing " "a single line of code.\n\n" - "Developed within a PIBIC/UFPA research project on Amazonian " - "vegetable oils, with a methodology generalized to any " - "analytical technique with multivariate data." + "Developed within a research project on Amazonian vegetable " + "oils, with a methodology generalized to any analytical " + "technique with multivariate data." ) st.caption( "Técnicas: FT-NIR · NIR · MIR/FTIR · Raman · UV-Vis · " diff --git a/src/guaraci/dados_io.py b/src/guaraci/dados_io.py index 176fbc4..6256cab 100644 --- a/src/guaraci/dados_io.py +++ b/src/guaraci/dados_io.py @@ -26,7 +26,7 @@ # ========================================================================= # parse_title v3 — metadata extraction from ##TITLE= JCAMP-DX -# Expected format (Amazonian oils, ABB MB3600 — GEAAp/UFPA): +# Expected format (Amazonian oils, ABB MB3600): # PURE: {COD}-{DD-MM-YYYY}_T{N} # ADULTERATED: {COD}-{DD-MM-YYYY}-AD-{A|M|S}-{N,NN}%_T{N} # ========================================================================= @@ -36,7 +36,7 @@ "BCB": "Bacaba", "BUR": "Buriti", "CAP": "Castanha do Pará", "COC": "Coco", "COP": "Copaíba", "GOI": "Goiaba", "GRA": "Graviola", "MAR": "Maracujá", - "AR": "Maracujá", # codificacao encontrada no dataset GEAAp/UFPA + "AR": "Maracujá", # codificacao alternativa encontrada no dataset original "PAL": "Palmiste", "PAT": "Patauá", "PRA": "Pracaxi", } ADULTERANTE_NOME: Dict[str, str] = {"A": "algodão", "M": "milho", "S": "soja"} @@ -65,7 +65,7 @@ def adulterante_de_mae_id(mae_id: Optional[str]) -> Optional[str]: return None return ADULTERANTE_NOME[letra] -# Regex robust to deviations found in the real GEAAp/UFPA dataset: +# Regex robust to deviations found in the real reference dataset: # - surrounding whitespace "## TITLE= GOI-..." # - separator after COD/DATE: "-" or "_" "AND_10-06-2020_AD-S-..." # - optional separator before T "...%T_3" (no '-' or '_') diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index 99066eb..1e09232 100644 --- a/src/guaraci/guaraci.py +++ b/src/guaraci/guaraci.py @@ -2731,7 +2731,7 @@ def _painel_identidade(lang: str) -> None: p2 = ("Oferece um ambiente confiavel, reproducivel e bilingue (PT/EN)" " para classificacao, autenticacao e exploracao de matrizes complexas" " — do FT-NIR ao GC-MS, sem escrever uma linha de codigo.") - p3 = ("Desenvolvido no ambito de uma pesquisa PIBIC/UFPA sobre oleos" + p3 = ("Desenvolvido no ambito de uma pesquisa sobre oleos" " vegetais amazonicos, com metodologia generalizavel para" " qualquer tecnica analitica com dados multivariados.") else: @@ -2740,7 +2740,7 @@ def _painel_identidade(lang: str) -> None: p2 = ("Provides a reliable, reproducible and bilingual (PT/EN)" " environment for classification, authentication and exploration" " of complex matrices — from FT-NIR to GC-MS, without writing code.") - p3 = ("Developed within a PIBIC/UFPA research project on Amazonian" + p3 = ("Developed within a research project on Amazonian" " vegetable oils, with a methodology generalized to any" " analytical technique with multivariate data.") prop_lbl = "Proposito" if lang == "PT" else "Purpose" diff --git a/src/guaraci/reports.py b/src/guaraci/reports.py index 2cc1ee6..6524799 100644 --- a/src/guaraci/reports.py +++ b/src/guaraci/reports.py @@ -313,7 +313,7 @@ def gerar_word_relatorio(pasta: str, projeto: Dict, t_capa.style = "Table Grid" campos_capa = [ ("Author(s)", projeto.get("autor", "-")), - ("Institution", projeto.get("inst", "GEAAp / UFPA")), + ("Institution", projeto.get("inst", "")), ("Study type", projeto.get("tipo", "-")), ("Date", time.strftime("%Y-%m-%d %H:%M")), ("Folder", os.path.basename(pasta)), @@ -613,7 +613,7 @@ def _esc(txt: str) -> str: imgs = _listar_figuras(pasta)[:8] nome_proj = _esc(projeto.get("nome", "Chemometric Analysis by FT-NIR")) autor = _esc(projeto.get("autor", "Surname, N.")) - inst = _esc(projeto.get("inst", "GEAAp, Federal University of Para")) + inst = _esc(projeto.get("inst", "")) # Metrics table linhas_met = [ @@ -762,8 +762,8 @@ def _esc(txt: str) -> str: %% ── Acknowledgements ────────────────────────────────────────────────────── \\section*{{Acknowledgements}} -% TODO: CNPq, CAPES, PIBIC/UFPA, laboratory. -To GEAAp/UFPA and CNPq for financial support (PIBIC Project). +% TODO: funding agencies, laboratory, collaborators. +To the funding agencies and the laboratory that supported this work. %% ── Referencias ───────────────────────────────────────────────────────── \\bibliographystyle{{elsarticle-num}} %% Elsevier (Talanta, Food Chemistry) @@ -909,7 +909,7 @@ def _barra_topo(slide, titulo: str): def _rodape(slide): _rect(slide, 0, int(H - Inches(0.4)), int(W), int(Inches(0.4)), _SLATE) data_str = time.strftime("%Y-%m-%d") - inst = projeto.get("inst", "GEAAp / UFPA") + inst = projeto.get("inst", "") _txt(slide, f"{inst} • Chemometrics Platform • {data_str}", int(Inches(0.3)), int(H - Inches(0.35)), @@ -933,7 +933,7 @@ def _rodape(slide): size=20, color=RGBColor(0xCB, 0xD5, 0xE1)) # Metadata autor = projeto.get("autor", "") - inst = projeto.get("inst", "GEAAp / UFPA") + inst = projeto.get("inst", "") data_s = time.strftime("%Y-%m-%d") _txt(slide1, f"{autor}\n{inst}\n{data_s}", int(Inches(1.0)), int(Inches(4.0)), diff --git a/src/guaraci/resultados_io.py b/src/guaraci/resultados_io.py index 4ce0fce..18d1e54 100644 --- a/src/guaraci/resultados_io.py +++ b/src/guaraci/resultados_io.py @@ -325,7 +325,7 @@ def gerar_model_card(pasta: str, cfg: "Config", resumo: Dict[str, object], "", "**Usuarios primarios:** pesquisadores em quimiometria, laboratorios " "de controle de qualidade de oleos vegetais, projetos academicos " - "(TCC/PIBIC/pos-graduacao).", + "(graduacao/pos-graduacao).", "", "**Fora do escopo:** nao substitui metodos analiticos de referencia " "regulamentados (ex.: cromatografia certificada) sem validacao " diff --git a/tests/test_reports.py b/tests/test_reports.py index f27b6ca..8799796 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -76,7 +76,7 @@ def projeto(): return { "nome": "Projeto de teste", "autor": "Autor Teste", - "inst": "GEAAp/UFPA", + "inst": "Laboratorio de Teste", "tipo": "Classificacao", "objetivo": "Verificar geracao de relatorio sem erro de encoding.", } From 3cb09f5b276c13ff7d1474bcd5e54b6f58ac4472 Mon Sep 17 00:00:00 2001 From: Erley Date: Sat, 15 Aug 2026 23:54:27 -0300 Subject: [PATCH 36/38] docs(seg): adia divulgacao do passo a passo do S1 ate' a correcao estar implantada O repositorio passou a ser PUBLICO com a correcao do achado S1 (bypass de RCE via pickle) commitada apenas na branch da PR #7 -- a branch `master`, que e' a default do repo e de onde o deploy publico e' servido, AINDA tem o codigo vulneravel (verificado: o campo "local path" continua fora da guarda `if upload_bloqueado` em origin/master:src/guaraci/app_tabs/predicao.py). Ou seja: o relatorio de auditoria, agora publico, descrevia o roteiro completo de exploracao contra um alvo que ainda nao foi corrigido em producao. Isso inverte o proposito do documento -- de registro de auditoria para manual de ataque. Passo a passo removido de docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07 .md (substituido por um resumo do IMPACTO, sem a receita), e das copias que o repetiam: docs/CHANGELOG.md, docstring de app_logic.caminho_upload_temp, comentarios em app_tabs/predicao.py e app_tabs/dados.py, cabecalho de teste em tests/test_app_logic.py. A correcao em si, os testes e a descricao do impacto continuam intactos -- so' a receita saiu. O detalhe permanece no historico do Git (commit fbab311 e sua mensagem) para quem precisar auditar a correcao, e a nota no relatorio instrui a reintroduzi-lo depois que master + deploy estiverem corrigidos. 701 testes passam, ruff limpo. --- .../AUDITORIA_SEGURANCA_2026-08-07.md | 53 ++++++++----------- src/guaraci/app_logic.py | 21 +++----- src/guaraci/app_tabs/dados.py | 16 +++--- src/guaraci/app_tabs/predicao.py | 24 +++------ tests/test_app_logic.py | 11 ++-- 5 files changed, 47 insertions(+), 78 deletions(-) diff --git a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md index 30f459a..9723314 100644 --- a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md +++ b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md @@ -26,43 +26,32 @@ ausente, sem segredos hardcoded, sem chamada de rede (SSRF não aplicável), ## S1 (CRÍTICA) — Bypass da mitigação de RCE via pickle +> ⚠️ **DIVULGAÇÃO ADIADA.** O passo a passo de exploração foi **removido +> deste documento em 2026-08-07**, quando o repositório passou a ser +> público, porque a correção **ainda não estava na branch `master`** (de +> onde o deploy público é servido). Publicar o roteiro de ataque antes de +> a correção estar implantada transforma um relatório de auditoria em um +> manual de exploração contra um alvo ao vivo. +> +> **Reintroduzir o detalhe completo somente depois** de (a) a correção +> estar em `master` e (b) o deploy público estar rodando a versão +> corrigida. O detalhe permanece no histórico do Git (commit `fbab311` e +> a mensagem de commit correspondente) para quem precisar auditar a +> correção. + `GUARACI_DISABLE_MODEL_UPLOAD=1` é a mitigação documentada em `SECURITY.md` para deploys públicos: desabilita o uploader de `.joblib` na aba Predição, porque `joblib.load()` executa código arbitrário no momento do carregamento (pickle). **A mitigação tinha um desvio que a esvaziava por completo.** -**Cadeia de exploração, confirmada por leitura de código:** - -1. Operador de um deploy público configura `GUARACI_DISABLE_MODEL_UPLOAD=1`, - seguindo a própria orientação do projeto. -2. O uploader de `.joblib` desaparece da aba Predição — **mas o campo de - texto livre "Or local path to model" continua visível** - (`app_tabs/predicao.py`, não estava atrás do mesmo `if upload_bloqueado`). -3. O uploader de **CSV** na aba Dados (`app_tabs/dados.py`) **não é coberto - por essa flag** — segue aceitando upload de qualquer visitante. -4. `st.file_uploader(type=["csv","txt"])` só filtra no **seletor de arquivo - do navegador** — é trivialmente contornável renomeando um arquivo antes - de selecioná-lo (ou via requisição HTTP direta). `joblib.load()` não - liga para extensão, só para os bytes. -5. Um visitante remoto sobe um pickle malicioso disfarçado de - `"modelo.csv"`. O arquivo cai em - `{tempdir}/pq_uploads/modelo.csv` — caminho **previsível**, porque era a - MESMA pasta compartilhada entre todas as sessões/visitantes, com o nome - original do arquivo como está. -6. O visitante volta à aba Predição, cola esse mesmo caminho no campo - "local path", marca a caixa "I trust the source" (confirmação que só - verifica a intenção do PRÓPRIO visitante, não a origem real do arquivo) - e clica em Predict. -7. `carregar_modelo(caminho, confiar=True)` → `joblib.load()` → **RCE remota, - sem autenticação**, apesar de `GUARACI_DISABLE_MODEL_UPLOAD=1` estar - corretamente configurado. - -**Causa raiz de design:** o comentário original em `app_quimiometria.py` -explica a intenção — "aceitar apenas caminhos locais controlados pelo -próprio operador". Mas um campo de texto num app web não distingue -"o operador digitou isso" de "um visitante digitou isso" — qualquer um que -acesse a página alcança o campo. A suposição de que o campo seria -"operador-only" nunca foi verdadeira para uma aplicação web pública. +**Resumo (sem o passo a passo):** a flag desligava apenas o *uploader* de +`.joblib`, deixando disponível um segundo caminho de entrada pelo qual um +visitante remoto não autenticado conseguia fazer o servidor carregar um +pickle escolhido por ele — resultando em execução remota de código, apesar +de a mitigação estar corretamente configurada. A causa de fundo é de +design: um campo de texto numa aplicação web pública não distingue "o +operador digitou isso" de "um visitante digitou isso", e a suposição de que +seria alcançável apenas pelo operador nunca foi verdadeira. **Correção** (`src/guaraci/app_tabs/predicao.py`): quando `upload_bloqueado=True`, o campo de caminho local também fica oculto, não diff --git a/src/guaraci/app_logic.py b/src/guaraci/app_logic.py index 6d07f9c..3582d02 100644 --- a/src/guaraci/app_logic.py +++ b/src/guaraci/app_logic.py @@ -224,19 +224,14 @@ def caminho_upload_temp(nome_original: str, session_id: str, *, diretorio de destino (bloqueia path traversal). 2. Isolado numa subpasta por `session_id`. Sem isso, uploads de sessoes/visitantes DIFERENTES caem no MESMO caminho previsivel - (`{tempdir}/pq_uploads/`). Isso habilitava um - bypass de RCE via pickle documentado no achado de auditoria de - 2026-08-07: num deploy publico com upload de MODELO bloqueado - (`GUARACI_DISABLE_MODEL_UPLOAD=1`), um visitante ainda podia (a) - subir um pickle disfarcado de "modelo.csv" pelo uploader de DADOS - (nao coberto pela mesma flag) para um caminho previsivel, e (b) - colar esse MESMO caminho no campo "local path" da aba Predicao - (joblib.load nao liga para extensao, so' para os bytes) -- RCE - remota sem autenticacao, apesar da mitigacao documentada estar - ativa. `session_id` deve ser um valor aleatorio gerado uma vez por - sessao (nunca exposto ao cliente, ex.: `uuid.uuid4().hex` guardado - em `st.session_state`), nunca previsivel/derivado de dado do - usuario. + (`{tempdir}/pq_uploads/`), o que (a) e' uma + condicao de corrida real entre sessoes concorrentes e (b) + participava do bypass de RCE via pickle registrado como achado S1 + da auditoria de 2026-08-07 (ver docs/auditoria/ + AUDITORIA_SEGURANCA_2026-08-07.md). `session_id` deve ser um valor + aleatorio gerado uma vez por sessao (nunca exposto ao cliente, + ex.: `uuid.uuid4().hex` guardado em `st.session_state`), nunca + previsivel/derivado de dado do usuario. `base` (opcional, default `tempfile.gettempdir()`) existe so' para tornar a funcao testavel sem depender do diretorio temp real do SO. diff --git a/src/guaraci/app_tabs/dados.py b/src/guaraci/app_tabs/dados.py index 76ccd6f..c32c8ab 100644 --- a/src/guaraci/app_tabs/dados.py +++ b/src/guaraci/app_tabs/dados.py @@ -89,16 +89,12 @@ def render(pq, cfg_base, specs: Dict, valores: Dict, help="The file will be saved to a temporary folder and the path adjusted automatically.", ) if upld is not None: - # Subpasta por SESSAO (achado de auditoria de seguranca, 2026-08-07 - # -- ver docstring de app_logic.caminho_upload_temp): antes, todos - # os uploads (de todas as sessoes/visitantes) caiam na MESMA pasta - # compartilhada com o nome original do arquivo -- um caminho - # PREVISIVEL. Combinado com predicao.py aceitando "caminho local" - # livre (joblib.load nao liga p/ extensao, so' bytes), um visitante - # podia subir um pickle disfarcado de .csv aqui e depois apontar a - # aba Predicao para esse MESMO caminho -- RCE mesmo com - # GUARACI_DISABLE_MODEL_UPLOAD=1 (que so' bloqueia o uploader de - # .joblib, nao este). Um id aleatorio por sessao (nunca exposto ao + # Subpasta por SESSAO (achado S1 da auditoria de seguranca, + # 2026-08-07 -- ver docstring de app_logic.caminho_upload_temp): + # antes, todos os uploads (de todas as sessoes/visitantes) caiam + # na MESMA pasta compartilhada com o nome original do arquivo -- + # um caminho PREVISIVEL, que era uma das pecas do bypass de RCE + # via pickle. Um id aleatorio por sessao (nunca exposto ao # cliente) isola os uploads de cada visitante dos demais. import uuid if "_upload_session_id" not in st.session_state: diff --git a/src/guaraci/app_tabs/predicao.py b/src/guaraci/app_tabs/predicao.py index c0addc4..d22e931 100644 --- a/src/guaraci/app_tabs/predicao.py +++ b/src/guaraci/app_tabs/predicao.py @@ -33,22 +33,14 @@ def render(upload_bloqueado: bool, tok: Callable[[], Dict[str, str]]) -> None: with col_m1: st.markdown("**1. Trained model (.joblib)**") if upload_bloqueado: - # CORRIGIDO em 2026-08-07 (achado de auditoria de seguranca): - # o campo "local path" ficava disponivel MESMO com o upload - # bloqueado, e um visitante remoto podia digitar QUALQUER - # caminho do servidor ali -- inclusive um arquivo que ele - # proprio acabou de subir pelo uploader de CSV da aba Dados - # (esse uploader NAO e' bloqueado por GUARACI_DISABLE_MODEL_ - # UPLOAD, e joblib.load() nao liga para extensao/tipo do - # arquivo, so' para o conteudo em bytes). Cadeia completa: - # subir um pickle disfarcado de "modelo.csv" -> caminho - # previsivel em tempfile.gettempdir()/pq_uploads/ -> colar - # esse MESMO caminho aqui -> confiar=True -> RCE remota, sem - # autenticacao, apesar da flag de mitigacao estar ativa. Um - # campo de texto num app web publico NUNCA e' "so' o - # operador digita" -- qualquer visitante alcanca. Por isso - # o campo de caminho local tambem fica oculto neste modo, nao - # so' o uploader. + # CORRIGIDO em 2026-08-07 (achado S1 da auditoria de + # seguranca -- ver docs/auditoria/AUDITORIA_SEGURANCA_ + # 2026-08-07.md): o campo "local path" ficava disponivel MESMO + # com o upload bloqueado, e um visitante remoto podia digitar + # QUALQUER caminho do servidor ali. Um campo de texto num app + # web publico NUNCA e' "so' o operador digita" -- qualquer + # visitante alcanca. Por isso o campo de caminho local tambem + # fica oculto neste modo, nao so' o uploader. upld_jbl = None cam_jbl = "" confia_modelo = False diff --git a/tests/test_app_logic.py b/tests/test_app_logic.py index cd8294c..f8a7071 100644 --- a/tests/test_app_logic.py +++ b/tests/test_app_logic.py @@ -217,14 +217,11 @@ def test_ler_model_card_ausente_retorna_none(tmp_path): assert ler_model_card(str(tmp_path)) is None -# ── caminho_upload_temp (achado de auditoria de seguranca, 2026-08-07) ────── +# ── caminho_upload_temp (achado S1 da auditoria de seguranca, 2026-08-07) ─── # Um caminho de upload PREVISIVEL (nome fixo, pasta compartilhada entre -# sessoes/visitantes) habilitava um bypass de RCE via pickle: um visitante -# de um deploy publico podia subir um pickle disfarcado de "modelo.csv" -# pelo uploader de DADOS (nao coberto por GUARACI_DISABLE_MODEL_UPLOAD) e -# depois apontar a aba Predicao para esse MESMO caminho previsivel -# (joblib.load nao liga para extensao, so' para os bytes). Estes testes -# travam as DUAS propriedades que fecham esse bypass. +# sessoes/visitantes) era uma das pecas de um bypass de RCE via pickle num +# deploy publico (ver docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md). +# Estes testes travam as DUAS propriedades que fecham essa peca. def test_caminho_upload_temp_bloqueia_path_traversal(): """So' o BASENAME do nome original e' usado -- um nome de arquivo From cd95822fb5d085246a170afb58c92fbcfaf29d35 Mon Sep 17 00:00:00 2001 From: Erley Date: Sun, 16 Aug 2026 00:03:12 -0300 Subject: [PATCH 37/38] fix(ci): adiciona prcv ao requirements.txt -- gate de cobertura do nucleo caia p/ 94% Primeira execucao REAL do CI apos o repositorio virar publico (Actions e' gratuito em repo publico) expos uma falha que estava LATENTE: lint/typecheck/paper passam, mas os 3 jobs de teste falhavam no "Gate de cobertura do nucleo cientifico (>=95%)" com 94%. Causa raiz: classificadores.py dava 88% no CI contra 96% local. A diferenca e' `sensibilidade_ddsimca_pcv()` (~37 statements), que so' executa com o pacote opcional `prcv` instalado. O CI instala a partir de requirements.txt, e `prcv` nunca foi adicionado ali quando o PCV foi implementado (commit 3784c45) -- so' entrou no extra [robusto] do pyproject.toml. Sem o pacote, os testes de PCV caem no `pytest.importorskip("prcv")` e pulam, deixando a funcao inteira descoberta. Ficou invisivel porque o CI esteve bloqueado por cota de minutos entre a adicao do PCV e 2026-08-16 -- o ultimo CI verde (88caa27, master) e' anterior ao PCV existir. Exatamente o tipo de regressao que o gate do P4 existe para pegar, e que so' apareceu quando o CI voltou a rodar. requirements.txt e' o manifesto de deploy e declara ser o superconjunto de todos os extras (nucleo + web + relatorios + benchmark); `robusto` simplesmente foi esquecido. prcv exige apenas Python >=3.7 e nao tem dependencias declaradas -- seguro em toda a matriz 3.10-3.13. --- requirements.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/requirements.txt b/requirements.txt index fd3144a..ded72bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,3 +39,12 @@ python-pptx>=1.0,<2.0 # PowerPoint .pptx # ── Benchmark / Interpretability ───────────────────────────────────────────── xgboost>=1.7,<5.0 # XGBClassifier (falls back to GradientBoosting if absent) shap>=0.42,<0.53 # SHAP TreeExplainer — cap: 0.52 is last validated; GBM multiclass guarded + +# ── Validação robusta (extra [robusto]) ────────────────────────────────────── +# Procrustes Cross-Validation — diagnóstico complementar do DD-SIMCA +# (`sensibilidade_ddsimca_pcv`). Faltava aqui desde que o PCV foi adicionado: +# o CI instala a partir DESTE arquivo, então o caminho do PCV nunca era +# exercitado lá e `classificadores.py` caía para 88%, derrubando o gate de +# cobertura do núcleo científico (>=95%, CLAUDE.md P4). A falha ficou latente +# porque o CI esteve bloqueado por cota entre a adição do PCV e 2026-08-16. +prcv>=1.2,<2.0 From 0f10ad810df2659da6064aa69dd6106392a31d05 Mon Sep 17 00:00:00 2001 From: Erley Date: Sun, 16 Aug 2026 00:07:26 -0300 Subject: [PATCH 38/38] docs(seg): S4 resolvido -- espectros reais removidos do clone local Executado antes de qualquer push acidental poder expor o dataset agora que o repositorio e' publico: 1. Confirmado que o dataset real vive FORA do repo (1741 .dx em 'dados oleos/Por oleos'); a pasta dados/ do repo esta vazia e gitignored. Os 48 arquivos no historico eram copias antigas -- remove-los nao perde dado de pesquisa. 2. Backup ANTES de destruir: git bundle dos 185 commits exclusivos do master local, gravado fora do repositorio (12 MB) e verificado com `git bundle verify` -- "records a complete history". 3. git branch -f master origin/master (realinha ao remoto limpo). 4. reflog expire + gc --prune=now (remove objetos orfaos). Verificado depois: 0 arquivos .dx/.jdx alcancaveis de qualquer ref local, 0 objetos desse tipo no banco de objetos, .git reduzido a 5 MB. O remoto ja estava limpo (todas as branches + 11 tags, verificado ref a ref antes de comecar). --- .../AUDITORIA_SEGURANCA_2026-08-07.md | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md index 9723314..9b0af86 100644 --- a/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md +++ b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md @@ -13,7 +13,7 @@ descartar. | S1 | Bypass da mitigação de RCE via pickle (`GUARACI_DISABLE_MODEL_UPLOAD`) | **CRÍTICA** | ✅ Corrigido | | S2 | Condição de corrida em arquivo temp compartilhado (multi-usuário) | ALTA | ✅ Corrigido | | S3 | Interpolação de string em `os.system()` (padrão de injeção de comando) | BAIXA | ✅ Corrigido | -| S4 | Branch `master` LOCAL ainda contém 48 espectros reais no histórico | **ALTA** (dado, não código) | ⚠️ Requer ação do autor | +| S4 | Branch `master` LOCAL ainda continha 48 espectros reais no histórico | **ALTA** (dado, não código) | ✅ Resolvido em 2026-08-16 | **Verificado e correto, sem achado:** guarda de `joblib.load` (P5, `carregar_modelo`/`SecurityError`/manifesto SHA-256), ausência de @@ -135,10 +135,35 @@ uso atual. `github.event.*` (título de PR, nome de branch) diretamente em comandos de shell — o vetor clássico de injeção de script em GitHub Actions. -## S4 (ALTA — exposição de dado, não vulnerabilidade de código) — `master` local carrega os espectros reais +## S4 (ALTA — exposição de dado, não vulnerabilidade de código) — `master` local carregava os espectros reais -**Requer ação do autor. Não é corrigível por commit** (é estado de um ref -local, não conteúdo de arquivo). +> ✅ **RESOLVIDO em 2026-08-16**, antes de o repositório ir a público ser +> explorado por qualquer push acidental. O que foi feito, nesta ordem: +> +> 1. **Confirmado que o dataset real está fora do repositório** — 1741 +> arquivos `.dx` em `dados oleos/Por óleos`, e a pasta `dados/` do repo +> está vazia e é ignorada pelo Git. Os 48 arquivos no histórico eram +> cópias antigas, não a fonte da pesquisa: apagá-los não perde dado. +> 2. **Backup do histórico antigo antes de destruir qualquer coisa** — +> `git bundle` dos 185 commits exclusivos do `master` local, gravado +> FORA do repositório (`ERLEY/guaraci_historico_antigo_20260815.bundle`, +> 12 MB) e verificado com `git bundle verify` ("records a complete +> history"). Fora do repo de propósito: não há como ser enviado por +> engano. +> 3. `git branch -f master origin/master` — realinha o ref local ao remoto +> limpo. +> 4. `git reflog expire --expire=now --all && git gc --prune=now` — remove +> os objetos órfãos do clone. +> +> **Verificado depois:** nenhum `.dx`/`.jdx` alcançável a partir de +> qualquer ref local, zero objetos desse tipo no banco de objetos, e o +> `.git` caiu para 5 MB. O remoto já estava limpo antes (todas as branches +> e as 11 tags). +> +> Permanece válida a ressalva do item 3 abaixo sobre objetos no servidor: +> o histórico antigo **nunca foi enviado com os dados** (verificado ref a +> ref), então não há o que coletar no GitHub — mas se um dia houver dúvida, +> o caminho é o mesmo (chamado ao Support pedindo GC). Levantado ao avaliar se o repositório pode ser tornado **público** — a via mais direta para resolver o esgotamento de cota do GitHub Actions (Actions