Spaces:
Running
Running
Jin Zhu commited on
Commit ·
2a268ef
1
Parent(s): 6beed25
update code
Browse files- requirements.txt +1 -1
- src/FineTune/.gitignore +2 -0
- src/FineTune/ckpt/domain_clf/README.md +206 -0
- src/FineTune/ckpt/domain_clf/adapter_config.json +48 -0
- src/FineTune/ckpt/domain_clf/adapter_model.safetensors +3 -0
- src/FineTune/ckpt/domain_clf/label_names.json +1 -0
- src/FineTune/model.py +542 -73
- src/app.py +133 -35
requirements.txt
CHANGED
|
@@ -3,7 +3,7 @@ gradio==5.31.0
|
|
| 3 |
pandas==2.3.1
|
| 4 |
torch==2.8.0
|
| 5 |
numpy==2.1.3
|
| 6 |
-
transformers==4.
|
| 7 |
peft==0.17.1
|
| 8 |
tqdm
|
| 9 |
scikit-learn
|
|
|
|
| 3 |
pandas==2.3.1
|
| 4 |
torch==2.8.0
|
| 5 |
numpy==2.1.3
|
| 6 |
+
transformers==4.57.1
|
| 7 |
peft==0.17.1
|
| 8 |
tqdm
|
| 9 |
scikit-learn
|
src/FineTune/.gitignore
CHANGED
|
@@ -163,3 +163,5 @@ cython_debug/
|
|
| 163 |
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 164 |
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 165 |
#.idea/
|
|
|
|
|
|
|
|
|
| 163 |
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 164 |
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 165 |
#.idea/
|
| 166 |
+
|
| 167 |
+
!ckpt/domain_clf
|
src/FineTune/ckpt/domain_clf/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
base_model: google/gemma-3-1b-pt
|
| 3 |
+
library_name: peft
|
| 4 |
+
tags:
|
| 5 |
+
- base_model:adapter:google/gemma-3-1b-pt
|
| 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.19.1
|
src/FineTune/ckpt/domain_clf/adapter_config.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"alora_invocation_tokens": null,
|
| 3 |
+
"alpha_pattern": {},
|
| 4 |
+
"arrow_config": null,
|
| 5 |
+
"auto_mapping": null,
|
| 6 |
+
"base_model_name_or_path": "google/gemma-3-1b-pt",
|
| 7 |
+
"bias": "none",
|
| 8 |
+
"corda_config": null,
|
| 9 |
+
"ensure_weight_tying": false,
|
| 10 |
+
"eva_config": null,
|
| 11 |
+
"exclude_modules": null,
|
| 12 |
+
"fan_in_fan_out": false,
|
| 13 |
+
"inference_mode": true,
|
| 14 |
+
"init_lora_weights": true,
|
| 15 |
+
"layer_replication": null,
|
| 16 |
+
"layers_pattern": null,
|
| 17 |
+
"layers_to_transform": null,
|
| 18 |
+
"loftq_config": {},
|
| 19 |
+
"lora_alpha": 32,
|
| 20 |
+
"lora_bias": false,
|
| 21 |
+
"lora_dropout": 0.05,
|
| 22 |
+
"lora_ga_config": null,
|
| 23 |
+
"megatron_config": null,
|
| 24 |
+
"megatron_core": "megatron.core",
|
| 25 |
+
"modules_to_save": [
|
| 26 |
+
"classifier",
|
| 27 |
+
"score"
|
| 28 |
+
],
|
| 29 |
+
"peft_type": "LORA",
|
| 30 |
+
"peft_version": "0.19.1",
|
| 31 |
+
"qalora_group_size": 16,
|
| 32 |
+
"r": 8,
|
| 33 |
+
"rank_pattern": {},
|
| 34 |
+
"revision": null,
|
| 35 |
+
"target_modules": [
|
| 36 |
+
"k_proj",
|
| 37 |
+
"o_proj",
|
| 38 |
+
"q_proj",
|
| 39 |
+
"v_proj"
|
| 40 |
+
],
|
| 41 |
+
"target_parameters": null,
|
| 42 |
+
"task_type": "SEQ_CLS",
|
| 43 |
+
"trainable_token_indices": null,
|
| 44 |
+
"use_bdlora": null,
|
| 45 |
+
"use_dora": false,
|
| 46 |
+
"use_qalora": false,
|
| 47 |
+
"use_rslora": false
|
| 48 |
+
}
|
src/FineTune/ckpt/domain_clf/adapter_model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4c99e0b144d6e36d98b25f1b6c456e0d413b2de2e1a1be424283c44d88196bed
|
| 3 |
+
size 6009592
|
src/FineTune/ckpt/domain_clf/label_names.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"label_names": ["Academia", "Finance", "Government", "Knowledge", "Legislation", "Medicine", "News", "UserReview"], "base_model": "gemma-1b"}
|
src/FineTune/model.py
CHANGED
|
@@ -1,16 +1,34 @@
|
|
| 1 |
import torch
|
| 2 |
from torch import nn
|
| 3 |
-
from peft import
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import time
|
| 6 |
import json
|
|
|
|
| 7 |
|
| 8 |
import os
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def calculate_MMD_loss(human_crit, sample_crit):
|
| 11 |
mmd_loss = human_crit.mean() - sample_crit.mean()
|
| 12 |
return mmd_loss
|
| 13 |
|
|
|
|
| 14 |
def from_pretrained(cls, model_name, kwargs, cache_dir, device=None):
|
| 15 |
# use local model if it exists
|
| 16 |
if "/" in model_name:
|
|
@@ -32,14 +50,29 @@ def from_pretrained(cls, model_name, kwargs, cache_dir, device=None):
|
|
| 32 |
remote_kwargs["device_map"] = {"": device}
|
| 33 |
return cls.from_pretrained(model_name, **remote_kwargs)
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
'gemma-1b': 'google/gemma-3-1b-pt',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
float16_models = []
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def get_model_fullname(model_name):
|
| 41 |
return model_fullnames[model_name] if model_name in model_fullnames else model_name
|
| 42 |
|
|
|
|
| 43 |
def load_tokenizer(model_name, for_dataset, cache_dir):
|
| 44 |
model_fullname = get_model_fullname(model_name)
|
| 45 |
optional_tok_kwargs = {}
|
|
@@ -54,36 +87,52 @@ def load_tokenizer(model_name, for_dataset, cache_dir):
|
|
| 54 |
base_tokenizer.pad_token_id = 0
|
| 55 |
return base_tokenizer
|
| 56 |
|
|
|
|
| 57 |
def get_sampling_discrepancy_analytic(logits_ref, logits_score, labels):
|
| 58 |
if logits_ref.size(-1) != logits_score.size(-1):
|
| 59 |
vocab_size = min(logits_ref.size(-1), logits_score.size(-1))
|
| 60 |
logits_ref = logits_ref[:, :, :vocab_size]
|
| 61 |
logits_score = logits_score[:, :, :vocab_size]
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
labels = labels.unsqueeze(-1) if labels.ndim == logits_score.ndim - 1 else labels
|
| 64 |
lprobs_score = torch.log_softmax(logits_score, dim=-1)
|
| 65 |
probs_ref = torch.softmax(logits_ref, dim=-1)
|
| 66 |
-
|
| 67 |
log_likelihood = lprobs_score.gather(dim=-1, index=labels).squeeze(-1)
|
| 68 |
mean_ref = (probs_ref * lprobs_score).sum(dim=-1)
|
| 69 |
var_ref = (probs_ref * torch.square(lprobs_score)).sum(dim=-1) - torch.square(mean_ref)
|
| 70 |
discrepancy = (log_likelihood.sum(dim=-1) - mean_ref.sum(dim=-1)) / var_ref.sum(dim=-1).clamp_min(0.0001).sqrt()
|
| 71 |
-
|
| 72 |
return discrepancy, log_likelihood.sum(dim=-1)
|
| 73 |
|
|
|
|
| 74 |
class ComputeStat(nn.Module):
|
| 75 |
-
def __init__(self, model_name, dataset='xsum', device='cuda', cache_dir='./models'):
|
| 76 |
super().__init__()
|
| 77 |
self.device = device
|
| 78 |
self.reference_model_name = get_model_fullname(model_name)
|
| 79 |
self.scoring_model_name = get_model_fullname(model_name)
|
| 80 |
-
|
| 81 |
-
def load_model(model_name, device, cache_dir):
|
| 82 |
model_fullname = get_model_fullname(model_name)
|
| 83 |
print(f'Loading model {model_fullname}...')
|
| 84 |
model_kwargs = {}
|
| 85 |
if model_name in float16_models:
|
| 86 |
model_kwargs.update(dict(torch_dtype=torch.float16))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
if torch.__version__ >= '2.0.0' and 'gemma' in model_name:
|
| 88 |
model_kwargs.update({'attn_implementation': 'sdpa'})
|
| 89 |
model = from_pretrained(AutoModelForCausalLM, model_fullname, model_kwargs, cache_dir, device=device)
|
|
@@ -92,29 +141,26 @@ class ComputeStat(nn.Module):
|
|
| 92 |
model.to(device)
|
| 93 |
print(f'DONE ({time.time() - start:.2f}s)')
|
| 94 |
return model
|
| 95 |
-
|
| 96 |
-
# load scoring model
|
|
|
|
| 97 |
self.scoring_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
|
| 98 |
-
|
|
|
|
| 99 |
if model_name in ['gemma-1b']:
|
| 100 |
-
|
| 101 |
-
task_type=TaskType.CAUSAL_LM,
|
| 102 |
-
inference_mode=False,
|
| 103 |
-
r=4,
|
| 104 |
-
lora_alpha=16,
|
| 105 |
-
lora_dropout=0.05,
|
| 106 |
-
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
|
| 107 |
-
)
|
| 108 |
else:
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
self.scoring_model = get_peft_model(scoring_model, self.peft_config)
|
| 117 |
-
|
| 118 |
# load sampling model
|
| 119 |
self.reference_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
|
| 120 |
reference_model = load_model(model_name, device, cache_dir)
|
|
@@ -126,14 +172,20 @@ class ComputeStat(nn.Module):
|
|
| 126 |
total = sum(p.numel() for p in self.scoring_model.parameters())
|
| 127 |
trainable = sum(p.numel() for p in self.scoring_model.parameters() if p.requires_grad)
|
| 128 |
print(f"Trainable / total (parameters): {trainable}/{total}={trainable/total}")
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def set_criterion_fn(self, criterion_fn):
|
| 131 |
if criterion_fn == "mean":
|
| 132 |
self.criterion = 'mean'
|
| 133 |
self.criterion_fn = get_sampling_discrepancy_analytic
|
| 134 |
else:
|
| 135 |
raise ValueError(f"Unknown criterion function: {criterion_fn}")
|
| 136 |
-
|
| 137 |
def print_gradient_requirement(self):
|
| 138 |
for name, param in self.named_parameters():
|
| 139 |
gradient_requirement = 'Requires Grad' if param.requires_grad else 'Does not require grad'
|
|
@@ -144,7 +196,6 @@ class ComputeStat(nn.Module):
|
|
| 144 |
def register_no_grad(self, module_names):
|
| 145 |
for name, param in self.named_parameters():
|
| 146 |
for selected_module in module_names:
|
| 147 |
-
# print(selected_module, name)
|
| 148 |
if selected_module in name:
|
| 149 |
param.requires_grad = False
|
| 150 |
|
|
@@ -154,23 +205,23 @@ class ComputeStat(nn.Module):
|
|
| 154 |
"""
|
| 155 |
os.makedirs(save_directory, exist_ok=True)
|
| 156 |
|
| 157 |
-
# 1.
|
| 158 |
if not save_null_distr_only:
|
| 159 |
scoring_dir = os.path.join(save_directory, "scoring_model")
|
| 160 |
self.scoring_model.save_pretrained(scoring_dir, safe_serialization=True)
|
| 161 |
|
| 162 |
-
# 2.
|
| 163 |
null_distrs = {}
|
| 164 |
for buffer_name, buffer_value in self.named_buffers():
|
| 165 |
if buffer_name.startswith("null_distr_"):
|
| 166 |
domain = buffer_name.replace("null_distr_", "")
|
| 167 |
null_distrs[domain] = buffer_value.detach().cpu()
|
| 168 |
-
|
| 169 |
if null_distrs:
|
| 170 |
torch.save(null_distrs, os.path.join(save_directory, "null_distrs.pt"))
|
| 171 |
print(f"✅ Saved {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
|
| 172 |
-
|
| 173 |
-
# 3.
|
| 174 |
config = {
|
| 175 |
"domains": list(null_distrs.keys()),
|
| 176 |
"criterion": getattr(self, "criterion", None),
|
|
@@ -178,38 +229,47 @@ class ComputeStat(nn.Module):
|
|
| 178 |
with open(os.path.join(save_directory, "config.json"), "w") as f:
|
| 179 |
json.dump(config, f)
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
print(f"✅ Model saved to {save_directory}")
|
| 182 |
|
| 183 |
@classmethod
|
| 184 |
def from_pretrained(cls, load_directory: str, *args, **kwargs):
|
| 185 |
"""
|
| 186 |
-
Load the scoring model, reference model,
|
|
|
|
| 187 |
"""
|
| 188 |
-
# 1.
|
| 189 |
model = cls(*args, **kwargs)
|
| 190 |
|
| 191 |
-
# 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
scoring_dir = os.path.join(load_directory, "scoring_model")
|
| 193 |
model.scoring_model = AutoPeftModelForCausalLM.from_pretrained(
|
| 194 |
scoring_dir,
|
| 195 |
-
# Same fix as `from_pretrained()` above: pin to model.device
|
| 196 |
-
# instead of 'auto' so this adapter checkpoint can't end up
|
| 197 |
-
# split across devices from the reference model it sits next to.
|
| 198 |
device_map={"": model.device},
|
| 199 |
low_cpu_mem_usage=True,
|
| 200 |
use_safetensors=True,
|
|
|
|
| 201 |
trust_remote_code=True,
|
| 202 |
)
|
| 203 |
|
| 204 |
-
# 3.
|
| 205 |
null_distrs_path = os.path.join(load_directory, "null_distrs.pt")
|
| 206 |
if os.path.exists(null_distrs_path):
|
| 207 |
null_distrs = torch.load(null_distrs_path, map_location="cpu")
|
| 208 |
for domain, null_distr in null_distrs.items():
|
| 209 |
model.set_null_distr(null_distr, domain)
|
| 210 |
print(f"✅ Restored {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
|
| 211 |
-
|
| 212 |
-
# 4.
|
| 213 |
config_path = os.path.join(load_directory, "config.json")
|
| 214 |
if os.path.exists(config_path):
|
| 215 |
with open(config_path, "r") as f:
|
|
@@ -218,9 +278,42 @@ class ComputeStat(nn.Module):
|
|
| 218 |
model.criterion = config["criterion"]
|
| 219 |
print(f"✅ Loaded config: {config}")
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
print(f"✅ Model loaded from {load_directory}")
|
| 222 |
return model
|
| 223 |
-
|
| 224 |
def compute_stats(self, tokenized=None, labels=[""], training_module=False):
|
| 225 |
if training_module:
|
| 226 |
logits_score = self.scoring_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:]
|
|
@@ -236,15 +329,15 @@ class ComputeStat(nn.Module):
|
|
| 236 |
def forward(self, text, training_module=True):
|
| 237 |
original_text = text[0]
|
| 238 |
sampled_text = text[1]
|
| 239 |
-
|
| 240 |
tokenized = self.scoring_tokenizer(original_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
|
| 241 |
-
labels = tokenized.input_ids[:, 1:]
|
| 242 |
train_original_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
|
| 243 |
-
|
| 244 |
tokenized = self.scoring_tokenizer(sampled_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
|
| 245 |
labels = tokenized.input_ids[:, 1:]
|
| 246 |
train_sampled_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
|
| 247 |
-
|
| 248 |
MMDloss = calculate_MMD_loss(train_original_crit, train_sampled_crit)
|
| 249 |
output = dict(crit=[train_original_crit.detach(), train_original_crit, train_sampled_crit.detach(), train_sampled_crit], loss=MMDloss)
|
| 250 |
return output
|
|
@@ -259,33 +352,98 @@ class ComputeStat(nn.Module):
|
|
| 259 |
if not isinstance(null_distr, torch.Tensor):
|
| 260 |
null_distr = torch.tensor(null_distr)
|
| 261 |
|
| 262 |
-
# detach + clone +
|
| 263 |
null_distr = null_distr.detach().clone().to(self.device)
|
| 264 |
|
| 265 |
-
#
|
| 266 |
self._buffers[distr_name] = null_distr
|
| 267 |
print(f"✅ Null distribution on {domain} with shape: {self._buffers[distr_name].shape} with mean {self._buffers[distr_name].mean():.4f} and std {self._buffers[distr_name].std():.4f}")
|
| 268 |
|
| 269 |
-
def
|
| 270 |
-
"""
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
tokenized = self.scoring_tokenizer(
|
| 278 |
-
text,
|
| 279 |
-
return_tensors="pt",
|
| 280 |
-
padding=True,
|
| 281 |
return_token_type_ids=False
|
| 282 |
).to(self.device)
|
| 283 |
-
labels = tokenized.input_ids[:, 1:]
|
| 284 |
-
|
| 285 |
with torch.inference_mode():
|
| 286 |
crit, _, _ = self.compute_stats(tokenized, labels, training_module=False)
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
distr_name = f"null_distr_{domain}"
|
| 290 |
if not hasattr(self, distr_name):
|
| 291 |
raise ValueError(
|
|
@@ -297,22 +455,333 @@ class ComputeStat(nn.Module):
|
|
| 297 |
|
| 298 |
return crit, p_value
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
def empirical_p_value(self, crit: torch.Tensor, null_distr: torch.Tensor):
|
| 301 |
# Compute p-value: (count + 1) / (total + 1)
|
| 302 |
total = null_distr.numel()
|
| 303 |
-
# count = (null_distr >= crit.unsqueeze(-1)).float().sum() # slow computation
|
| 304 |
count = total - torch.searchsorted(null_distr, crit, right=False)[0]
|
| 305 |
p_value = (count + 1.0) / (total + 1.0)
|
| 306 |
-
# print(f"p_value (slow): {p_value} & p_value (fast): {(count + 1) / (total + 1)}", )
|
| 307 |
return p_value
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
def get_available_domains(self):
|
| 310 |
"""
|
| 311 |
-
Get list of all available domains with null distributions
|
|
|
|
|
|
|
| 312 |
"""
|
| 313 |
-
domains =
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
domains.append(domain)
|
| 318 |
return domains
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import torch
|
| 2 |
from torch import nn
|
| 3 |
+
from peft import (
|
| 4 |
+
get_peft_model,
|
| 5 |
+
LoraConfig,
|
| 6 |
+
TaskType,
|
| 7 |
+
AutoPeftModelForCausalLM,
|
| 8 |
+
AutoPeftModelForSequenceClassification,
|
| 9 |
+
)
|
| 10 |
+
from transformers import (
|
| 11 |
+
AutoModelForCausalLM,
|
| 12 |
+
AutoModelForSequenceClassification,
|
| 13 |
+
AutoTokenizer,
|
| 14 |
+
)
|
| 15 |
import time
|
| 16 |
import json
|
| 17 |
+
import random
|
| 18 |
|
| 19 |
import os
|
| 20 |
|
| 21 |
+
try:
|
| 22 |
+
from transformers import AdamW
|
| 23 |
+
except ImportError:
|
| 24 |
+
from torch.optim import AdamW
|
| 25 |
+
|
| 26 |
+
|
| 27 |
def calculate_MMD_loss(human_crit, sample_crit):
|
| 28 |
mmd_loss = human_crit.mean() - sample_crit.mean()
|
| 29 |
return mmd_loss
|
| 30 |
|
| 31 |
+
|
| 32 |
def from_pretrained(cls, model_name, kwargs, cache_dir, device=None):
|
| 33 |
# use local model if it exists
|
| 34 |
if "/" in model_name:
|
|
|
|
| 50 |
remote_kwargs["device_map"] = {"": device}
|
| 51 |
return cls.from_pretrained(model_name, **remote_kwargs)
|
| 52 |
|
| 53 |
+
|
| 54 |
+
model_fullnames = {
|
| 55 |
+
# default base for the witness functions
|
| 56 |
'gemma-1b': 'google/gemma-3-1b-pt',
|
| 57 |
+
'gemma-4b': 'google/gemma-3-4b-pt',
|
| 58 |
+
'qwen-1.5b': 'Qwen/Qwen2.5-1.5B',
|
| 59 |
+
'falcon-1b': 'tiiuae/Falcon3-1B-Base',
|
| 60 |
+
'phi-1b': 'microsoft/phi-1',
|
| 61 |
}
|
| 62 |
float16_models = []
|
| 63 |
|
| 64 |
+
# Dtype for the *trainable* scoring model under gemma-1b. fp32 keeps the LoRA
|
| 65 |
+
# weight updates exact (bf16 would round away small lr*grad steps); the frozen
|
| 66 |
+
# reference model is always bf16. Switch this to torch.bfloat16 to also halve
|
| 67 |
+
# the scoring model's weight memory if you still hit OOM, at a small risk to
|
| 68 |
+
# AdaJASA training precision.
|
| 69 |
+
GEMMA1B_SCORING_DTYPE = torch.float32
|
| 70 |
+
|
| 71 |
+
|
| 72 |
def get_model_fullname(model_name):
|
| 73 |
return model_fullnames[model_name] if model_name in model_fullnames else model_name
|
| 74 |
|
| 75 |
+
|
| 76 |
def load_tokenizer(model_name, for_dataset, cache_dir):
|
| 77 |
model_fullname = get_model_fullname(model_name)
|
| 78 |
optional_tok_kwargs = {}
|
|
|
|
| 87 |
base_tokenizer.pad_token_id = 0
|
| 88 |
return base_tokenizer
|
| 89 |
|
| 90 |
+
|
| 91 |
def get_sampling_discrepancy_analytic(logits_ref, logits_score, labels):
|
| 92 |
if logits_ref.size(-1) != logits_score.size(-1):
|
| 93 |
vocab_size = min(logits_ref.size(-1), logits_score.size(-1))
|
| 94 |
logits_ref = logits_ref[:, :, :vocab_size]
|
| 95 |
logits_score = logits_score[:, :, :vocab_size]
|
| 96 |
|
| 97 |
+
# Evaluate the witness statistic in fp32 even when the models run in bf16:
|
| 98 |
+
# the full-vocab softmax, the probability-weighted variance and the
|
| 99 |
+
# var-normalised division are precision-sensitive. The upcast buffer is
|
| 100 |
+
# transient (freed each step), so memory impact is small.
|
| 101 |
+
logits_ref = logits_ref.float()
|
| 102 |
+
logits_score = logits_score.float()
|
| 103 |
+
|
| 104 |
labels = labels.unsqueeze(-1) if labels.ndim == logits_score.ndim - 1 else labels
|
| 105 |
lprobs_score = torch.log_softmax(logits_score, dim=-1)
|
| 106 |
probs_ref = torch.softmax(logits_ref, dim=-1)
|
| 107 |
+
|
| 108 |
log_likelihood = lprobs_score.gather(dim=-1, index=labels).squeeze(-1)
|
| 109 |
mean_ref = (probs_ref * lprobs_score).sum(dim=-1)
|
| 110 |
var_ref = (probs_ref * torch.square(lprobs_score)).sum(dim=-1) - torch.square(mean_ref)
|
| 111 |
discrepancy = (log_likelihood.sum(dim=-1) - mean_ref.sum(dim=-1)) / var_ref.sum(dim=-1).clamp_min(0.0001).sqrt()
|
| 112 |
+
|
| 113 |
return discrepancy, log_likelihood.sum(dim=-1)
|
| 114 |
|
| 115 |
+
|
| 116 |
class ComputeStat(nn.Module):
|
| 117 |
+
def __init__(self, model_name, dataset='xsum', device='cuda', cache_dir='./models', lora_r=None):
|
| 118 |
super().__init__()
|
| 119 |
self.device = device
|
| 120 |
self.reference_model_name = get_model_fullname(model_name)
|
| 121 |
self.scoring_model_name = get_model_fullname(model_name)
|
| 122 |
+
|
| 123 |
+
def load_model(model_name, device, cache_dir, dtype_override=None):
|
| 124 |
model_fullname = get_model_fullname(model_name)
|
| 125 |
print(f'Loading model {model_fullname}...')
|
| 126 |
model_kwargs = {}
|
| 127 |
if model_name in float16_models:
|
| 128 |
model_kwargs.update(dict(torch_dtype=torch.float16))
|
| 129 |
+
# Gemma-1b's ~256k vocab makes fp32 logits/activations very large;
|
| 130 |
+
# bf16 ~halves base-model + activation memory at no runtime cost.
|
| 131 |
+
if 'gemma-1b' in model_name:
|
| 132 |
+
model_kwargs.update(dict(torch_dtype=torch.bfloat16))
|
| 133 |
+
# Explicit override (e.g. keep the trainable scoring model in fp32).
|
| 134 |
+
if dtype_override is not None:
|
| 135 |
+
model_kwargs.update(dict(torch_dtype=dtype_override))
|
| 136 |
if torch.__version__ >= '2.0.0' and 'gemma' in model_name:
|
| 137 |
model_kwargs.update({'attn_implementation': 'sdpa'})
|
| 138 |
model = from_pretrained(AutoModelForCausalLM, model_fullname, model_kwargs, cache_dir, device=device)
|
|
|
|
| 141 |
model.to(device)
|
| 142 |
print(f'DONE ({time.time() - start:.2f}s)')
|
| 143 |
return model
|
| 144 |
+
|
| 145 |
+
# load scoring model (the trainable one). Keep gemma-1b in fp32 here so
|
| 146 |
+
# the LoRA witness updates stay precise; the frozen reference below is bf16.
|
| 147 |
self.scoring_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
|
| 148 |
+
scoring_dtype = GEMMA1B_SCORING_DTYPE if 'gemma-1b' in model_name else None
|
| 149 |
+
scoring_model = load_model(model_name, device, cache_dir, dtype_override=scoring_dtype)
|
| 150 |
if model_name in ['gemma-1b']:
|
| 151 |
+
default_r, alpha, dropout = 4, 16, 0.05
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
else:
|
| 153 |
+
default_r, alpha, dropout = 8, 32, 0.1
|
| 154 |
+
self.peft_config = LoraConfig(
|
| 155 |
+
task_type=TaskType.CAUSAL_LM,
|
| 156 |
+
inference_mode=False,
|
| 157 |
+
r=lora_r if lora_r is not None else default_r,
|
| 158 |
+
lora_alpha=alpha,
|
| 159 |
+
lora_dropout=dropout,
|
| 160 |
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
|
| 161 |
+
)
|
| 162 |
self.scoring_model = get_peft_model(scoring_model, self.peft_config)
|
| 163 |
+
|
| 164 |
# load sampling model
|
| 165 |
self.reference_tokenizer = load_tokenizer(model_name, dataset, cache_dir)
|
| 166 |
reference_model = load_model(model_name, device, cache_dir)
|
|
|
|
| 172 |
total = sum(p.numel() for p in self.scoring_model.parameters())
|
| 173 |
trainable = sum(p.numel() for p in self.scoring_model.parameters() if p.requires_grad)
|
| 174 |
print(f"Trainable / total (parameters): {trainable}/{total}={trainable/total}")
|
| 175 |
+
|
| 176 |
+
# Optional learned-domain estimator. When present, the special domain
|
| 177 |
+
# names "estimate" / "softest" route a text through this classifier to
|
| 178 |
+
# pick (or blend) the null distribution; see `_resolve_domain` /
|
| 179 |
+
# `_predict_domain_probs` and `compute_p_value` below.
|
| 180 |
+
self.domain_estimator = None
|
| 181 |
+
|
| 182 |
def set_criterion_fn(self, criterion_fn):
|
| 183 |
if criterion_fn == "mean":
|
| 184 |
self.criterion = 'mean'
|
| 185 |
self.criterion_fn = get_sampling_discrepancy_analytic
|
| 186 |
else:
|
| 187 |
raise ValueError(f"Unknown criterion function: {criterion_fn}")
|
| 188 |
+
|
| 189 |
def print_gradient_requirement(self):
|
| 190 |
for name, param in self.named_parameters():
|
| 191 |
gradient_requirement = 'Requires Grad' if param.requires_grad else 'Does not require grad'
|
|
|
|
| 196 |
def register_no_grad(self, module_names):
|
| 197 |
for name, param in self.named_parameters():
|
| 198 |
for selected_module in module_names:
|
|
|
|
| 199 |
if selected_module in name:
|
| 200 |
param.requires_grad = False
|
| 201 |
|
|
|
|
| 205 |
"""
|
| 206 |
os.makedirs(save_directory, exist_ok=True)
|
| 207 |
|
| 208 |
+
# 1. Save the scoring model (LoRA adapter + base model).
|
| 209 |
if not save_null_distr_only:
|
| 210 |
scoring_dir = os.path.join(save_directory, "scoring_model")
|
| 211 |
self.scoring_model.save_pretrained(scoring_dir, safe_serialization=True)
|
| 212 |
|
| 213 |
+
# 2. Save every null_distr_* buffer.
|
| 214 |
null_distrs = {}
|
| 215 |
for buffer_name, buffer_value in self.named_buffers():
|
| 216 |
if buffer_name.startswith("null_distr_"):
|
| 217 |
domain = buffer_name.replace("null_distr_", "")
|
| 218 |
null_distrs[domain] = buffer_value.detach().cpu()
|
| 219 |
+
|
| 220 |
if null_distrs:
|
| 221 |
torch.save(null_distrs, os.path.join(save_directory, "null_distrs.pt"))
|
| 222 |
print(f"✅ Saved {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
|
| 223 |
+
|
| 224 |
+
# 3. Save config (including the domain list).
|
| 225 |
config = {
|
| 226 |
"domains": list(null_distrs.keys()),
|
| 227 |
"criterion": getattr(self, "criterion", None),
|
|
|
|
| 229 |
with open(os.path.join(save_directory, "config.json"), "w") as f:
|
| 230 |
json.dump(config, f)
|
| 231 |
|
| 232 |
+
# 4. Save the domain estimator, if one has been trained.
|
| 233 |
+
if not save_null_distr_only and self.domain_estimator is not None:
|
| 234 |
+
self.domain_estimator.save_pretrained(save_directory)
|
| 235 |
+
|
| 236 |
print(f"✅ Model saved to {save_directory}")
|
| 237 |
|
| 238 |
@classmethod
|
| 239 |
def from_pretrained(cls, load_directory: str, *args, **kwargs):
|
| 240 |
"""
|
| 241 |
+
Load the scoring model, reference model, all null_distr buffers, and
|
| 242 |
+
(if present) the domain estimator.
|
| 243 |
"""
|
| 244 |
+
# 1. Construct the class.
|
| 245 |
model = cls(*args, **kwargs)
|
| 246 |
|
| 247 |
+
# 2. Load the scoring model.
|
| 248 |
+
# NOTE: pass cache_dir through so the PEFT base model (google/gemma-3-1b-pt,
|
| 249 |
+
# referenced in adapter_config.json) is resolved from the same cache used by
|
| 250 |
+
# the constructor above, instead of silently re-downloading into the default
|
| 251 |
+
# HF cache (~/.cache/huggingface). Pin to a single device instead of
|
| 252 |
+
# device_map='auto' so this adapter checkpoint can't end up split across
|
| 253 |
+
# devices from the reference model it sits next to.
|
| 254 |
scoring_dir = os.path.join(load_directory, "scoring_model")
|
| 255 |
model.scoring_model = AutoPeftModelForCausalLM.from_pretrained(
|
| 256 |
scoring_dir,
|
|
|
|
|
|
|
|
|
|
| 257 |
device_map={"": model.device},
|
| 258 |
low_cpu_mem_usage=True,
|
| 259 |
use_safetensors=True,
|
| 260 |
+
cache_dir=kwargs.get("cache_dir"),
|
| 261 |
trust_remote_code=True,
|
| 262 |
)
|
| 263 |
|
| 264 |
+
# 3. Load every null_distr.
|
| 265 |
null_distrs_path = os.path.join(load_directory, "null_distrs.pt")
|
| 266 |
if os.path.exists(null_distrs_path):
|
| 267 |
null_distrs = torch.load(null_distrs_path, map_location="cpu")
|
| 268 |
for domain, null_distr in null_distrs.items():
|
| 269 |
model.set_null_distr(null_distr, domain)
|
| 270 |
print(f"✅ Restored {len(null_distrs)} null distributions: {list(null_distrs.keys())}")
|
| 271 |
+
|
| 272 |
+
# 4. Load config.
|
| 273 |
config_path = os.path.join(load_directory, "config.json")
|
| 274 |
if os.path.exists(config_path):
|
| 275 |
with open(config_path, "r") as f:
|
|
|
|
| 278 |
model.criterion = config["criterion"]
|
| 279 |
print(f"✅ Loaded config: {config}")
|
| 280 |
|
| 281 |
+
# 5. Load the domain estimator, if the checkpoint has one.
|
| 282 |
+
# Pass our already-loaded `scoring_tokenizer` through: the classifier
|
| 283 |
+
# shares the exact same base-model tokenizer (gemma-1b), so this
|
| 284 |
+
# skips a second, redundant tokenizer load from disk/hub.
|
| 285 |
+
#
|
| 286 |
+
# This is wrapped in its own try/except: the domain estimator is an
|
| 287 |
+
# *optional* feature (only needed for domain="estimate"/"softest").
|
| 288 |
+
# A failure here -- e.g. an installed `transformers` version that
|
| 289 |
+
# doesn't yet map the base model's config to a SequenceClassification
|
| 290 |
+
# head -- must not take down the whole app; `model` (with manual
|
| 291 |
+
# domain selection) should still load and serve requests.
|
| 292 |
+
if os.path.exists(os.path.join(load_directory, DOMAIN_CLF_SUBDIR)):
|
| 293 |
+
try:
|
| 294 |
+
model.domain_estimator = DomainClassifier.from_pretrained(
|
| 295 |
+
load_directory,
|
| 296 |
+
cache_dir=kwargs.get("cache_dir", "./models"),
|
| 297 |
+
device=model.device,
|
| 298 |
+
tokenizer=model.scoring_tokenizer,
|
| 299 |
+
)
|
| 300 |
+
except Exception as e: # noqa: BLE001 — degrade gracefully, see comment above
|
| 301 |
+
print(
|
| 302 |
+
f"⚠️ Could not load domain estimator from {load_directory}: {e}\n"
|
| 303 |
+
f" Continuing without it -- domain='estimate'/'softest' will be "
|
| 304 |
+
f"unavailable, but manual domain selection still works."
|
| 305 |
+
)
|
| 306 |
+
model.domain_estimator = None
|
| 307 |
+
|
| 308 |
+
# Default to eval mode: a reloaded checkpoint is normally used for
|
| 309 |
+
# inference, and leaving LoRA dropout active would perturb the witness
|
| 310 |
+
# statistic away from the calibrated null distribution. Call .train()
|
| 311 |
+
# explicitly before any further fine-tuning.
|
| 312 |
+
model.scoring_model.eval()
|
| 313 |
+
|
| 314 |
print(f"✅ Model loaded from {load_directory}")
|
| 315 |
return model
|
| 316 |
+
|
| 317 |
def compute_stats(self, tokenized=None, labels=[""], training_module=False):
|
| 318 |
if training_module:
|
| 319 |
logits_score = self.scoring_model(tokenized.input_ids, attention_mask=tokenized.attention_mask).logits[:,:-1,:]
|
|
|
|
| 329 |
def forward(self, text, training_module=True):
|
| 330 |
original_text = text[0]
|
| 331 |
sampled_text = text[1]
|
| 332 |
+
|
| 333 |
tokenized = self.scoring_tokenizer(original_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
|
| 334 |
+
labels = tokenized.input_ids[:, 1:]
|
| 335 |
train_original_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
|
| 336 |
+
|
| 337 |
tokenized = self.scoring_tokenizer(sampled_text, return_tensors="pt", padding=True, return_token_type_ids=False).to(self.device)
|
| 338 |
labels = tokenized.input_ids[:, 1:]
|
| 339 |
train_sampled_crit, _, _ = self.compute_stats(tokenized, labels, training_module=training_module)
|
| 340 |
+
|
| 341 |
MMDloss = calculate_MMD_loss(train_original_crit, train_sampled_crit)
|
| 342 |
output = dict(crit=[train_original_crit.detach(), train_original_crit, train_sampled_crit.detach(), train_sampled_crit], loss=MMDloss)
|
| 343 |
return output
|
|
|
|
| 352 |
if not isinstance(null_distr, torch.Tensor):
|
| 353 |
null_distr = torch.tensor(null_distr)
|
| 354 |
|
| 355 |
+
# detach + clone + move to the right device
|
| 356 |
null_distr = null_distr.detach().clone().to(self.device)
|
| 357 |
|
| 358 |
+
# overwrite the buffer directly, to avoid issues with delattr
|
| 359 |
self._buffers[distr_name] = null_distr
|
| 360 |
print(f"✅ Null distribution on {domain} with shape: {self._buffers[distr_name].shape} with mean {self._buffers[distr_name].mean():.4f} and std {self._buffers[distr_name].std():.4f}")
|
| 361 |
|
| 362 |
+
def _resolve_domain(self, text, domain: str):
|
| 363 |
+
"""Map the requested domain to a concrete one. The special name
|
| 364 |
+
``"estimate"`` predicts a single, most-likely domain from ``text``
|
| 365 |
+
with the learned estimator (hard routing); any other name is returned
|
| 366 |
+
unchanged. For the soft/mixture routing used by ``"softest"``, see
|
| 367 |
+
``_predict_domain_probs`` and ``compute_p_value_softest`` below."""
|
| 368 |
+
if domain in (ESTIMATE_DOMAIN, "estimated"):
|
| 369 |
+
if self.domain_estimator is None:
|
| 370 |
+
raise ValueError(
|
| 371 |
+
"domain='estimate' requested but no domain estimator is "
|
| 372 |
+
"loaded. Train one with scripts/train_domain_clf.py (it is "
|
| 373 |
+
"saved into the checkpoint), or pass an explicit domain."
|
| 374 |
+
)
|
| 375 |
+
texts = [text] if isinstance(text, str) else list(text)
|
| 376 |
+
return self.domain_estimator.predict(texts)[0]
|
| 377 |
+
return domain
|
| 378 |
+
|
| 379 |
+
def _predict_domain_probs(self, text):
|
| 380 |
+
"""Return ``{domain: probability}`` for ``text``, from the learned
|
| 381 |
+
domain estimator, restricted to and renormalised over the domains
|
| 382 |
+
that have a calibrated null distribution.
|
| 383 |
+
|
| 384 |
+
This is the *soft* counterpart of ``_resolve_domain``'s ``"estimate"``:
|
| 385 |
+
instead of collapsing the estimator's output to a single most-likely
|
| 386 |
+
domain, it keeps the full predicted distribution, so a text that is
|
| 387 |
+
itself a mixture of domains can be scored against a weighted
|
| 388 |
+
combination of the candidate null distributions instead of being
|
| 389 |
+
forced into exactly one of them.
|
| 390 |
"""
|
| 391 |
+
if self.domain_estimator is None:
|
| 392 |
+
raise ValueError(
|
| 393 |
+
"domain='softest' requested but no domain estimator is "
|
| 394 |
+
"loaded. Train one with scripts/train_domain_clf.py (it is "
|
| 395 |
+
"saved into the checkpoint), or pass an explicit domain."
|
| 396 |
+
)
|
| 397 |
+
texts = [text] if isinstance(text, str) else list(text)
|
| 398 |
+
probs = self.domain_estimator.predict_proba(texts)[0]
|
| 399 |
+
|
| 400 |
+
available = set(self._null_distr_domains())
|
| 401 |
+
probs = {d: p for d, p in probs.items() if d in available}
|
| 402 |
+
z = sum(probs.values())
|
| 403 |
+
if z <= 0:
|
| 404 |
+
raise ValueError(
|
| 405 |
+
"No overlap between the domain estimator's labels and the "
|
| 406 |
+
"calibrated null distributions; cannot compute a 'softest' "
|
| 407 |
+
"p-value."
|
| 408 |
+
)
|
| 409 |
+
return {d: p / z for d, p in probs.items()}
|
| 410 |
+
|
| 411 |
+
def _compute_crit(self, text):
|
| 412 |
+
"""Tokenize ``text`` and compute the AdaJASA witness statistic. Shared
|
| 413 |
+
by both the hard-domain (``compute_p_value``) and the soft/mixture
|
| 414 |
+
(``compute_p_value_softest``) p-value paths."""
|
| 415 |
tokenized = self.scoring_tokenizer(
|
| 416 |
+
text,
|
| 417 |
+
return_tensors="pt",
|
| 418 |
+
padding=True,
|
| 419 |
return_token_type_ids=False
|
| 420 |
).to(self.device)
|
| 421 |
+
labels = tokenized.input_ids[:, 1:]
|
| 422 |
+
|
| 423 |
with torch.inference_mode():
|
| 424 |
crit, _, _ = self.compute_stats(tokenized, labels, training_module=False)
|
| 425 |
+
return crit
|
| 426 |
+
|
| 427 |
+
def compute_p_value(self, text, domain: str):
|
| 428 |
+
"""
|
| 429 |
+
Compute p-value for given text using the null distribution of specified domain.
|
| 430 |
+
|
| 431 |
+
Args:
|
| 432 |
+
text: Input text to compute score for
|
| 433 |
+
domain: Domain name to use for null distribution. Pass "estimate" to
|
| 434 |
+
let the learned estimator predict a single domain from the
|
| 435 |
+
text (hard routing), or "softest" to calibrate against a
|
| 436 |
+
weighted mixture of all calibrated domains' null
|
| 437 |
+
distributions -- weighted by the estimator's predicted
|
| 438 |
+
probabilities.
|
| 439 |
+
"""
|
| 440 |
+
if domain == SOFTEST_DOMAIN:
|
| 441 |
+
return self.compute_p_value_softest(text)
|
| 442 |
+
|
| 443 |
+
domain = self._resolve_domain(text, domain)
|
| 444 |
+
crit = self._compute_crit(text)
|
| 445 |
+
|
| 446 |
+
# Look up the null distribution for this domain.
|
| 447 |
distr_name = f"null_distr_{domain}"
|
| 448 |
if not hasattr(self, distr_name):
|
| 449 |
raise ValueError(
|
|
|
|
| 455 |
|
| 456 |
return crit, p_value
|
| 457 |
|
| 458 |
+
def compute_p_value_softest(self, text):
|
| 459 |
+
"""Soft/mixture p-value:
|
| 460 |
+
|
| 461 |
+
p-value = (1 + sum_k p_k * count_k) / (1 + sum_k p_k * m_k)
|
| 462 |
+
|
| 463 |
+
where, for each calibrated domain k, ``p_k`` is the estimated
|
| 464 |
+
probability that ``text`` belongs to domain k (from
|
| 465 |
+
``_predict_domain_probs``), ``m_k`` is the number of human-written
|
| 466 |
+
calibration texts collected for domain k, and ``count_k`` is the
|
| 467 |
+
number of those texts whose statistic falls below the observed
|
| 468 |
+
statistic S(text).
|
| 469 |
+
"""
|
| 470 |
+
domain_weights = self._predict_domain_probs(text)
|
| 471 |
+
crit = self._compute_crit(text)
|
| 472 |
+
|
| 473 |
+
numerator = 1.0
|
| 474 |
+
denominator = 1.0
|
| 475 |
+
for domain, weight in domain_weights.items():
|
| 476 |
+
if weight <= 0:
|
| 477 |
+
continue
|
| 478 |
+
null_distr = getattr(self, f"null_distr_{domain}")
|
| 479 |
+
m_k = null_distr.numel()
|
| 480 |
+
count_k = (m_k - torch.searchsorted(null_distr, crit, right=False)[0]).item()
|
| 481 |
+
numerator += weight * count_k
|
| 482 |
+
denominator += weight * m_k
|
| 483 |
+
|
| 484 |
+
p_value = torch.tensor(numerator / denominator, device=crit.device)
|
| 485 |
+
return crit, p_value
|
| 486 |
+
|
| 487 |
def empirical_p_value(self, crit: torch.Tensor, null_distr: torch.Tensor):
|
| 488 |
# Compute p-value: (count + 1) / (total + 1)
|
| 489 |
total = null_distr.numel()
|
|
|
|
| 490 |
count = total - torch.searchsorted(null_distr, crit, right=False)[0]
|
| 491 |
p_value = (count + 1.0) / (total + 1.0)
|
|
|
|
| 492 |
return p_value
|
| 493 |
|
| 494 |
+
def _null_distr_domains(self):
|
| 495 |
+
"""Domains with a concretely calibrated null distribution (excludes
|
| 496 |
+
the pseudo-domain names ``"estimate"`` / ``"softest"``, which are
|
| 497 |
+
resolved to a concrete domain -- or a weighted mixture of them -- at
|
| 498 |
+
inference time)."""
|
| 499 |
+
return [
|
| 500 |
+
buffer_name.replace("null_distr_", "")
|
| 501 |
+
for buffer_name in self._buffers.keys()
|
| 502 |
+
if buffer_name.startswith("null_distr_")
|
| 503 |
+
]
|
| 504 |
+
|
| 505 |
def get_available_domains(self):
|
| 506 |
"""
|
| 507 |
+
Get list of all available domains with null distributions, plus the
|
| 508 |
+
pseudo-domain names ("estimate", "softest") when a domain estimator
|
| 509 |
+
is loaded.
|
| 510 |
"""
|
| 511 |
+
domains = self._null_distr_domains()
|
| 512 |
+
if getattr(self, "domain_estimator", None) is not None:
|
| 513 |
+
domains.append(ESTIMATE_DOMAIN)
|
| 514 |
+
domains.append(SOFTEST_DOMAIN)
|
|
|
|
| 515 |
return domains
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
# Sub-directory (inside the AdaJASA checkpoint directory) that holds the learned
|
| 519 |
+
# domain estimator, so the classifier travels with the null distributions it
|
| 520 |
+
# selects between.
|
| 521 |
+
DOMAIN_CLF_SUBDIR = "domain_clf"
|
| 522 |
+
|
| 523 |
+
# Special domain name: route a text through the learned estimator instead of
|
| 524 |
+
# assuming the domain is known (oracle). Hard routing -- picks a single,
|
| 525 |
+
# most-likely domain (argmax).
|
| 526 |
+
ESTIMATE_DOMAIN = "estimate"
|
| 527 |
+
|
| 528 |
+
# Special domain name: soft/mixture routing. Instead of picking a single
|
| 529 |
+
# domain, calibrates against a weighted combination of every calibrated
|
| 530 |
+
# domain's null distribution, weighted by the domain estimator's predicted
|
| 531 |
+
# probabilities -- appropriate when the text may itself be a mixture of
|
| 532 |
+
# domains. See `ComputeStat.compute_p_value_softest`.
|
| 533 |
+
SOFTEST_DOMAIN = "softest"
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
class DomainClassifier(nn.Module):
|
| 537 |
+
"""A LoRA sequence classifier over text *domains*, sharing the gemma-1b base.
|
| 538 |
+
|
| 539 |
+
Instead of assuming the test domain is known a priori, we estimate it from
|
| 540 |
+
the text and let the predicted domain pick (or blend) which pre-computed
|
| 541 |
+
AdaJASA null distribution is used for the decision.
|
| 542 |
+
|
| 543 |
+
It is trained on ``(text, domain_label)`` pairs. Crucially, it never sees
|
| 544 |
+
the human/machine label and is not part of null-distribution calibration,
|
| 545 |
+
so it introduces no adaptivity into the p-values -- it only routes a test
|
| 546 |
+
text to the correct calibration. A separate LoRA adapter on the *same*
|
| 547 |
+
base model keeps this memory-efficient.
|
| 548 |
+
"""
|
| 549 |
+
|
| 550 |
+
def __init__(
|
| 551 |
+
self,
|
| 552 |
+
model_name,
|
| 553 |
+
label_names,
|
| 554 |
+
device="cuda",
|
| 555 |
+
cache_dir="./models",
|
| 556 |
+
lora_r=8,
|
| 557 |
+
max_length=512,
|
| 558 |
+
tokenizer=None,
|
| 559 |
+
_build_base=True,
|
| 560 |
+
):
|
| 561 |
+
super().__init__()
|
| 562 |
+
self.device = device
|
| 563 |
+
self.model_name = model_name
|
| 564 |
+
self.label_names = list(label_names)
|
| 565 |
+
self.label2id = {name: i for i, name in enumerate(self.label_names)}
|
| 566 |
+
self.id2label = {i: name for i, name in enumerate(self.label_names)}
|
| 567 |
+
self.max_length = max_length
|
| 568 |
+
# The classifier shares its base model with ComputeStat's
|
| 569 |
+
# scoring/reference models (same `model_name`), so it can reuse an
|
| 570 |
+
# already-loaded tokenizer (e.g. `ComputeStat.scoring_tokenizer`)
|
| 571 |
+
# instead of loading -- or saving/reloading -- its own copy.
|
| 572 |
+
self.tokenizer = tokenizer
|
| 573 |
+
self.model = None
|
| 574 |
+
|
| 575 |
+
# When loading via the classmethod `from_pretrained` below, the PEFT
|
| 576 |
+
# model is restored from disk, so skip building a fresh base here
|
| 577 |
+
# (avoids re-downloading / re-initialising the backbone).
|
| 578 |
+
if not _build_base:
|
| 579 |
+
return
|
| 580 |
+
|
| 581 |
+
model_fullname = get_model_fullname(model_name)
|
| 582 |
+
if self.tokenizer is None:
|
| 583 |
+
tok_kwargs = {"padding_side": "right"}
|
| 584 |
+
self.tokenizer = from_pretrained(AutoTokenizer, model_fullname, tok_kwargs, cache_dir=cache_dir)
|
| 585 |
+
if self.tokenizer.pad_token_id is None:
|
| 586 |
+
self.tokenizer.pad_token_id = self.tokenizer.eos_token_id
|
| 587 |
+
|
| 588 |
+
base_kwargs = {
|
| 589 |
+
"num_labels": len(self.label_names),
|
| 590 |
+
"id2label": self.id2label,
|
| 591 |
+
"label2id": self.label2id,
|
| 592 |
+
}
|
| 593 |
+
if "gemma-1b" in model_name:
|
| 594 |
+
base_kwargs["torch_dtype"] = torch.bfloat16
|
| 595 |
+
base_model = from_pretrained(AutoModelForSequenceClassification, model_fullname, base_kwargs, cache_dir, device=device)
|
| 596 |
+
base_model.config.pad_token_id = self.tokenizer.pad_token_id
|
| 597 |
+
|
| 598 |
+
peft_config = LoraConfig(
|
| 599 |
+
task_type=TaskType.SEQ_CLS,
|
| 600 |
+
inference_mode=False,
|
| 601 |
+
r=lora_r,
|
| 602 |
+
lora_alpha=lora_r * 4,
|
| 603 |
+
lora_dropout=0.05,
|
| 604 |
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
|
| 605 |
+
)
|
| 606 |
+
self.model = get_peft_model(base_model, peft_config)
|
| 607 |
+
self.model.to(device)
|
| 608 |
+
|
| 609 |
+
trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
|
| 610 |
+
total = sum(p.numel() for p in self.model.parameters())
|
| 611 |
+
print(f"[DomainClassifier] {len(self.label_names)} domains; "
|
| 612 |
+
f"trainable/total params: {trainable}/{total}={trainable / total:.4f}")
|
| 613 |
+
|
| 614 |
+
def fit(self, texts, labels, epochs=3, lr=1e-4, batch_size=8, seed=42):
|
| 615 |
+
"""Train on ``(texts, labels)`` where each label is a domain name."""
|
| 616 |
+
random.seed(seed)
|
| 617 |
+
self.model.train()
|
| 618 |
+
label_ids = [self.label2id[l] for l in labels]
|
| 619 |
+
optimizer = AdamW(self.model.parameters(), lr=lr)
|
| 620 |
+
n = len(texts)
|
| 621 |
+
order = list(range(n))
|
| 622 |
+
for epoch in range(epochs):
|
| 623 |
+
random.shuffle(order)
|
| 624 |
+
total_loss, correct, seen = 0.0, 0, 0
|
| 625 |
+
for start in range(0, n, batch_size):
|
| 626 |
+
idx = order[start:start + batch_size]
|
| 627 |
+
batch_texts = [texts[i] for i in idx]
|
| 628 |
+
batch_labels = torch.tensor([label_ids[i] for i in idx], device=self.device)
|
| 629 |
+
enc = self.tokenizer(
|
| 630 |
+
batch_texts, return_tensors="pt", padding=True, truncation=True,
|
| 631 |
+
max_length=self.max_length, return_token_type_ids=False,
|
| 632 |
+
).to(self.device)
|
| 633 |
+
optimizer.zero_grad()
|
| 634 |
+
out = self.model(**enc, labels=batch_labels)
|
| 635 |
+
out.loss.backward()
|
| 636 |
+
optimizer.step()
|
| 637 |
+
total_loss += out.loss.item() * len(idx)
|
| 638 |
+
correct += (out.logits.argmax(dim=-1) == batch_labels).sum().item()
|
| 639 |
+
seen += len(idx)
|
| 640 |
+
if (start // batch_size) % 50 == 0:
|
| 641 |
+
torch.cuda.empty_cache()
|
| 642 |
+
print(f"[DomainClassifier] epoch {epoch}: "
|
| 643 |
+
f"loss={total_loss / max(seen, 1):.4f} acc={correct / max(seen, 1):.4f}")
|
| 644 |
+
return self
|
| 645 |
+
|
| 646 |
+
@torch.no_grad()
|
| 647 |
+
def predict(self, texts, batch_size=16):
|
| 648 |
+
"""Return a list of predicted domain *names* for ``texts``."""
|
| 649 |
+
self.model.eval()
|
| 650 |
+
preds = []
|
| 651 |
+
for start in range(0, len(texts), batch_size):
|
| 652 |
+
batch_texts = texts[start:start + batch_size]
|
| 653 |
+
enc = self.tokenizer(
|
| 654 |
+
batch_texts, return_tensors="pt", padding=True, truncation=True,
|
| 655 |
+
max_length=self.max_length, return_token_type_ids=False,
|
| 656 |
+
).to(self.device)
|
| 657 |
+
logits = self.model(**enc).logits
|
| 658 |
+
preds.extend(self.id2label[i] for i in logits.argmax(dim=-1).tolist())
|
| 659 |
+
return preds
|
| 660 |
+
|
| 661 |
+
@torch.no_grad()
|
| 662 |
+
def predict_proba(self, texts, batch_size=16):
|
| 663 |
+
"""Return a list of ``{domain_name: probability}`` dicts (softmax over
|
| 664 |
+
the classifier's logits), one per text in ``texts``.
|
| 665 |
+
|
| 666 |
+
Unlike :meth:`predict` (hard argmax), this keeps the full predicted
|
| 667 |
+
distribution over domains -- what the soft/mixture p-value
|
| 668 |
+
(domain="softest") needs: a text that is itself a blend of domains
|
| 669 |
+
gets calibrated against a weighted combination of null distributions
|
| 670 |
+
rather than forced into a single one.
|
| 671 |
+
"""
|
| 672 |
+
self.model.eval()
|
| 673 |
+
all_probs = []
|
| 674 |
+
for start in range(0, len(texts), batch_size):
|
| 675 |
+
batch_texts = texts[start:start + batch_size]
|
| 676 |
+
enc = self.tokenizer(
|
| 677 |
+
batch_texts, return_tensors="pt", padding=True, truncation=True,
|
| 678 |
+
max_length=self.max_length, return_token_type_ids=False,
|
| 679 |
+
).to(self.device)
|
| 680 |
+
logits = self.model(**enc).logits
|
| 681 |
+
probs = torch.softmax(logits.float(), dim=-1)
|
| 682 |
+
for row in probs.tolist():
|
| 683 |
+
all_probs.append({self.id2label[i]: p for i, p in enumerate(row)})
|
| 684 |
+
return all_probs
|
| 685 |
+
|
| 686 |
+
def save_pretrained(self, ckpt_dir):
|
| 687 |
+
"""Save the LoRA adapter and label map under ``ckpt_dir``.
|
| 688 |
+
|
| 689 |
+
The tokenizer is deliberately *not* saved here. It's byte-identical
|
| 690 |
+
to the one `ComputeStat` already loads for its scoring/reference
|
| 691 |
+
models (same base model), so writing another copy into
|
| 692 |
+
``domain_clf/`` would just duplicate ~40MB of vocab files per
|
| 693 |
+
checkpoint for no benefit -- `from_pretrained` below reloads it from
|
| 694 |
+
the shared ``cache_dir`` (or reuses a passed-in tokenizer) instead.
|
| 695 |
+
"""
|
| 696 |
+
save_dir = os.path.join(ckpt_dir, DOMAIN_CLF_SUBDIR)
|
| 697 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 698 |
+
self.model.save_pretrained(save_dir, safe_serialization=True)
|
| 699 |
+
with open(os.path.join(save_dir, "label_names.json"), "w") as f:
|
| 700 |
+
json.dump({"label_names": self.label_names, "base_model": self.model_name}, f)
|
| 701 |
+
print(f"✅ Domain classifier saved to {save_dir} (labels: {self.label_names})")
|
| 702 |
+
|
| 703 |
+
@classmethod
|
| 704 |
+
def from_pretrained(cls, ckpt_dir, base_model=None, cache_dir="./models", device="cuda", max_length=512, tokenizer=None):
|
| 705 |
+
"""Load a saved domain classifier from ``ckpt_dir``. ``base_model`` is
|
| 706 |
+
optional -- it's read from the checkpoint's ``label_names.json`` if
|
| 707 |
+
omitted.
|
| 708 |
+
|
| 709 |
+
Pass ``tokenizer=`` to reuse an already-loaded tokenizer (e.g.
|
| 710 |
+
``ComputeStat.scoring_tokenizer``) instead of loading a fresh copy --
|
| 711 |
+
see the note on `save_pretrained` for why no tokenizer is bundled
|
| 712 |
+
with this checkpoint in the first place.
|
| 713 |
+
"""
|
| 714 |
+
save_dir = os.path.join(ckpt_dir, DOMAIN_CLF_SUBDIR)
|
| 715 |
+
with open(os.path.join(save_dir, "label_names.json")) as f:
|
| 716 |
+
meta = json.load(f)
|
| 717 |
+
label_names = meta["label_names"]
|
| 718 |
+
base_model = base_model or meta.get("base_model", "gemma-1b")
|
| 719 |
+
|
| 720 |
+
obj = cls(
|
| 721 |
+
base_model, label_names, device=device, cache_dir=cache_dir,
|
| 722 |
+
max_length=max_length, tokenizer=tokenizer, _build_base=False,
|
| 723 |
+
)
|
| 724 |
+
if obj.tokenizer is None:
|
| 725 |
+
model_fullname = get_model_fullname(base_model)
|
| 726 |
+
tok_kwargs = {"padding_side": "right"}
|
| 727 |
+
obj.tokenizer = from_pretrained(AutoTokenizer, model_fullname, tok_kwargs, cache_dir=cache_dir)
|
| 728 |
+
if obj.tokenizer.pad_token_id is None:
|
| 729 |
+
obj.tokenizer.pad_token_id = obj.tokenizer.eos_token_id
|
| 730 |
+
|
| 731 |
+
# Match the training dtype (gemma-1b is trained/saved in bf16). Loaded
|
| 732 |
+
# without device_map="auto" (we go straight to `.to(device, dtype)`
|
| 733 |
+
# below) so there's no risk of the offload-split issue that motivates
|
| 734 |
+
# the device pinning elsewhere in this file.
|
| 735 |
+
dtype = torch.bfloat16 if "gemma-1b" in base_model else torch.float32
|
| 736 |
+
obj.model = AutoPeftModelForSequenceClassification.from_pretrained(
|
| 737 |
+
save_dir,
|
| 738 |
+
num_labels=len(label_names),
|
| 739 |
+
torch_dtype=dtype,
|
| 740 |
+
low_cpu_mem_usage=True,
|
| 741 |
+
cache_dir=cache_dir,
|
| 742 |
+
)
|
| 743 |
+
obj.model.to(device=device, dtype=dtype)
|
| 744 |
+
obj.model.config.pad_token_id = obj.tokenizer.pad_token_id
|
| 745 |
+
obj.model.eval()
|
| 746 |
+
print(f"✅ Domain classifier loaded from {save_dir} (labels: {label_names}, dtype: {dtype})")
|
| 747 |
+
return obj
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def train_domain_clf(
|
| 751 |
+
texts,
|
| 752 |
+
labels,
|
| 753 |
+
ckpt_dir,
|
| 754 |
+
base_model="gemma-1b",
|
| 755 |
+
cache_dir="./models",
|
| 756 |
+
device="cuda",
|
| 757 |
+
epochs=3,
|
| 758 |
+
lr=1e-4,
|
| 759 |
+
batch_size=8,
|
| 760 |
+
lora_r=8,
|
| 761 |
+
seed=42,
|
| 762 |
+
tokenizer=None,
|
| 763 |
+
):
|
| 764 |
+
"""Train a LoRA domain estimator on ``(text, domain-label)`` pairs and save
|
| 765 |
+
its checkpoint into ``ckpt_dir`` -- the *same* directory that holds the
|
| 766 |
+
AdaJASA null distributions.
|
| 767 |
+
|
| 768 |
+
Args:
|
| 769 |
+
texts: list[str] of input texts.
|
| 770 |
+
labels: list[str] of domain names, aligned with ``texts``.
|
| 771 |
+
ckpt_dir: AdaJASA checkpoint directory; the classifier is written to its
|
| 772 |
+
``domain_clf/`` sub-directory.
|
| 773 |
+
tokenizer: optional, already-loaded tokenizer to reuse (e.g. a
|
| 774 |
+
`ComputeStat` instance's `scoring_tokenizer`) instead of
|
| 775 |
+
loading a fresh copy of the same base-model tokenizer.
|
| 776 |
+
|
| 777 |
+
Returns:
|
| 778 |
+
The trained :class:`DomainClassifier`.
|
| 779 |
+
"""
|
| 780 |
+
label_names = sorted(set(labels))
|
| 781 |
+
clf = DomainClassifier(
|
| 782 |
+
base_model, label_names, device=device, cache_dir=cache_dir, lora_r=lora_r,
|
| 783 |
+
tokenizer=tokenizer,
|
| 784 |
+
)
|
| 785 |
+
clf.fit(texts, labels, epochs=epochs, lr=lr, batch_size=batch_size, seed=seed)
|
| 786 |
+
clf.save_pretrained(ckpt_dir)
|
| 787 |
+
return clf
|
src/app.py
CHANGED
|
@@ -29,7 +29,7 @@ if os.environ.get("SPACE_ID"):
|
|
| 29 |
|
| 30 |
import gradio as gr
|
| 31 |
|
| 32 |
-
from FineTune.model import ComputeStat
|
| 33 |
from feedback import FeedbackManager
|
| 34 |
from stats import StatsManager
|
| 35 |
|
|
@@ -79,6 +79,37 @@ DOMAINS = [
|
|
| 79 |
"UserReview",
|
| 80 |
]
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
FEEDBACK_DATASET_ID = os.environ.get("FEEDBACK_DATASET_ID", f"{ACCOUNT_NAME}/user-feedback")
|
| 83 |
|
| 84 |
|
|
@@ -105,6 +136,16 @@ except Exception as e: # noqa: BLE001 — surfaced in the UI below
|
|
| 105 |
model = None
|
| 106 |
model_load_error = str(e)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
feedback_manager = FeedbackManager(
|
| 109 |
dataset_repo_id=FEEDBACK_DATASET_ID,
|
| 110 |
hf_token=os.environ.get("HF_TOKEN"),
|
|
@@ -159,6 +200,7 @@ INTERPRETATION_TEXT = """
|
|
| 159 |
- **Suggestions for better detection**
|
| 160 |
- Provide longer text inputs for more reliable detection results.
|
| 161 |
- Select the domain that best matches the content of your text to improve detection accuracy.
|
|
|
|
| 162 |
"""
|
| 163 |
|
| 164 |
FOOTER_TEXT = (
|
|
@@ -187,19 +229,55 @@ REFERENCES_BIBTEX = """@article{zhou2026detecting,
|
|
| 187 |
# -----------------------------------------------------------------------
|
| 188 |
# Event handlers
|
| 189 |
# -----------------------------------------------------------------------
|
| 190 |
-
def
|
| 191 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
if not text or not text.strip():
|
| 193 |
raise gr.Error("⚠️ Please enter some text before detecting.")
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
start_time = time.time()
|
| 196 |
try:
|
|
|
|
|
|
|
| 197 |
crit, p_value = _run_inference(text, domain)
|
|
|
|
| 198 |
except gr.Error:
|
| 199 |
raise
|
| 200 |
except Exception as e: # noqa: BLE001 — surfaced to the user via gr.Error
|
| 201 |
raise gr.Error(f"Detection failed: {e}")
|
| 202 |
elapsed_time = time.time() - start_time
|
|
|
|
| 203 |
|
| 204 |
stats_manager.increment_detection()
|
| 205 |
|
|
@@ -216,7 +294,8 @@ def run_detection(text: str, domain: str, alpha: float):
|
|
| 216 |
gr.update(value=format_conclusion(p_value, alpha), visible=True),
|
| 217 |
gr.update(visible=True), # interpretation accordion
|
| 218 |
gr.update(value=f"⏱️ Processing time: {elapsed_time:.2f} seconds", visible=True),
|
| 219 |
-
gr.update(visible=True), # feedback
|
|
|
|
| 220 |
gr.update(visible=False), # feedback thanks message
|
| 221 |
detection_state,
|
| 222 |
)
|
|
@@ -250,8 +329,10 @@ def submit_feedback(feedback_type: str, detection_state: dict | None):
|
|
| 250 |
|
| 251 |
|
| 252 |
def on_load():
|
|
|
|
|
|
|
|
|
|
| 253 |
stats_manager.increment_visit()
|
| 254 |
-
return f"{stats_manager.visit_count:,} visits"
|
| 255 |
|
| 256 |
|
| 257 |
# -----------------------------------------------------------------------
|
|
@@ -300,15 +381,6 @@ CUSTOM_CSS = """
|
|
| 300 |
color: #475569;
|
| 301 |
font-size: 0.9rem;
|
| 302 |
}
|
| 303 |
-
#stats-chip {
|
| 304 |
-
position: fixed;
|
| 305 |
-
top: 3.6rem;
|
| 306 |
-
right: 1rem;
|
| 307 |
-
font-size: 0.78rem;
|
| 308 |
-
color: #9ca3af;
|
| 309 |
-
z-index: 999;
|
| 310 |
-
text-align: right;
|
| 311 |
-
}
|
| 312 |
#app-footer {
|
| 313 |
position: fixed;
|
| 314 |
left: 0;
|
|
@@ -360,14 +432,30 @@ def build_interface() -> gr.Blocks:
|
|
| 360 |
)
|
| 361 |
|
| 362 |
with gr.Row():
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
alpha_slider = gr.Slider(
|
| 369 |
minimum=0.01, maximum=0.2, value=0.05, step=0.005,
|
| 370 |
-
label="Significance level (α)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
)
|
| 372 |
|
| 373 |
conclusion_md = gr.Markdown(visible=False, latex_delimiters=LATEX_DELIMITERS)
|
|
@@ -377,27 +465,35 @@ def build_interface() -> gr.Blocks:
|
|
| 377 |
|
| 378 |
elapsed_caption = gr.Markdown(visible=False)
|
| 379 |
|
| 380 |
-
gr.
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
stats_chip = gr.Markdown(elem_id="stats-chip")
|
| 392 |
|
| 393 |
with gr.Accordion("📚 References", open=False, elem_id="references-box"):
|
| 394 |
gr.Markdown(REFERENCES_INTRO)
|
| 395 |
gr.Code(value=REFERENCES_BIBTEX, language=None, interactive=False, show_label=False)
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
detect_btn.click(
|
| 398 |
fn=run_detection,
|
| 399 |
-
inputs=[text_input,
|
| 400 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
| 401 |
)
|
| 402 |
|
| 403 |
expected_btn.click(
|
|
@@ -415,7 +511,9 @@ def build_interface() -> gr.Blocks:
|
|
| 415 |
|
| 416 |
# `demo.load` fires once per browser session (page load) — the closest
|
| 417 |
# Gradio equivalent to the Streamlit "count once per session" trick.
|
| 418 |
-
|
|
|
|
|
|
|
| 419 |
|
| 420 |
return demo
|
| 421 |
|
|
|
|
| 29 |
|
| 30 |
import gradio as gr
|
| 31 |
|
| 32 |
+
from FineTune.model import ComputeStat, ESTIMATE_DOMAIN, SOFTEST_DOMAIN
|
| 33 |
from feedback import FeedbackManager
|
| 34 |
from stats import StatsManager
|
| 35 |
|
|
|
|
| 79 |
"UserReview",
|
| 80 |
]
|
| 81 |
|
| 82 |
+
# "Domain of the text" is a two-level hierarchical selector built from two
|
| 83 |
+
# cascading gr.Dropdowns, grouped together in one row:
|
| 84 |
+
# - Level 1 (domain_mode_dropdown): Manual / Soft Estimate / Estimate.
|
| 85 |
+
# - Level 2 (manual_domain_dropdown): the 9 entries in DOMAINS -- only
|
| 86 |
+
# meaningful (and only shown) when level 1 is "Manual".
|
| 87 |
+
# "Soft Estimate" calibrates against a probability-weighted blend of every
|
| 88 |
+
# domain's null distribution (FineTune.model.SOFTEST_DOMAIN); "Estimate"
|
| 89 |
+
# calibrates against the single most likely domain (FineTune.model.
|
| 90 |
+
# ESTIMATE_DOMAIN). Both route through the pretrained transformer-based
|
| 91 |
+
# domain classifier bundled in the checkpoint (FineTune/model.py:
|
| 92 |
+
# DomainClassifier).
|
| 93 |
+
DOMAIN_MODES = ["Manual", "Soft Estimate", "Estimate"]
|
| 94 |
+
DEFAULT_DOMAIN_MODE = "Manual"
|
| 95 |
+
DEFAULT_MANUAL_DOMAIN = "General"
|
| 96 |
+
|
| 97 |
+
DOMAIN_MODE_INFO = {
|
| 98 |
+
"Manual": (
|
| 99 |
+
"Pick the domain that best matches your text."
|
| 100 |
+
),
|
| 101 |
+
"Soft Estimate": (
|
| 102 |
+
"🤖 The estimation is conducted by a pretrained transformer-based classifier, which "
|
| 103 |
+
"predicts a probability over all supported domains; detection is then calibrated "
|
| 104 |
+
"against a probability-weighted blend of their null distributions. Useful when the "
|
| 105 |
+
"text may span more than one domain."
|
| 106 |
+
),
|
| 107 |
+
"Estimate": (
|
| 108 |
+
"🤖 The estimation is conducted by a pretrained transformer-based classifier, which "
|
| 109 |
+
"predicts the single most likely domain for your text automatically."
|
| 110 |
+
),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
FEEDBACK_DATASET_ID = os.environ.get("FEEDBACK_DATASET_ID", f"{ACCOUNT_NAME}/user-feedback")
|
| 114 |
|
| 115 |
|
|
|
|
| 136 |
model = None
|
| 137 |
model_load_error = str(e)
|
| 138 |
|
| 139 |
+
# `ComputeStat.from_pretrained` degrades gracefully if the bundled domain
|
| 140 |
+
# classifier fails to load (e.g. an installed `transformers` version too old
|
| 141 |
+
# to map the base model to a SequenceClassification head) -- `domain_estimator`
|
| 142 |
+
# is simply left as None. Mirror that here: only offer "Soft Estimate" /
|
| 143 |
+
# "Estimate" in the UI when there's actually a classifier behind them.
|
| 144 |
+
DOMAIN_ESTIMATOR_AVAILABLE = bool(model is not None and model.domain_estimator is not None)
|
| 145 |
+
AVAILABLE_DOMAIN_MODES = (
|
| 146 |
+
DOMAIN_MODES if DOMAIN_ESTIMATOR_AVAILABLE else [m for m in DOMAIN_MODES if m == "Manual"]
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
feedback_manager = FeedbackManager(
|
| 150 |
dataset_repo_id=FEEDBACK_DATASET_ID,
|
| 151 |
hf_token=os.environ.get("HF_TOKEN"),
|
|
|
|
| 200 |
- **Suggestions for better detection**
|
| 201 |
- Provide longer text inputs for more reliable detection results.
|
| 202 |
- Select the domain that best matches the content of your text to improve detection accuracy.
|
| 203 |
+
- Not sure of the domain? Switch **Domain of the text** to *Soft Estimate* or *Estimate* — the estimation is conducted by a pretrained transformer-based classifier that infers it automatically.
|
| 204 |
"""
|
| 205 |
|
| 206 |
FOOTER_TEXT = (
|
|
|
|
| 229 |
# -----------------------------------------------------------------------
|
| 230 |
# Event handlers
|
| 231 |
# -----------------------------------------------------------------------
|
| 232 |
+
def resolve_domain_selection(domain_mode: str, manual_domain: str) -> str:
|
| 233 |
+
"""Translate the level-1 mode + (level-2, Manual-only) domain pick into
|
| 234 |
+
the concrete domain string `ComputeStat.compute_p_value` expects."""
|
| 235 |
+
if domain_mode == "Soft Estimate":
|
| 236 |
+
return SOFTEST_DOMAIN
|
| 237 |
+
if domain_mode == "Estimate":
|
| 238 |
+
return ESTIMATE_DOMAIN
|
| 239 |
+
return manual_domain
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def on_domain_mode_change(domain_mode: str):
|
| 243 |
+
"""Level-1 dropdown change handler: show the level-2 domain dropdown only
|
| 244 |
+
in Manual mode, and swap in the explanatory blurb for whichever mode is
|
| 245 |
+
now active."""
|
| 246 |
+
return (
|
| 247 |
+
gr.update(visible=(domain_mode == "Manual")),
|
| 248 |
+
DOMAIN_MODE_INFO.get(domain_mode, ""),
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def run_detection(text: str, domain_mode: str, manual_domain: str, alpha: float, progress=gr.Progress()):
|
| 253 |
+
"""Detect button handler: runs inference and refreshes all result widgets.
|
| 254 |
+
|
| 255 |
+
`progress` is a `gr.Progress` instance (see
|
| 256 |
+
https://www.gradio.app/guides/progress-bars) -- Gradio recognizes it by
|
| 257 |
+
its default value and doesn't treat it as a regular input, so it's left
|
| 258 |
+
out of `detect_btn.click(inputs=...)`. The actual scoring pass
|
| 259 |
+
(`_run_inference`) is one blocking call with no internal hooks to report
|
| 260 |
+
from, so the bar advances in stages around it rather than continuously.
|
| 261 |
+
"""
|
| 262 |
if not text or not text.strip():
|
| 263 |
raise gr.Error("⚠️ Please enter some text before detecting.")
|
| 264 |
|
| 265 |
+
progress(0, desc="Validating input…")
|
| 266 |
+
domain = resolve_domain_selection(domain_mode, manual_domain)
|
| 267 |
+
is_auto_domain = domain in (ESTIMATE_DOMAIN, SOFTEST_DOMAIN)
|
| 268 |
+
|
| 269 |
start_time = time.time()
|
| 270 |
try:
|
| 271 |
+
progress(0.2, desc="Estimating domain…" if is_auto_domain else "Tokenizing text…")
|
| 272 |
+
progress(0.4, desc="Scoring text against the reference model…")
|
| 273 |
crit, p_value = _run_inference(text, domain)
|
| 274 |
+
progress(0.9, desc="Computing p-value…")
|
| 275 |
except gr.Error:
|
| 276 |
raise
|
| 277 |
except Exception as e: # noqa: BLE001 — surfaced to the user via gr.Error
|
| 278 |
raise gr.Error(f"Detection failed: {e}")
|
| 279 |
elapsed_time = time.time() - start_time
|
| 280 |
+
progress(1.0, desc="Done")
|
| 281 |
|
| 282 |
stats_manager.increment_detection()
|
| 283 |
|
|
|
|
| 294 |
gr.update(value=format_conclusion(p_value, alpha), visible=True),
|
| 295 |
gr.update(visible=True), # interpretation accordion
|
| 296 |
gr.update(value=f"⏱️ Processing time: {elapsed_time:.2f} seconds", visible=True),
|
| 297 |
+
gr.update(visible=True), # feedback section (label + buttons) — first reveal happens here
|
| 298 |
+
gr.update(visible=True), # feedback buttons row (re-shown in case a prior run hid it)
|
| 299 |
gr.update(visible=False), # feedback thanks message
|
| 300 |
detection_state,
|
| 301 |
)
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
def on_load():
|
| 332 |
+
# Visit count is still tracked (StatsManager persists it alongside the
|
| 333 |
+
# detection count) but is no longer surfaced in the UI, so this handler
|
| 334 |
+
# has no outputs.
|
| 335 |
stats_manager.increment_visit()
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
# -----------------------------------------------------------------------
|
|
|
|
| 381 |
color: #475569;
|
| 382 |
font-size: 0.9rem;
|
| 383 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
#app-footer {
|
| 385 |
position: fixed;
|
| 386 |
left: 0;
|
|
|
|
| 432 |
)
|
| 433 |
|
| 434 |
with gr.Row():
|
| 435 |
+
with gr.Row(scale=2):
|
| 436 |
+
# Level 1 + level 2 of the domain hierarchy, grouped together
|
| 437 |
+
# in their own row so they read as one control cluster even
|
| 438 |
+
# though they're two separate gr.Dropdowns.
|
| 439 |
+
domain_mode_dropdown = gr.Dropdown(
|
| 440 |
+
choices=AVAILABLE_DOMAIN_MODES, value=DEFAULT_DOMAIN_MODE,
|
| 441 |
+
label="💡 Domain of the text",
|
| 442 |
+
)
|
| 443 |
+
manual_domain_dropdown = gr.Dropdown(
|
| 444 |
+
choices=DOMAINS, value=DEFAULT_MANUAL_DOMAIN,
|
| 445 |
+
label="Domain",
|
| 446 |
+
visible=(DEFAULT_DOMAIN_MODE == "Manual"),
|
| 447 |
+
)
|
| 448 |
+
detect_btn = gr.Button("🔍 Detect", variant="primary", elem_id="detect-btn", scale=1)
|
| 449 |
alpha_slider = gr.Slider(
|
| 450 |
minimum=0.01, maximum=0.2, value=0.05, step=0.005,
|
| 451 |
+
label="Significance level (α)", scale=1,
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
domain_mode_info = gr.Markdown(DOMAIN_MODE_INFO[DEFAULT_DOMAIN_MODE])
|
| 455 |
+
if not DOMAIN_ESTIMATOR_AVAILABLE:
|
| 456 |
+
gr.Markdown(
|
| 457 |
+
"_Automatic domain estimation is temporarily unavailable on this deployment "
|
| 458 |
+
"— please select a domain manually._"
|
| 459 |
)
|
| 460 |
|
| 461 |
conclusion_md = gr.Markdown(visible=False, latex_delimiters=LATEX_DELIMITERS)
|
|
|
|
| 465 |
|
| 466 |
elapsed_caption = gr.Markdown(visible=False)
|
| 467 |
|
| 468 |
+
with gr.Column(visible=False) as feedback_section:
|
| 469 |
+
gr.HTML(
|
| 470 |
+
'<div style="margin-top: 0.6rem;"><strong>📝 Result Feedback</strong>: '
|
| 471 |
+
'Does this detection result meet your expectations? '
|
| 472 |
+
'<span title="🔒 Your feedback is stored privately and will never be shared with '
|
| 473 |
+
'third parties. It is used solely to improve detection accuracy.">🔒</span></div>'
|
| 474 |
+
)
|
| 475 |
+
with gr.Row() as feedback_row:
|
| 476 |
+
expected_btn = gr.Button("✅ Expected")
|
| 477 |
+
unexpected_btn = gr.Button("❌ Unexpected")
|
| 478 |
+
feedback_thanks = gr.Markdown(visible=False)
|
|
|
|
| 479 |
|
| 480 |
with gr.Accordion("📚 References", open=False, elem_id="references-box"):
|
| 481 |
gr.Markdown(REFERENCES_INTRO)
|
| 482 |
gr.Code(value=REFERENCES_BIBTEX, language=None, interactive=False, show_label=False)
|
| 483 |
|
| 484 |
+
domain_mode_dropdown.change(
|
| 485 |
+
fn=on_domain_mode_change,
|
| 486 |
+
inputs=[domain_mode_dropdown],
|
| 487 |
+
outputs=[manual_domain_dropdown, domain_mode_info],
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
detect_btn.click(
|
| 491 |
fn=run_detection,
|
| 492 |
+
inputs=[text_input, domain_mode_dropdown, manual_domain_dropdown, alpha_slider],
|
| 493 |
+
outputs=[
|
| 494 |
+
conclusion_md, interpretation_box, elapsed_caption,
|
| 495 |
+
feedback_section, feedback_row, feedback_thanks, detection_state,
|
| 496 |
+
],
|
| 497 |
)
|
| 498 |
|
| 499 |
expected_btn.click(
|
|
|
|
| 511 |
|
| 512 |
# `demo.load` fires once per browser session (page load) — the closest
|
| 513 |
# Gradio equivalent to the Streamlit "count once per session" trick.
|
| 514 |
+
# No outputs: the visit count is tracked (see StatsManager) but no
|
| 515 |
+
# longer displayed in the UI.
|
| 516 |
+
demo.load(fn=on_load)
|
| 517 |
|
| 518 |
return demo
|
| 519 |
|