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: 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 9bf9dc2..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 @@ -97,16 +103,38 @@ 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` | -| 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` | -| `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` | +| Versão | 31.9.0 | `grep -r version pyproject.toml` | +| 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 .` | +| 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` | 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 +> (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 +> 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 @@ -157,13 +185,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 @@ -467,9 +501,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 @@ -481,8 +518,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) @@ -657,6 +701,93 @@ 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. + +--- + +### ✅ 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 @@ -768,7 +899,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 — 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`. ~~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. 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/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 f63f278..6f73c77 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,338 @@ 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 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() + 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 + 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. + [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). + [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. + [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. @@ -46,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/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 2c34931..799f3bf 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -19,10 +19,11 @@ | 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` | -| 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 new file mode 100644 index 0000000..9b69db2 --- /dev/null +++ b/docs/auditoria/AUDITORIA_METODOLOGICA_2026-08-07.md @@ -0,0 +1,294 @@ +# 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 | 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): 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. + +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 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 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 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 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`). + +**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. + +--- + +## 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 +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. **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. + +--- + +## 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/AUDITORIA_SEGURANCA_2026-08-07.md b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md new file mode 100644 index 0000000..9b0af86 --- /dev/null +++ b/docs/auditoria/AUDITORIA_SEGURANCA_2026-08-07.md @@ -0,0 +1,226 @@ +# 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 | +| 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 +`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 + +> ⚠️ **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.** + +**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 +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. + +## S4 (ALTA — exposição de dado, não vulnerabilidade de código) — `master` local carregava os espectros reais + +> ✅ **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 +é 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 +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/docs/auditoria/medir_achados.py b/docs/auditoria/medir_achados.py new file mode 100644 index 0000000..db96738 --- /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 +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(" 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_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/docs/auditoria/medir_permutacao_grupos.py b/docs/auditoria/medir_permutacao_grupos.py new file mode 100644 index 0000000..96cf471 --- /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(" 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(" 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(" 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}") 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/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/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 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 diff --git a/src/guaraci/app_logic.py b/src/guaraci/app_logic.py index e9d04b1..3582d02 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 @@ -87,8 +89,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 +103,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: @@ -170,6 +209,38 @@ 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/`), 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. + """ + 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). @@ -214,4 +285,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..c32c8ab 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,20 @@ 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 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: + 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/modelo.py b/src/guaraci/app_tabs/modelo.py index 498fb2f..4e5095d 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", @@ -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/app_tabs/predicao.py b/src/guaraci/app_tabs/predicao.py index d729bd4..d22e931 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,23 @@ 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 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 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 +58,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 +95,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/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/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/src/guaraci/chemometric_stats.py b/src/guaraci/chemometric_stats.py index e757c4c..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.""" @@ -33,39 +36,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 + 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 @ w1_unit # (n,) - tt = float(t_tp @ t_tp) - if tt < 1e-12: - return np.zeros(X.shape[1]) + 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 + 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 + 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( @@ -170,22 +207,39 @@ 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.") + 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)) @@ -208,6 +262,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 @@ -417,16 +514,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 ---------- @@ -434,31 +542,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) @@ -468,25 +582,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) @@ -501,18 +620,125 @@ 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"), 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/classificadores.py b/src/guaraci/classificadores.py index 18f2cfa..f832c68 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__) @@ -43,30 +44,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 +94,66 @@ 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 _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: + """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. 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() if method == "empirical": @@ -166,16 +243,45 @@ 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 = 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): + # 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, "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, "n_train": nc, "n_comp": n_comp, + "outliers_treino": idx_out, } return self @@ -190,7 +296,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,14 +313,25 @@ 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"], + "n_comp": m["n_comp"], + "outliers_treino": m["outliers_treino"], } 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)): @@ -223,7 +342,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") @@ -298,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() @@ -494,8 +621,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 @@ -516,3 +642,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/dados_io.py b/src/guaraci/dados_io.py index 36a92b8..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 '_') @@ -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/figuras.py b/src/guaraci/figuras.py index 28f865c..3fa1ac9 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) @@ -1383,6 +1412,71 @@ 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 _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], @@ -1429,11 +1523,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], @@ -1443,11 +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) - 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 + # 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. @@ -1466,17 +1569,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", @@ -1505,11 +1613,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]) @@ -1519,11 +1635,13 @@ 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) - 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) + # 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] sc, ec = _info[0], _info[1] @@ -1537,8 +1655,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)) @@ -1660,6 +1780,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 +1921,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=":") @@ -1912,6 +2159,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)) @@ -1937,11 +2189,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="--") diff --git a/src/guaraci/guaraci.py b/src/guaraci/guaraci.py index dbed442..1e09232 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 @@ -1649,10 +1699,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"}) @@ -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"]) @@ -2679,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: @@ -2688,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" @@ -3216,10 +3268,24 @@ 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) + # 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 +3302,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')} ") @@ -3317,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) @@ -3361,14 +3449,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() - with Live(console=console, refresh_per_second=3) 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() @@ -3632,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) @@ -3661,6 +3794,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(): @@ -3692,7 +3829,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/src/guaraci/pipeline.py b/src/guaraci/pipeline.py index e8a63b2..1ebf387 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, ) @@ -278,6 +279,7 @@ def gerar_nome_saida(cfg: Config, n_classes: int, n_amostras: int) -> str: DDSimca, OPLSDAWrapper, sensibilidade_ddsimca_logo, + sensibilidade_ddsimca_pcv, ) @@ -1326,6 +1328,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})") @@ -1665,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. @@ -1718,8 +1743,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) @@ -1744,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 " @@ -1906,6 +1943,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"), @@ -2002,6 +2047,25 @@ 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 + # 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: @@ -2099,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 9cd5a05..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. """ @@ -196,8 +198,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) @@ -255,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/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/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/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/src/guaraci/validacao_estatistica.py b/src/guaraci/validacao_estatistica.py index 97261e7..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. @@ -158,6 +161,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 +401,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": @@ -382,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 @@ -402,17 +444,17 @@ 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) 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) @@ -494,12 +536,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": @@ -513,45 +558,44 @@ 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. 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) 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)) 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_app_logic.py b/tests/test_app_logic.py index 7d4907a..f8a7071 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, ) @@ -51,6 +54,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"), @@ -145,3 +215,48 @@ 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 S1 da auditoria de seguranca, 2026-08-07) ─── +# Um caminho de upload PREVISIVEL (nome fixo, pasta compartilhada entre +# 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 + 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" 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)) diff --git a/tests/test_classificadores.py b/tests/test_classificadores.py index 7bcf484..4de7284 100644 --- a/tests/test_classificadores.py +++ b/tests/test_classificadores.py @@ -12,6 +12,7 @@ DDSimca, OPLSDAWrapper, sensibilidade_ddsimca_logo, + sensibilidade_ddsimca_pcv, ) @@ -66,11 +67,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) @@ -175,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), @@ -189,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(): @@ -285,7 +313,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 @@ -337,3 +365,234 @@ 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(): + # 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 = media_e_dof_momentos(np.full(10, 3.0)) # desvio=0 + assert N == 1.0 + media, N = media_e_dof_momentos(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 + + +# --------------------------------------------------------------------------- +# 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 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") diff --git a/tests/test_figuras.py b/tests/test_figuras.py index 25d618a..1e2651d 100644 --- a/tests/test_figuras.py +++ b/tests/test_figuras.py @@ -46,3 +46,258 @@ 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 + + +# --------------------------------------------------------------------------- +# 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" + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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)) diff --git a/tests/test_guaraci_cli.py b/tests/test_guaraci_cli.py index c8d5358..7d2b22d 100644 --- a/tests/test_guaraci_cli.py +++ b/tests/test_guaraci_cli.py @@ -106,6 +106,138 @@ 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_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.""" @@ -162,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 @@ -692,3 +831,144 @@ 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/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 + duracao = time.monotonic() - inicio + 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() diff --git a/tests/test_pipeline_core.py b/tests/test_pipeline_core.py index 8f88350..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) @@ -137,6 +205,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 +533,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)) @@ -937,7 +1067,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): @@ -955,8 +1085,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) @@ -965,8 +1096,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): @@ -985,13 +1140,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): @@ -1389,3 +1545,121 @@ 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"])) + + +# --------------------------------------------------------------------------- +# 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 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 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 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.", } 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() 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