Jin Zhu commited on
Commit
2a268ef
·
1 Parent(s): 6beed25

update code

Browse files
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.55.2
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 get_peft_model, LoraConfig, TaskType, AutoPeftModelForCausalLM
4
- from transformers import AutoModelForCausalLM, AutoTokenizer
 
 
 
 
 
 
 
 
 
 
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
- model_fullnames = {
 
 
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
- scoring_model = load_model(model_name, device, cache_dir)
 
99
  if model_name in ['gemma-1b']:
100
- self.peft_config = LoraConfig(
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
- self.peft_config = LoraConfig(
110
- task_type=TaskType.CAUSAL_LM,
111
- inference_mode=False,
112
- r=8,
113
- lora_alpha=32,
114
- lora_dropout=0.1,
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. 保存 scoring_model (LoRA adapter + 基础模型)
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. 保存所有 null_distr_* buffers
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. 保存配置信息(包括domain列表)
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, and all null_distr buffers.
 
187
  """
188
- # 1. 初始化类
189
  model = cls(*args, **kwargs)
190
 
191
- # 2. 加载 scoring_model
 
 
 
 
 
 
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. 加载所有 null_distr
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
- # 直接覆盖 buffer,避免 delattr 带来的问题
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 compute_p_value(self, text, domain: str):
270
- """
271
- Compute p-value for given text using the null distribution of specified domain.
272
-
273
- Args:
274
- text: Input text to compute score for
275
- domain: Domain name to use for null distribution
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 获取对应domain的null distribution
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- for buffer_name in self._buffers.keys():
315
- if buffer_name.startswith("null_distr_"):
316
- domain = buffer_name.replace("null_distr_", "")
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 run_detection(text: str, domain: str, alpha: float):
191
- """Detect button handler: runs inference and refreshes all result widgets."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 row
 
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
- domain_dropdown = gr.Dropdown(
364
- choices=DOMAINS, value="General",
365
- label="💡 Domain that matches your text",
366
- )
367
- detect_btn = gr.Button("🔍 Detect", variant="primary", elem_id="detect-btn")
 
 
 
 
 
 
 
 
 
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.HTML(
381
- '<div style="margin-top: 0.6rem;"><strong>📝 Result Feedback</strong>: '
382
- 'Does this detection result meet your expectations? '
383
- '<span title="🔒 Your feedback is stored privately and will never be shared with '
384
- 'third parties. It is used solely to improve detection accuracy.">🔒</span></div>'
385
- )
386
- with gr.Row(visible=False) as feedback_row:
387
- expected_btn = gr.Button("✅ Expected")
388
- unexpected_btn = gr.Button(" Unexpected")
389
- feedback_thanks = gr.Markdown(visible=False)
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, domain_dropdown, alpha_slider],
400
- outputs=[conclusion_md, interpretation_box, elapsed_caption, feedback_row, feedback_thanks, detection_state],
 
 
 
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
- demo.load(fn=on_load, outputs=[stats_chip])
 
 
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