histlearn commited on
Commit
a2ad1d2
·
verified ·
1 Parent(s): 5938c74

feat: endpoint FT-Solo inicial (Qwen3-Embedding-4B + LoRA fold 01 + linear head)

Browse files

Sobe o código do endpoint (app.py, inference.py, config.py) e os artefatos do FT-Solo (adapter LoRA + cabeça linear do melhor fold segundo o manifesto). Gerado via Colab a partir do tar + zip no Drive.

.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .venv/
6
+ .env
7
+
8
+ # IDE / OS
9
+ .DS_Store
10
+ .idea/
11
+ .vscode/
12
+ *.swp
13
+
14
+ # Jupyter
15
+ .ipynb_checkpoints/
16
+
17
+ # Artefatos opcionais/pesados — NÃO committados no Space
18
+ # (o adapter e a head, que são obrigatórios, não aparecem aqui de propósito)
19
+ artifacts/embeddings_qwen3_4b_finetuned.npz
20
+ artifacts/dataset.parquet
21
+ artifacts/*.zip
22
+ artifacts/_raw/
23
+
24
+ # Logs
25
+ *.log
README.md CHANGED
@@ -1,14 +1,197 @@
1
  ---
2
- title: Communitynotesbr
3
- emoji: 📊
4
- colorFrom: red
5
  colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.13.0
 
8
  app_file: app.py
9
  pinned: false
10
- license: other
11
- short_description: classificação e análise interpretável de Notas da Comunidade
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Notinhas Endpoint (FT-Solo)
3
+ emoji: 📝
4
+ colorFrom: green
5
  colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.50.0
8
+ python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
+ short_description: Classificador de utilidade para community notes em PT-BR.
12
+ models:
13
+ - Qwen/Qwen3-Embedding-4B
14
  ---
15
 
16
+ # Notinhas endpoint de utilidade (FT-Solo)
17
+
18
+ Endpoint privado do modelo **FT-Solo** do projeto: dado o texto de uma *community
19
+ note* em português, devolve a probabilidade de ela ser classificada como "útil"
20
+ (`label_binary_strict = 1`), junto com uma leitura opcional da contribuição de
21
+ cada palavra.
22
+
23
+ Arquitetura: **Qwen3-Embedding-4B + LoRA + cabeça linear**, idêntica ao
24
+ `predict_from_text` do notebook `explicabilidade_qwen4b_redesign` em modo fiel
25
+ (fold 01).
26
+
27
+ ## Estrutura do repositório
28
+
29
+ ```
30
+ .
31
+ ├── app.py # Gradio UI + API (abas Prever / Explicar / Sobre)
32
+ ├── inference.py # Loader + predict + explain (occlusion word-level)
33
+ ├── config.py # Constantes (modelo, prompt, paths, thresholds)
34
+ ├── requirements.txt # Dependências Python
35
+ ├── README.md # Este arquivo (com YAML header lido pelo HF)
36
+ ├── .gitignore
37
+ └── artifacts/ # ← você popula isso (veja a seção Setup)
38
+ ├── fold_01_adapter/ # Pasta do adapter LoRA
39
+ │ ├── adapter_config.json
40
+ │ └── adapter_model.safetensors
41
+ └── fold_01_head.pt # State dict do nn.Linear(2560, 1)
42
+ ```
43
+
44
+ ## Setup — do zero até o Space no ar
45
+
46
+ ### 1. Criar o Space (privado)
47
+
48
+ Na UI do Hugging Face:
49
+
50
+ 1. **New Space**.
51
+ 2. SDK: **Gradio**.
52
+ 3. Hardware: **T4 small** (recomendado — caber na memória em bf16 e inferência
53
+ em ~0,5 s). **A10G small** dá latência ainda menor. **ZeroGPU** funciona mas
54
+ com cold-start mais longo. **CPU** roda, porém cada inferência leva 20–40 s.
55
+ 4. Visibility: **Private**.
56
+
57
+ ### 2. Popular `artifacts/`
58
+
59
+ Os pesos vêm do pipeline do projeto. O zip base do Drive (`artefatos_projeto.zip`)
60
+ traz as pastas `qwen4b_adapters/` e `qwen4b_heads/`. Rode localmente:
61
+
62
+ ```bash
63
+ pip install gdown
64
+ gdown "https://drive.google.com/uc?id=1_wCCxZG25tcGIVHgrdfOj54vI5Iw6MUF" \
65
+ -O artefatos_projeto.zip
66
+ unzip -q artefatos_projeto.zip -d _raw/
67
+
68
+ # Estrutura esperada pelo Space:
69
+ mkdir -p artifacts
70
+ cp -r _raw/qwen4b_adapters/fold_01_adapter artifacts/
71
+ cp _raw/qwen4b_heads/fold_01_head.pt artifacts/
72
+ ```
73
+
74
+ > **Qual fold usar?** O notebook escolhe dinamicamente o "melhor fold" via
75
+ > `qwen4b_ftsolo_manifest.json`. Para servir em produção é coerente reusar o
76
+ > mesmo. Se o manifesto apontar para outro fold (digamos, `fold_03`), renomeie
77
+ > os arquivos acima para `fold_01_adapter/` e `fold_01_head.pt` **ou** edite
78
+ > `config.py` para apontar para os nomes reais.
79
+
80
+ ### 3. Commitar e subir
81
+
82
+ Spaces são repositórios git hospedados no HF. Dentro da pasta clonada do Space:
83
+
84
+ ```bash
85
+ git lfs install # safetensors > 10 MB usam LFS
86
+ git lfs track "*.safetensors"
87
+ git lfs track "*.pt"
88
+ git add .gitattributes artifacts/
89
+ git add app.py inference.py config.py requirements.txt README.md .gitignore
90
+ git commit -m "feat: endpoint inicial FT-Solo"
91
+ git push
92
+ ```
93
+
94
+ O adapter do Qwen3-Embedding-4B em LoRA costuma ficar entre **20 e 80 MB**
95
+ (dependendo do rank e dos módulos-alvo). A cabeça é ~20 KB. Tudo cabe
96
+ confortavelmente sem apertar quota.
97
+
98
+ ### 4. (Opcional) Secrets
99
+
100
+ Em **Settings → Variables and secrets**:
101
+
102
+ - `HF_TOKEN` — só necessário se `Qwen/Qwen3-Embedding-4B` virar gated no futuro.
103
+ Hoje o modelo é público, então você pode ignorar.
104
+
105
+ ### 5. Primeiro boot
106
+
107
+ Na primeira inicialização o Space:
108
+
109
+ 1. Instala `requirements.txt` (~1 min).
110
+ 2. Baixa `Qwen/Qwen3-Embedding-4B` da HF (~8 GB, ~2–3 min).
111
+ 3. Carrega adapter + head (~5 s).
112
+ 4. Fica pronto — e o warm-up do modelo já aconteceu, o primeiro request é rápido.
113
+
114
+ Acompanhe pela aba **Logs** do Space.
115
+
116
+ ## Uso
117
+
118
+ ### Via UI web
119
+
120
+ Basta acessar a URL privada do Space. Três abas:
121
+
122
+ - **Prever** — score + label + faixa de confiança.
123
+ - **Explicar** — o mesmo + texto com destaque por contribuição de palavra, mais
124
+ uma tabela dos top 5 tokens de cada lado.
125
+ - **Sobre** — detalhes técnicos e limitações.
126
+
127
+ ### Via `gradio_client` (Python)
128
+
129
+ ```python
130
+ from gradio_client import Client
131
+
132
+ client = Client("<seu-usuario>/<nome-do-space>", hf_token="hf_...")
133
+
134
+ # Só a probabilidade
135
+ card_html, payload = client.predict(
136
+ "Segundo o Ministério da Saúde, o número é falso. Fonte: https://...",
137
+ api_name="/predict",
138
+ )
139
+ print(payload)
140
+ # {'proba_util': 0.87, 'label': 'Útil', 'confidence_band': 'Alta'}
141
+
142
+ # Com explicação
143
+ card, highlight_html, tokens_html, full = client.predict(
144
+ "Essa nota é claramente desnecessária, opinião pessoal.",
145
+ api_name="/explain",
146
+ )
147
+ print(full["tokens"][:3], full["contributions"][:3])
148
+ ```
149
+
150
+ ### Via HTTP puro
151
+
152
+ Gradio 5 expõe as rotas em `/gradio_api/call/<api_name>`. Veja a doc oficial
153
+ em `https://<seu-space>.hf.space/?view=api` — o próprio Space gera a documentação
154
+ e exemplos de `curl` para os dois endpoints.
155
+
156
+ ## Arquitetura e decisões
157
+
158
+ ### Por que este stack
159
+
160
+ - **Gradio 5 em vez de FastAPI puro**: entrega UI + HTTP API de uma vez, com doc
161
+ automática. Para um endpoint privado de MVP, dobrar a utilidade sem dobrar o
162
+ código é o trade certo.
163
+ - **Occlusion em vez de SHAP Partition**: o notebook gasta 12–15 s/nota em SHAP
164
+ textual, justamente porque explora combinações de subconjuntos. Para servir
165
+ em tempo real, leave-one-out por palavra dá um `Δ` por token em ~N+1 forward
166
+ passes — 1 a 2 s para notas típicas, resultado visualmente comparável.
167
+ - **Fold único**: o notebook também usou fold único para SHAP textual. Ensemble
168
+ dos 5 folds é a extensão natural (listar `[(adapter_i, head_i) for i in 1..5]`,
169
+ mediar as sigmóides), mas não é obrigatório para o MVP.
170
+
171
+ ### O que muda se você quiser escalar
172
+
173
+ - **Ensemble**: substituir `load_model()` por `load_models()` devolvendo uma lista
174
+ de pares `(encoder, head)`. `predict_batch` itera, mediana ou média das
175
+ probabilidades. Dobra VRAM e latência — só vale quando a performance marginal
176
+ justificar.
177
+ - **Vizinhos semânticos** (como na seção 5 do notebook): exige embutir
178
+ `embeddings_qwen3_4b_finetuned.npz` (≈200 MB) e o dataset mestre para
179
+ recuperar texto + label. É uma extensão natural — crie um `artifacts/knn_index/`
180
+ com FAISS e adicione uma aba "Vizinhos" ao Gradio.
181
+ - **Inference Endpoint** dedicado: se o Space virar gargalo, o mesmo repositório
182
+ de código pode ser deployado como **Inference Endpoint pago** da HF, que
183
+ aguenta paralelismo real e autoescala.
184
+
185
+ ## Limitações
186
+
187
+ - O rótulo `helpful` mede **aceitabilidade bipartidária**, não qualidade editorial
188
+ — o notebook exemplifica casos em que vizinhos semânticos idênticos recebem
189
+ rótulos opostos por razões políticas, não textuais.
190
+ - Textos longos são truncados em 256 tokens.
191
+ - Predições são dependentes do fold servido; o notebook observou variação pequena
192
+ mas não nula entre folds.
193
+
194
+ ## Créditos
195
+
196
+ Baseado no pipeline e no notebook de explicabilidade do projeto Notinhas.
197
+ O código aqui é o protótipo funcional da função `predict_from_text` virado serviço.
app.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio app — endpoint de utilidade para community notes em PT-BR.
2
+
3
+ Expõe:
4
+ - UI web com três abas: Prever / Explicar / Sobre.
5
+ - API HTTP em /gradio_api/call/predict e /gradio_api/call/explain (gerada
6
+ automaticamente pelo Gradio a partir dos api_name).
7
+
8
+ Para clientes Python, use gradio_client:
9
+
10
+ from gradio_client import Client
11
+ c = Client("<user>/<space>", hf_token="hf_...")
12
+ score = c.predict("texto da nota...", api_name="/predict")
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import logging
18
+ import os
19
+ import traceback
20
+
21
+ import gradio as gr
22
+
23
+ from config import (
24
+ CONFIDENCE_BOUNDS_ALTA,
25
+ CONFIDENCE_BOUNDS_MEDIA,
26
+ THRESHOLD_UTIL,
27
+ )
28
+ from inference import DEVICE, explain_occlusion, predict_one, warmup
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
33
+ )
34
+ log = logging.getLogger("app")
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Warm-up agressivo — queremos que o primeiro request não pague cold-start
39
+ # ---------------------------------------------------------------------------
40
+ MODEL_READY: bool
41
+ MODEL_ERROR: str | None
42
+
43
+ try:
44
+ warmup()
45
+ MODEL_READY = True
46
+ MODEL_ERROR = None
47
+ log.info("Modelo carregado no startup. Device=%s", DEVICE)
48
+ except Exception as exc: # noqa: BLE001 — queremos pegar qualquer falha de carregamento
49
+ MODEL_READY = False
50
+ MODEL_ERROR = f"{type(exc).__name__}: {exc}"
51
+ log.error("Falha ao carregar modelo no startup:\n%s", traceback.format_exc())
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Helpers de apresentação
56
+ # ---------------------------------------------------------------------------
57
+ def _confidence_band(p: float) -> str:
58
+ lo_a, hi_a = CONFIDENCE_BOUNDS_ALTA
59
+ lo_m, hi_m = CONFIDENCE_BOUNDS_MEDIA
60
+ if p <= lo_a or p >= hi_a:
61
+ return "Alta"
62
+ if p <= lo_m or p >= hi_m:
63
+ return "Média"
64
+ return "Baixa"
65
+
66
+
67
+ def _label(p: float) -> str:
68
+ return "Útil" if p >= THRESHOLD_UTIL else "Não-útil"
69
+
70
+
71
+ def _score_card_html(p: float) -> str:
72
+ """Card principal do resultado — badge de label + badge de confiança + probabilidade."""
73
+ lbl = _label(p)
74
+ band = _confidence_band(p)
75
+
76
+ lbl_colors = {"Útil": ("#d8f3dc", "#1b4332"), "Não-útil": ("#fde2e4", "#9d0208")}
77
+ band_colors = {
78
+ "Alta": ("#d8f3dc", "#1b4332"),
79
+ "Média": ("#fff3bf", "#7c5c00"),
80
+ "Baixa": ("#e9ecef", "#495057"),
81
+ }
82
+ lbg, lfg = lbl_colors[lbl]
83
+ bbg, bfg = band_colors[band]
84
+
85
+ return f"""
86
+ <div style="background:#fff;border:1px solid #e9ecef;border-radius:16px;
87
+ padding:18px 22px;box-shadow:0 4px 14px rgba(0,0,0,0.04);
88
+ font-family:system-ui, -apple-system, sans-serif;">
89
+ <div style="display:flex;justify-content:space-between;align-items:center;
90
+ gap:12px;flex-wrap:wrap;">
91
+ <div style="display:flex;gap:8px;flex-wrap:wrap;">
92
+ <span style="background:{lbg};color:{lfg};padding:4px 12px;
93
+ border-radius:999px;font-size:13px;font-weight:700;">{lbl}</span>
94
+ <span style="background:{bbg};color:{bfg};padding:4px 12px;
95
+ border-radius:999px;font-size:13px;font-weight:700;">
96
+ Confiança {band}
97
+ </span>
98
+ </div>
99
+ <div style="text-align:right;">
100
+ <div style="font-size:12px;color:#6c757d;">P(útil)</div>
101
+ <div style="font-size:32px;font-weight:800;color:#2b2d42;
102
+ font-variant-numeric:tabular-nums;">{p:.3f}</div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ """
107
+
108
+
109
+ def _contrib_color(v: float, v_max: float) -> str:
110
+ if v_max <= 0:
111
+ return "transparent"
112
+ intensity = min(1.0, abs(v) / v_max)
113
+ alpha = 0.15 + 0.65 * intensity # 0.15 .. 0.80
114
+ if v > 0:
115
+ return f"rgba(95, 168, 143, {alpha:.3f})" # verde (PALETA['util'] do notebook)
116
+ return f"rgba(224, 123, 107, {alpha:.3f})" # coral (PALETA['nao_util'])
117
+
118
+
119
+ def _highlighted_text_html(tokens: list[str], contribs: list[float]) -> str:
120
+ if not tokens:
121
+ return "<em>(sem palavras para destacar)</em>"
122
+ v_max = max((abs(c) for c in contribs), default=1e-9) or 1e-9
123
+ spans = []
124
+ for tok, c in zip(tokens, contribs):
125
+ bg = _contrib_color(c, v_max)
126
+ spans.append(
127
+ f'<span style="background:{bg};padding:2px 4px;border-radius:4px;'
128
+ f'margin:0 1px;" title="Δ={c:+.4f}">{html.escape(tok)}</span>'
129
+ )
130
+ return (
131
+ '<div style="font-size:15px;line-height:2;color:#212529;'
132
+ 'font-family:system-ui, -apple-system, sans-serif;padding:4px;">'
133
+ + " ".join(spans)
134
+ + "</div>"
135
+ )
136
+
137
+
138
+ def _top_tokens_table_html(
139
+ tokens: list[str], contribs: list[float], k: int = 5
140
+ ) -> str:
141
+ pairs = list(zip(tokens, contribs))
142
+ pos = sorted([p for p in pairs if p[1] > 0], key=lambda x: -x[1])[:k]
143
+ neg = sorted([p for p in pairs if p[1] < 0], key=lambda x: x[1])[:k]
144
+
145
+ def _row(tok: str, v: float, side: str) -> str:
146
+ color = "#1b4332" if side == "pos" else "#9d0208"
147
+ sign = "+" if v > 0 else ""
148
+ return (
149
+ f'<tr><td style="padding:5px 8px;color:{color};">'
150
+ f"{html.escape(tok)}</td>"
151
+ f'<td style="padding:5px 8px;text-align:right;color:{color};'
152
+ f'font-variant-numeric:tabular-nums;">{sign}{v:.4f}</td></tr>'
153
+ )
154
+
155
+ empty = '<tr><td colspan="2" style="padding:6px;color:#9aa1aa;"><em>—</em></td></tr>'
156
+ pos_rows = "".join(_row(t, v, "pos") for t, v in pos) or empty
157
+ neg_rows = "".join(_row(t, v, "neg") for t, v in neg) or empty
158
+
159
+ return f"""
160
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;
161
+ font-family:system-ui, -apple-system, sans-serif;">
162
+ <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
163
+ <div style="font-size:13px;font-weight:700;color:#1b4332;margin-bottom:6px;">
164
+ Empurram para útil
165
+ </div>
166
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">{pos_rows}</table>
167
+ </div>
168
+ <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
169
+ <div style="font-size:13px;font-weight:700;color:#9d0208;margin-bottom:6px;">
170
+ Empurram para não-útil
171
+ </div>
172
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">{neg_rows}</table>
173
+ </div>
174
+ </div>
175
+ """
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Handlers — retornam HTML para a UI + JSON para a API
180
+ # ---------------------------------------------------------------------------
181
+ def handle_predict(text: str):
182
+ text = (text or "").strip()
183
+ if not text:
184
+ return "<em>Forneça um texto.</em>", {"error": "empty_input"}
185
+ if not MODEL_READY:
186
+ err = MODEL_ERROR or "modelo indisponível"
187
+ return (
188
+ f"<em>Modelo indisponível: {html.escape(err)}</em>",
189
+ {"error": "model_unavailable", "detail": err},
190
+ )
191
+
192
+ p = predict_one(text)
193
+ return (
194
+ _score_card_html(p),
195
+ {
196
+ "proba_util": p,
197
+ "label": _label(p),
198
+ "confidence_band": _confidence_band(p),
199
+ },
200
+ )
201
+
202
+
203
+ def handle_explain(text: str):
204
+ text = (text or "").strip()
205
+ if not text:
206
+ return "<em>Forneça um texto.</em>", "", "", {"error": "empty_input"}
207
+ if not MODEL_READY:
208
+ err = MODEL_ERROR or "modelo indisponível"
209
+ return (
210
+ f"<em>Modelo indisponível: {html.escape(err)}</em>",
211
+ "",
212
+ "",
213
+ {"error": "model_unavailable", "detail": err},
214
+ )
215
+
216
+ result = explain_occlusion(text)
217
+ p = result["proba_full"]
218
+ tokens = result["tokens"]
219
+ contribs = result["contributions"]
220
+
221
+ return (
222
+ _score_card_html(p),
223
+ _highlighted_text_html(tokens, contribs),
224
+ _top_tokens_table_html(tokens, contribs),
225
+ {
226
+ "proba_util": p,
227
+ "label": _label(p),
228
+ "confidence_band": _confidence_band(p),
229
+ "tokens": tokens,
230
+ "contributions": contribs,
231
+ },
232
+ )
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # UI
237
+ # ---------------------------------------------------------------------------
238
+ EXAMPLE_UTIL = (
239
+ "Segundo dados oficiais do Ministério da Saúde, o número citado no tweet é falso. "
240
+ "A fonte correta pode ser conferida no link: https://www.gov.br/saude/..."
241
+ )
242
+ EXAMPLE_NAO = "Essa nota é claramente desnecessária, é opinião pessoal do autor."
243
+
244
+ INTRO_MD = """
245
+ # Notinhas — endpoint de utilidade (FT-Solo)
246
+
247
+ Classificador de utilidade para **community notes em português**, baseado em
248
+ **Qwen3-Embedding-4B + LoRA + cabeça linear** (modo fiel do FT-Solo, fold 01).
249
+
250
+ - **Prever** — score + label + faixa de confiança.
251
+ - **Explicar** — o mesmo + contribuição de cada palavra via leave-one-out.
252
+ - **Sobre** — detalhes técnicos e limitações.
253
+ """
254
+
255
+
256
+ with gr.Blocks(
257
+ title="Notinhas — endpoint de utilidade (FT-Solo)",
258
+ theme=gr.themes.Soft(primary_hue="emerald", neutral_hue="slate"),
259
+ ) as demo:
260
+ gr.Markdown(INTRO_MD)
261
+
262
+ if not MODEL_READY:
263
+ gr.Markdown(
264
+ f"""
265
+ > ⚠️ **Modelo não carregou.** Detalhe: `{html.escape(MODEL_ERROR or '')}`
266
+ >
267
+ > Verifique que `artifacts/fold_01_adapter/` e `artifacts/fold_01_head.pt` estão presentes
268
+ > no repositório do Space. Se o modelo base exigir autenticação, configure `HF_TOKEN` em
269
+ > **Settings → Variables and secrets**.
270
+ """
271
+ )
272
+
273
+ with gr.Tab("Prever"):
274
+ with gr.Row():
275
+ with gr.Column(scale=2):
276
+ inp_p = gr.Textbox(
277
+ label="Texto da nota",
278
+ placeholder="Cole aqui o texto em português...",
279
+ lines=7,
280
+ max_lines=25,
281
+ )
282
+ btn_p = gr.Button("Prever", variant="primary")
283
+ gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_p])
284
+ with gr.Column(scale=3):
285
+ out_card_p = gr.HTML(label="Resultado")
286
+ out_json_p = gr.JSON(label="Resposta da API")
287
+
288
+ btn_p.click(
289
+ handle_predict,
290
+ inputs=[inp_p],
291
+ outputs=[out_card_p, out_json_p],
292
+ api_name="predict",
293
+ )
294
+
295
+ with gr.Tab("Explicar"):
296
+ with gr.Row():
297
+ with gr.Column(scale=2):
298
+ inp_e = gr.Textbox(
299
+ label="Texto da nota",
300
+ placeholder="Cole aqui o texto em português...",
301
+ lines=7,
302
+ max_lines=25,
303
+ )
304
+ btn_e = gr.Button("Explicar", variant="primary")
305
+ gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_e])
306
+ with gr.Column(scale=3):
307
+ out_card_e = gr.HTML(label="Resultado")
308
+ out_hl = gr.HTML(label="Contribuição por palavra")
309
+ out_tbl = gr.HTML(label="Top tokens por lado")
310
+ out_json_e = gr.JSON(label="Resposta da API")
311
+
312
+ btn_e.click(
313
+ handle_explain,
314
+ inputs=[inp_e],
315
+ outputs=[out_card_e, out_hl, out_tbl, out_json_e],
316
+ api_name="explain",
317
+ )
318
+
319
+ with gr.Tab("Sobre"):
320
+ gr.Markdown(
321
+ f"""
322
+ ### Detalhes técnicos
323
+
324
+ - **Modelo base**: `Qwen/Qwen3-Embedding-4B` (embedding, 2.560 dims, last-token pooling).
325
+ - **Adaptação**: LoRA treinado com alvo `label_binary_strict` (recorte A do projeto).
326
+ - **Cabeça**: `nn.Linear(2560, 1)` → sigmoid.
327
+ - **Prompt de instrução** (idêntico ao treino):
328
+
329
+ > `Instruct: Represent the following Brazilian Portuguese community note for binary classification of helpfulness.`
330
+ > `Query: <texto>`
331
+
332
+ - **max_length**: 256 tokens.
333
+ - **Dispositivo atual**: `{DEVICE}`.
334
+ - **Fold servido**: 01 (melhor fold segundo o manifesto do pipeline).
335
+
336
+ ### Método de explicação
337
+
338
+ A aba **Explicar** usa **occlusion word-level** (leave-one-out): para cada palavra
339
+ separada por espaço, calculamos `Δ = P(texto completo) − P(texto sem a palavra)`.
340
+
341
+ - Δ positivo ⇒ palavra puxando para **útil** (verde).
342
+ - Δ negativo ⇒ palavra puxando para **não-útil** (coral).
343
+
344
+ É uma aproximação rápida do SHAP Partition usado no notebook de explicabilidade
345
+ (~1–2 s vs ~12–15 s em GPU), com resultados visualmente comparáveis para notas curtas.
346
+
347
+ ### Limitações
348
+
349
+ - O rótulo `helpful` mede **aceitabilidade bipartidária**, não qualidade editorial.
350
+ A galeria curada do notebook mostra casos onde vizinhos semânticos idênticos
351
+ recebem rótulos opostos por razões políticas.
352
+ - Textos são truncados em 256 tokens.
353
+ - Este endpoint serve um único fold. Para produção com ganho marginal de robustez,
354
+ subir para ensemble dos 5 folds (média de probabilidades).
355
+ """
356
+ )
357
+
358
+
359
+ if __name__ == "__main__":
360
+ demo.queue(default_concurrency_limit=1).launch(
361
+ server_name="0.0.0.0",
362
+ server_port=int(os.environ.get("PORT", 7860)),
363
+ show_api=True,
364
+ )
artifacts/fold_01_adapter/README.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: Qwen/Qwen3-Embedding-4B
3
+ library_name: peft
4
+ tags:
5
+ - base_model:adapter:Qwen/Qwen3-Embedding-4B
6
+ - lora
7
+ - transformers
8
+ ---
9
+
10
+ # Model Card for Model ID
11
+
12
+ <!-- Provide a quick summary of what the model is/does. -->
13
+
14
+
15
+
16
+ ## Model Details
17
+
18
+ ### Model Description
19
+
20
+ <!-- Provide a longer summary of what this model is. -->
21
+
22
+
23
+
24
+ - **Developed by:** [More Information Needed]
25
+ - **Funded by [optional]:** [More Information Needed]
26
+ - **Shared by [optional]:** [More Information Needed]
27
+ - **Model type:** [More Information Needed]
28
+ - **Language(s) (NLP):** [More Information Needed]
29
+ - **License:** [More Information Needed]
30
+ - **Finetuned from model [optional]:** [More Information Needed]
31
+
32
+ ### Model Sources [optional]
33
+
34
+ <!-- Provide the basic links for the model. -->
35
+
36
+ - **Repository:** [More Information Needed]
37
+ - **Paper [optional]:** [More Information Needed]
38
+ - **Demo [optional]:** [More Information Needed]
39
+
40
+ ## Uses
41
+
42
+ <!-- Address questions around how the model is intended to be used, including the foreseeable users of the model and those affected by the model. -->
43
+
44
+ ### Direct Use
45
+
46
+ <!-- This section is for the model use without fine-tuning or plugging into a larger ecosystem/app. -->
47
+
48
+ [More Information Needed]
49
+
50
+ ### Downstream Use [optional]
51
+
52
+ <!-- This section is for the model use when fine-tuned for a task, or when plugged into a larger ecosystem/app -->
53
+
54
+ [More Information Needed]
55
+
56
+ ### Out-of-Scope Use
57
+
58
+ <!-- This section addresses misuse, malicious use, and uses that the model will not work well for. -->
59
+
60
+ [More Information Needed]
61
+
62
+ ## Bias, Risks, and Limitations
63
+
64
+ <!-- This section is meant to convey both technical and sociotechnical limitations. -->
65
+
66
+ [More Information Needed]
67
+
68
+ ### Recommendations
69
+
70
+ <!-- This section is meant to convey recommendations with respect to the bias, risk, and technical limitations. -->
71
+
72
+ Users (both direct and downstream) should be made aware of the risks, biases and limitations of the model. More information needed for further recommendations.
73
+
74
+ ## How to Get Started with the Model
75
+
76
+ Use the code below to get started with the model.
77
+
78
+ [More Information Needed]
79
+
80
+ ## Training Details
81
+
82
+ ### Training Data
83
+
84
+ <!-- This should link to a Dataset Card, perhaps with a short stub of information on what the training data is all about as well as documentation related to data pre-processing or additional filtering. -->
85
+
86
+ [More Information Needed]
87
+
88
+ ### Training Procedure
89
+
90
+ <!-- This relates heavily to the Technical Specifications. Content here should link to that section when it is relevant to the training procedure. -->
91
+
92
+ #### Preprocessing [optional]
93
+
94
+ [More Information Needed]
95
+
96
+
97
+ #### Training Hyperparameters
98
+
99
+ - **Training regime:** [More Information Needed] <!--fp32, fp16 mixed precision, bf16 mixed precision, bf16 non-mixed precision, fp16 non-mixed precision, fp8 mixed precision -->
100
+
101
+ #### Speeds, Sizes, Times [optional]
102
+
103
+ <!-- This section provides information about throughput, start/end time, checkpoint size if relevant, etc. -->
104
+
105
+ [More Information Needed]
106
+
107
+ ## Evaluation
108
+
109
+ <!-- This section describes the evaluation protocols and provides the results. -->
110
+
111
+ ### Testing Data, Factors & Metrics
112
+
113
+ #### Testing Data
114
+
115
+ <!-- This should link to a Dataset Card if possible. -->
116
+
117
+ [More Information Needed]
118
+
119
+ #### Factors
120
+
121
+ <!-- These are the things the evaluation is disaggregating by, e.g., subpopulations or domains. -->
122
+
123
+ [More Information Needed]
124
+
125
+ #### Metrics
126
+
127
+ <!-- These are the evaluation metrics being used, ideally with a description of why. -->
128
+
129
+ [More Information Needed]
130
+
131
+ ### Results
132
+
133
+ [More Information Needed]
134
+
135
+ #### Summary
136
+
137
+
138
+
139
+ ## Model Examination [optional]
140
+
141
+ <!-- Relevant interpretability work for the model goes here -->
142
+
143
+ [More Information Needed]
144
+
145
+ ## Environmental Impact
146
+
147
+ <!-- Total emissions (in grams of CO2eq) and additional considerations, such as electricity usage, go here. Edit the suggested text below accordingly -->
148
+
149
+ Carbon emissions can be estimated using the [Machine Learning Impact calculator](https://mlco2.github.io/impact#compute) presented in [Lacoste et al. (2019)](https://arxiv.org/abs/1910.09700).
150
+
151
+ - **Hardware Type:** [More Information Needed]
152
+ - **Hours used:** [More Information Needed]
153
+ - **Cloud Provider:** [More Information Needed]
154
+ - **Compute Region:** [More Information Needed]
155
+ - **Carbon Emitted:** [More Information Needed]
156
+
157
+ ## Technical Specifications [optional]
158
+
159
+ ### Model Architecture and Objective
160
+
161
+ [More Information Needed]
162
+
163
+ ### Compute Infrastructure
164
+
165
+ [More Information Needed]
166
+
167
+ #### Hardware
168
+
169
+ [More Information Needed]
170
+
171
+ #### Software
172
+
173
+ [More Information Needed]
174
+
175
+ ## Citation [optional]
176
+
177
+ <!-- If there is a paper or blog post introducing the model, the APA and Bibtex information for that should go in this section. -->
178
+
179
+ **BibTeX:**
180
+
181
+ [More Information Needed]
182
+
183
+ **APA:**
184
+
185
+ [More Information Needed]
186
+
187
+ ## Glossary [optional]
188
+
189
+ <!-- If relevant, include terms and calculations in this section that can help readers understand the model or model card. -->
190
+
191
+ [More Information Needed]
192
+
193
+ ## More Information [optional]
194
+
195
+ [More Information Needed]
196
+
197
+ ## Model Card Authors [optional]
198
+
199
+ [More Information Needed]
200
+
201
+ ## Model Card Contact
202
+
203
+ [More Information Needed]
204
+ ### Framework versions
205
+
206
+ - PEFT 0.18.1
artifacts/fold_01_adapter/adapter_config.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alora_invocation_tokens": null,
3
+ "alpha_pattern": {},
4
+ "arrow_config": null,
5
+ "auto_mapping": {
6
+ "base_model_class": "Qwen3Model",
7
+ "parent_library": "transformers.models.qwen3.modeling_qwen3"
8
+ },
9
+ "base_model_name_or_path": "Qwen/Qwen3-Embedding-4B",
10
+ "bias": "none",
11
+ "corda_config": null,
12
+ "ensure_weight_tying": false,
13
+ "eva_config": null,
14
+ "exclude_modules": null,
15
+ "fan_in_fan_out": false,
16
+ "inference_mode": true,
17
+ "init_lora_weights": true,
18
+ "layer_replication": null,
19
+ "layers_pattern": null,
20
+ "layers_to_transform": null,
21
+ "loftq_config": {},
22
+ "lora_alpha": 32,
23
+ "lora_bias": false,
24
+ "lora_dropout": 0.05,
25
+ "megatron_config": null,
26
+ "megatron_core": "megatron.core",
27
+ "modules_to_save": null,
28
+ "peft_type": "LORA",
29
+ "peft_version": "0.18.1",
30
+ "qalora_group_size": 16,
31
+ "r": 16,
32
+ "rank_pattern": {},
33
+ "revision": null,
34
+ "target_modules": [
35
+ "down_proj",
36
+ "q_proj",
37
+ "up_proj",
38
+ "gate_proj",
39
+ "k_proj",
40
+ "o_proj",
41
+ "v_proj"
42
+ ],
43
+ "target_parameters": null,
44
+ "task_type": null,
45
+ "trainable_token_indices": null,
46
+ "use_dora": false,
47
+ "use_qalora": false,
48
+ "use_rslora": false
49
+ }
artifacts/fold_01_adapter/adapter_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:326493c0cc026b088e80be86dc28fe61e21db919e52b602250e11abb6bac59b5
3
+ size 132184864
artifacts/fold_01_head.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7a66a6088bce2a00b93377ecc4f8243e061eccdc4679f4920fd691b35a0523ab
3
+ size 12365
config.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Constantes compartilhadas pelo Space.
2
+
3
+ Mantemos tudo em um único módulo para facilitar trocas (ex: substituir o fold
4
+ selecionado, apontar para um tokenizer diferente em debug, etc.).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Modelo base (baixado da Hugging Face no primeiro startup do Space)
13
+ # ---------------------------------------------------------------------------
14
+ MODEL_NAME = "Qwen/Qwen3-Embedding-4B"
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Inferência — parâmetros IDÊNTICOS aos do notebook (seção 6, predict_from_text)
18
+ # ---------------------------------------------------------------------------
19
+ MAX_LENGTH = 256
20
+ BATCH_SIZE = 8
21
+
22
+ # Este prompt é parte do contrato do modelo — foi usado no fine-tuning.
23
+ # Mudá-lo quebra o alinhamento entre o que o adapter viu e o que recebe agora.
24
+ TASK_PROMPT = (
25
+ "Represent the following Brazilian Portuguese community note "
26
+ "for binary classification of helpfulness."
27
+ )
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Paths dos artefatos (resolvidos a partir da raiz do repo do Space)
31
+ # ---------------------------------------------------------------------------
32
+ ROOT = Path(__file__).resolve().parent
33
+ ARTIFACTS_DIR = ROOT / "artifacts"
34
+
35
+ # Obrigatórios para servir predição.
36
+ ADAPTER_PATH = ARTIFACTS_DIR / "fold_01_adapter"
37
+ HEAD_PATH = ARTIFACTS_DIR / "fold_01_head.pt"
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Classificação (thresholds de apresentação — não afetam a probabilidade em si)
41
+ # ---------------------------------------------------------------------------
42
+ THRESHOLD_UTIL = 0.5
43
+
44
+ # Faixas de confiança em função de p diretamente (evita imprecisão float do |p-0.5|):
45
+ # Alta → p ≤ 0.10 ou p ≥ 0.90
46
+ # Média → p ≤ 0.30 ou p ≥ 0.70
47
+ # Baixa → 0.30 < p < 0.70
48
+ CONFIDENCE_BOUNDS_ALTA = (0.10, 0.90) # fora desses limites = Alta
49
+ CONFIDENCE_BOUNDS_MEDIA = (0.30, 0.70) # fora desses limites = Média
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Secrets (opcionais — definir em Settings → Secrets no Space)
53
+ # ---------------------------------------------------------------------------
54
+ HF_TOKEN = os.environ.get("HF_TOKEN") # só necessário se o modelo base virar gated
inference.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Carregamento do modelo e inferência.
2
+
3
+ Espelha o modo 'fiel' (faithful) do FT-Solo no notebook de explicabilidade:
4
+ base Qwen3-Embedding-4B + LoRA do fold 01 + cabeça linear treinada no projeto.
5
+
6
+ A função `predict_from_text` do notebook está reproduzida aqui com a mesma
7
+ tokenização, mesmo pooling, mesmo dtype e mesmo prompt — para que as
8
+ probabilidades retornadas sejam numericamente comparáveis às OOF salvas.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from functools import lru_cache
14
+ from typing import Iterable
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+ from peft import PeftModel
21
+ from transformers import AutoModel, AutoTokenizer
22
+
23
+ from config import (
24
+ ADAPTER_PATH,
25
+ BATCH_SIZE,
26
+ HEAD_PATH,
27
+ HF_TOKEN,
28
+ MAX_LENGTH,
29
+ MODEL_NAME,
30
+ TASK_PROMPT,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Dispositivo e dtype — lógica direta do notebook
37
+ # ---------------------------------------------------------------------------
38
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
39
+
40
+ if DEVICE == "cuda":
41
+ AMP_DTYPE = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
42
+ else:
43
+ # Em CPU usamos float16 nos pesos para caber em 16 GB de RAM (fp32 daria ~16 GB
44
+ # só nos pesos, sem sobrar para ativações). As operações em CPU rodam em fp32
45
+ # via upcast automático; o dtype aqui só controla o armazenamento.
46
+ # O autocast fica desligado (enabled=False abaixo) — fp16 ativo em CPU é instável.
47
+ AMP_DTYPE = torch.float16
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Utilitários — idênticos ao notebook (seção 6)
52
+ # ---------------------------------------------------------------------------
53
+ def build_instruction_text(text: str) -> str:
54
+ """Formata o texto no molde esperado pelo fine-tuning."""
55
+ if not isinstance(text, str):
56
+ text = ""
57
+ return f"Instruct: {TASK_PROMPT}\nQuery: {text}"
58
+
59
+
60
+ def last_token_pool(
61
+ last_hidden_states: torch.Tensor, attention_mask: torch.Tensor
62
+ ) -> torch.Tensor:
63
+ """Extrai o embedding do último token real.
64
+
65
+ Com o tokenizer em padding_side='left', o último índice (-1) é sempre um
66
+ token real para todos os elementos do batch, então podemos usar o atalho.
67
+ Mantemos a branch de right-padding por paranoia.
68
+ """
69
+ left_padding = bool(
70
+ (attention_mask[:, -1].sum() == attention_mask.shape[0]).item()
71
+ )
72
+ if left_padding:
73
+ return last_hidden_states[:, -1]
74
+ sequence_lengths = attention_mask.sum(dim=1) - 1
75
+ return last_hidden_states[
76
+ torch.arange(last_hidden_states.shape[0], device=last_hidden_states.device),
77
+ sequence_lengths,
78
+ ]
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Carregamento preguiçoso e cacheado
83
+ # ---------------------------------------------------------------------------
84
+ @lru_cache(maxsize=1)
85
+ def load_model():
86
+ """Retorna (tokenizer, encoder, head). Carregado uma única vez por processo."""
87
+ if not ADAPTER_PATH.exists():
88
+ raise FileNotFoundError(
89
+ f"Adapter LoRA não encontrado em {ADAPTER_PATH}. "
90
+ "Suba a pasta fold_01_adapter/ em artifacts/ antes de iniciar o Space."
91
+ )
92
+ if not HEAD_PATH.exists():
93
+ raise FileNotFoundError(
94
+ f"Cabeça classificadora não encontrada em {HEAD_PATH}. "
95
+ "Suba o fold_01_head.pt em artifacts/ antes de iniciar o Space."
96
+ )
97
+
98
+ logger.info("Carregando tokenizer de %s", MODEL_NAME)
99
+ tokenizer = AutoTokenizer.from_pretrained(
100
+ MODEL_NAME, padding_side="left", token=HF_TOKEN
101
+ )
102
+ if tokenizer.pad_token is None:
103
+ tokenizer.pad_token = tokenizer.eos_token
104
+
105
+ logger.info(
106
+ "Carregando encoder base %s (dtype=%s, device=%s)",
107
+ MODEL_NAME,
108
+ AMP_DTYPE,
109
+ DEVICE,
110
+ )
111
+ base_encoder = AutoModel.from_pretrained(
112
+ MODEL_NAME,
113
+ low_cpu_mem_usage=True,
114
+ torch_dtype=AMP_DTYPE,
115
+ token=HF_TOKEN,
116
+ ).to(DEVICE)
117
+
118
+ logger.info("Anexando adapter LoRA de %s", ADAPTER_PATH)
119
+ encoder = PeftModel.from_pretrained(
120
+ base_encoder, str(ADAPTER_PATH), is_trainable=False
121
+ ).to(DEVICE)
122
+ encoder.eval()
123
+
124
+ logger.info("Carregando cabeça linear de %s", HEAD_PATH)
125
+ head_payload = torch.load(HEAD_PATH, map_location="cpu")
126
+ # Suporta tanto {"state_dict": {...}} quanto o state_dict direto.
127
+ head_state = (
128
+ head_payload["state_dict"]
129
+ if isinstance(head_payload, dict) and "state_dict" in head_payload
130
+ else head_payload
131
+ )
132
+ in_feat = int(head_state["weight"].shape[1])
133
+ head = nn.Linear(in_feat, 1)
134
+ head.load_state_dict(head_state)
135
+ head = head.to(DEVICE).eval()
136
+
137
+ logger.info("Modelo pronto. In_features da cabeça: %d", in_feat)
138
+ return tokenizer, encoder, head
139
+
140
+
141
+ def warmup() -> None:
142
+ """Força o carregamento agora. Útil para que o primeiro request não pague cold-start."""
143
+ load_model()
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Predição — lógica do predict_from_text do notebook, preservada
148
+ # ---------------------------------------------------------------------------
149
+ @torch.no_grad()
150
+ def predict_batch(
151
+ texts: Iterable[str], batch_size: int = BATCH_SIZE
152
+ ) -> np.ndarray:
153
+ """Probabilidade de 'útil' para cada texto. Retorna np.array de shape (N,)."""
154
+ tokenizer, encoder, head = load_model()
155
+
156
+ if isinstance(texts, str):
157
+ texts = [texts]
158
+ texts = list(texts)
159
+ if not texts:
160
+ return np.zeros(0, dtype=np.float64)
161
+
162
+ preds = []
163
+ autocast_device = "cuda" if DEVICE == "cuda" else "cpu"
164
+
165
+ for i in range(0, len(texts), batch_size):
166
+ batch = texts[i : i + batch_size]
167
+ instr = [build_instruction_text(t) for t in batch]
168
+ toks = tokenizer(
169
+ instr,
170
+ padding=True,
171
+ truncation=True,
172
+ max_length=MAX_LENGTH,
173
+ return_tensors="pt",
174
+ ).to(DEVICE)
175
+
176
+ with torch.inference_mode(), torch.autocast(
177
+ device_type=autocast_device,
178
+ dtype=AMP_DTYPE,
179
+ enabled=(DEVICE == "cuda"),
180
+ ):
181
+ out = encoder(**toks)
182
+ emb = last_token_pool(out.last_hidden_state, toks["attention_mask"])
183
+ emb = F.normalize(emb, p=2, dim=1)
184
+ logits = head(emb).squeeze(-1)
185
+ p = torch.sigmoid(logits).float().cpu().numpy()
186
+ preds.append(p)
187
+
188
+ # Clip nos mesmos limites usados no notebook (evita proba exatamente 0 ou 1).
189
+ return np.clip(np.concatenate(preds).astype(np.float64), 1e-6, 1 - 1e-6)
190
+
191
+
192
+ def predict_one(text: str) -> float:
193
+ """Atalho: retorna a probabilidade escalar para um único texto."""
194
+ return float(predict_batch([text])[0])
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Explicação — occlusion word-level (leave-one-out)
199
+ # ---------------------------------------------------------------------------
200
+ def explain_occlusion(text: str, batch_size: int = BATCH_SIZE) -> dict:
201
+ """Importância por palavra via deixar-uma-fora.
202
+
203
+ Para cada palavra separada por espaço: calcula Δ = P(texto) − P(texto sem a palavra).
204
+ Δ > 0 → a palavra estava puxando para 'útil'
205
+ Δ < 0 → a palavra estava puxando para 'não-útil'
206
+
207
+ Custo: (N + 1) forward passes — ~metade do SHAP Partition do notebook,
208
+ resultado visual comparável para notas curtas.
209
+ """
210
+ words = text.split()
211
+ if not words:
212
+ p = predict_one(text)
213
+ return {"proba_full": p, "tokens": [], "contributions": []}
214
+
215
+ variants = [" ".join(words[:i] + words[i + 1 :]) for i in range(len(words))]
216
+ all_texts = [text] + variants
217
+ probs = predict_batch(all_texts, batch_size=batch_size)
218
+ p_full = float(probs[0])
219
+ contribs = (p_full - probs[1:]).tolist()
220
+
221
+ return {
222
+ "proba_full": p_full,
223
+ "tokens": words,
224
+ "contributions": contribs,
225
+ }
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Gradio é gerenciado pelo Space SDK (campo sdk_version no header do README.md).
2
+ # Torch é provido pelo runtime do HF Spaces conforme o hardware (CPU / T4 / A10G / ZeroGPU).
3
+ # Por isso nenhum dos dois aparece aqui.
4
+
5
+ transformers>=4.51.0
6
+ peft>=0.15.0
7
+ accelerate>=0.34.0
8
+ safetensors>=0.4.3