MuskAI commited on
Commit
821b69c
·
verified ·
1 Parent(s): c0b3703

Release original LTF v3.3 epoch 20 inference model

Browse files
Files changed (4) hide show
  1. demo/README.md +27 -0
  2. demo/app.py +96 -5
  3. demo/attention.py +226 -0
  4. demo/training_reference.json +21 -0
demo/README.md CHANGED
@@ -59,3 +59,30 @@ LTF_MODEL_DIR=../model_repo python app.py
59
  ```
60
 
61
  The web UI and its `predict` API both call the same original-file inference path.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  ```
60
 
61
  The web UI and its `predict` API both call the same original-file inference path.
62
+
63
+ ## Attention and tile inspection
64
+
65
+ The Detect button additionally requests the model's existing `return_viz`
66
+ outputs during the same forward pass. Temporary observation hooks collect the
67
+ eight L1 across-tile aggregation heads. No model parameters are changed.
68
+
69
+ - Exact, numbered local crop boxes, including edge-aligned overlap and small-image padding.
70
+ - L1 tile-attention overlay: mean or any of the eight aggregation heads.
71
+ - L2 local-view overlay with the global-view attention weight listed separately.
72
+ - Top four local tiles, original crops paired with their 16×16 MIL patch attention.
73
+ - A tile selector for inspecting any local tile without rerunning inference.
74
+ - Exact view-weight table, embedding fusion gate and read-only model/inference parameters.
75
+ - Historical training settings from the original saved configuration, explicitly labeled Training only.
76
+
77
+ Colors use a separate min-max scale for each map (blue→yellow is low→high).
78
+ Overlapping image pixels use the mean of covering tile weights. A patch map is
79
+ normalized within its own tile. Raw normalized view weights are available in
80
+ the table and prediction JSON; colors cannot be compared across images or tiles.
81
+ These are model attention values, not pixel-level forgery probabilities or
82
+ causal explanations. The eight selectable heads belong to the L1 across-tile
83
+ aggregator; L1 patch pooling and L2 view pooling use gated MIL attention.
84
+
85
+ The original five-output `predict` API is preserved. The new `explain` API
86
+ returns prediction results plus visualization images, tables and attention metadata.
87
+ Changing a head or inspected tile uses per-session cached data, retained for up
88
+ to an hour. The unchanged checkpoint is used for both endpoints.
demo/app.py CHANGED
@@ -22,6 +22,10 @@ from huggingface_hub import snapshot_download
22
  from PIL import Image, UnidentifiedImageError
23
  import torch
24
 
 
 
 
 
25
  LOG = logging.getLogger(__name__)
26
  MAX_PIXELS = int(os.environ.get('LTF_MAX_PIXELS', '16000000'))
27
  MAX_VIEWS = int(os.environ.get('LTF_MAX_VIEWS', '400'))
@@ -108,7 +112,7 @@ def get_detector():
108
  return _detector
109
 
110
 
111
- def predict(image_path, branch='fusion', threshold=0.5):
112
  source, width, height, views = validate_upload(image_path)
113
  if branch not in ('fusion', 'level1', 'level2'):
114
  raise gr.Error('Unknown model branch.')
@@ -118,7 +122,14 @@ def predict(image_path, branch='fusion', threshold=0.5):
118
  try:
119
  with MODEL_LOCK:
120
  detector = get_detector()
121
- result = detector.predict(source, branch, float(threshold))
 
 
 
 
 
 
 
122
  except torch.cuda.OutOfMemoryError:
123
  torch.cuda.empty_cache()
124
  raise gr.Error('GPU memory is insufficient. Please try a smaller image.') from None
@@ -144,7 +155,42 @@ def predict(image_path, branch='fusion', threshold=0.5):
144
  rows = [[name, result['probabilities'][name]['real'],
145
  result['probabilities'][name]['fake']]
146
  for name in ('fusion', 'level1', 'level2')]
147
- return preview, summary, {'Real': scores['real'], 'AI-generated': scores['fake']}, rows, result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  def build_demo():
@@ -170,8 +216,53 @@ def build_demo():
170
  table = gr.Dataframe(headers=['Branch', 'Real score', 'AI-generated score'],
171
  datatype=['str', 'number', 'number'], interactive=False)
172
  details = gr.JSON(label='Prediction')
173
- button.click(predict, [upload, branch, threshold], [preview, summary, scores, table, details],
174
- api_name='predict', concurrency_limit=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  gr.Markdown('分数是模型输出,不能单独作为图片真伪的证明。'
176
  ' / Model scores are not calibrated probabilities or proof of authenticity.')
177
  return demo.queue(max_size=16, default_concurrency_limit=1)
 
22
  from PIL import Image, UnidentifiedImageError
23
  import torch
24
 
25
+ sys.path.insert(0, str(Path(__file__).absolute().parent))
26
+ from attention import (capture_prediction, draw_grid, head_view, parameter_rows,
27
+ patch_view, public_attention, tile_boxes, tile_overlay, top_tiles)
28
+
29
  LOG = logging.getLogger(__name__)
30
  MAX_PIXELS = int(os.environ.get('LTF_MAX_PIXELS', '16000000'))
31
  MAX_VIEWS = int(os.environ.get('LTF_MAX_VIEWS', '400'))
 
112
  return _detector
113
 
114
 
115
+ def _predict_request(image_path, branch='fusion', threshold=0.5, with_attention=False):
116
  source, width, height, views = validate_upload(image_path)
117
  if branch not in ('fusion', 'level1', 'level2'):
118
  raise gr.Error('Unknown model branch.')
 
122
  try:
123
  with MODEL_LOCK:
124
  detector = get_detector()
125
+ if with_attention:
126
+ result, attention = capture_prediction(detector, source, branch, float(threshold))
127
+ state = dict(path=str(source), attention=attention,
128
+ boxes=tile_boxes(width, height, int(detector.model.input_resolution)),
129
+ parameters=parameter_rows(detector, branch, float(threshold), views))
130
+ else:
131
+ result = detector.predict(source, branch, float(threshold))
132
+ state = None
133
  except torch.cuda.OutOfMemoryError:
134
  torch.cuda.empty_cache()
135
  raise gr.Error('GPU memory is insufficient. Please try a smaller image.') from None
 
155
  rows = [[name, result['probabilities'][name]['real'],
156
  result['probabilities'][name]['fake']]
157
  for name in ('fusion', 'level1', 'level2')]
158
+ if state:
159
+ result['attention'] = public_attention(state)
160
+ return (preview, summary, {'Real': scores['real'], 'AI-generated': scores['fake']}, rows, result), state
161
+
162
+
163
+ def predict(image_path, branch='fusion', threshold=0.5):
164
+ """Keep the original five-output public prediction API compatible."""
165
+ return _predict_request(image_path, branch, threshold)[0]
166
+
167
+
168
+ def explain(image_path, branch='fusion', threshold=0.5):
169
+ base, state = _predict_request(image_path, branch, threshold, with_attention=True)
170
+ attention = state['attention']
171
+ count = len(state['boxes'])
172
+ if len(attention['l1']) != count or len(attention['l2']) != count + 1:
173
+ raise gr.Error('Attention and original image tile counts disagree.')
174
+ grid = draw_grid(state['path'], state['boxes'])
175
+ l1_image, l1_description = head_view(state, 'mean')
176
+ l2_image = tile_overlay(state['path'], state['boxes'], attention['l2'][1:])
177
+ description = (f'**L2 global-view weight: {attention["l2"][0]:.6f}** · '
178
+ f'local-view weights sum: {float(attention["l2"][1:].sum()):.6f}.\n\n'
179
+ 'L2 图仅着色局部分块;全局权重单列,不铺满整张图。'
180
+ ' / The L2 overlay shows local views only; the global weight is listed separately.\n\n'
181
+ f'Fusion embedding gate: L1 × **{attention["gate"]:.4f}**, '
182
+ f'L2 × **{1 - attention["gate"]:.4f}**. '
183
+ '这是特征缩放系数,不是分支概率 / These scale embeddings, not branch probabilities.')
184
+ weight_rows = [['Global', 'whole image', None, float(attention['l2'][0])]]
185
+ weight_rows.extend([f'T{i + 1}', str(list(box)), float(attention['l1'][i]), float(attention['l2'][i + 1])]
186
+ for i, box in enumerate(state['boxes']))
187
+ selected = int(attention['l1'].argmax()) + 1
188
+ tile, patch, patch_description = patch_view(state, str(selected))
189
+ return (*base, grid, l1_image, l1_description, l2_image, description, top_tiles(state),
190
+ state['parameters'], weight_rows, state,
191
+ gr.Dropdown(choices=[str(i + 1) for i in range(count)], value=str(selected)),
192
+ tile, patch, patch_description,
193
+ gr.Dropdown(choices=['mean'] + [f'head_{i + 1}' for i in range(attention['l1_heads'].shape[0])], value='mean'))
194
 
195
 
196
  def build_demo():
 
216
  table = gr.Dataframe(headers=['Branch', 'Real score', 'AI-generated score'],
217
  datatype=['str', 'number', 'number'], interactive=False)
218
  details = gr.JSON(label='Prediction')
219
+ state = gr.State(value=None, time_to_live=3600)
220
+ with gr.Accordion('分块与 MIL Attention / Tiles and model attention', open=True):
221
+ gr.Markdown('以下权重直接来自本次模型前向计算。Attention 是相对关注程度,'
222
+ '不能直接作为伪造区域或因果解释。'
223
+ ' / These are actual forward-pass weights, not forgery probabilities or causal explanations.')
224
+ with gr.Tabs():
225
+ with gr.Tab('原图分块 / Tile grid'):
226
+ grid = gr.Image(label='224×224 local tiles · T1…Tn · edge-aligned', interactive=False)
227
+ gr.Markdown('分块编号按行排列,边缘对齐时允许重叠。全局视图独立于局部分块。'
228
+ ' / Row-major IDs; edge-aligned tiles may overlap. The global view is separate.')
229
+ with gr.Tab('L1 分块注意力'):
230
+ head = gr.Dropdown(['mean'], value='mean', label='Across-tile aggregation head')
231
+ l1_image = gr.Image(label='L1 tile attention', interactive=False)
232
+ l1_description = gr.Markdown()
233
+ with gr.Tab('L2 视图注意力'):
234
+ l2_image = gr.Image(label='L2 local-view attention', interactive=False)
235
+ attention_description = gr.Markdown()
236
+ weight_table = gr.Dataframe(headers=['View', 'Original crop box', 'L1 weight', 'L2 weight'],
237
+ datatype=['str', 'str', 'number', 'number'], interactive=False)
238
+ with gr.Tab('Top-K 与块内 Patch Attention'):
239
+ gallery = gr.Gallery(label='Top 4 L1 tiles: original + within-tile patch attention', columns=4,
240
+ object_fit='contain', height='auto')
241
+ tile_number = gr.Dropdown(choices=[], label='Inspect local tile / 分块编号', allow_custom_value=False)
242
+ with gr.Row():
243
+ selected_tile = gr.Image(label='Original 224×224 crop', interactive=False)
244
+ selected_patch = gr.Image(label='MIL patch attention (16×16)', interactive=False)
245
+ patch_description = gr.Markdown()
246
+ gr.Markdown('热图分别归一化:蓝色低、黄色高,不能跨图片按颜色比较。'
247
+ '重叠像素显示覆盖分块的平均权重,精确权重见表格。'
248
+ ' / Heatmaps use separate min-max scales; overlap uses mean weights.')
249
+ with gr.Accordion('模型与超参数 / Model and hyperparameters (read only)', open=False):
250
+ parameters = gr.Dataframe(headers=['Scope', 'Parameter', 'Value'],
251
+ datatype=['str', 'str', 'str'], interactive=False)
252
+ gr.Markdown('Training only 是历史训练配置;Cross-shuffle、随机增强和 dropout '
253
+ '在本次推理中不启用。Head 数量与网络结构随权重固定。')
254
+ button.click(explain, [upload, branch, threshold],
255
+ [preview, summary, scores, table, details, grid, l1_image, l1_description,
256
+ l2_image, attention_description, gallery, parameters, weight_table, state,
257
+ tile_number, selected_tile, selected_patch, patch_description, head],
258
+ api_name='explain', concurrency_limit=1, concurrency_id='ltf-inference')
259
+ head.change(head_view, [state, head], [l1_image, l1_description], api_name=False)
260
+ tile_number.change(patch_view, [state, tile_number],
261
+ [selected_tile, selected_patch, patch_description], api_name=False)
262
+ # Preserve the original prediction endpoint for existing API clients.
263
+ api_button = gr.Button(visible=False)
264
+ api_button.click(predict, [upload, branch, threshold], [preview, summary, scores, table, details],
265
+ api_name='predict', concurrency_limit=1, concurrency_id='ltf-inference')
266
  gr.Markdown('分数是模型输出,不能单独作为图片真伪的证明。'
267
  ' / Model scores are not calibrated probabilities or proof of authenticity.')
268
  return demo.queue(max_size=16, default_concurrency_limit=1)
demo/attention.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read actual LTF MIL attention and render its original-image coordinates."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from PIL import Image, ImageDraw, ImageFont
10
+
11
+
12
+ def capture_prediction(detector, path, branch, threshold):
13
+ """Observe one ordinary prediction; temporary hooks are always removed.
14
+
15
+ Call under the app's model lock. return_viz only appends existing model
16
+ tensors to the output; it does not change logits or rerun the backbone.
17
+ """
18
+ captured = {}
19
+
20
+ def enable_viz(module, args, kwargs):
21
+ return args, dict(kwargs, return_viz=True)
22
+
23
+ def collect(module, args, output):
24
+ viz = output['viz']
25
+ for key in ('tile_attn', 'level1_patch_attn', 'level2_attn', 'gate'):
26
+ captured[key] = viz[key].detach().float().cpu().numpy()
27
+
28
+ def collect_heads(module, args, output):
29
+ captured['head_attn'] = output[1][0, :, 0, :].detach().float().cpu().numpy()
30
+
31
+ handles = []
32
+ try:
33
+ handles.append(detector.model.register_forward_pre_hook(enable_viz, with_kwargs=True))
34
+ handles.append(detector.model.register_forward_hook(collect))
35
+ handles.append(detector.model.core.level1_aggregate_attn.register_forward_hook(collect_heads))
36
+ result = detector.predict(path, branch, threshold)
37
+ finally:
38
+ for handle in handles:
39
+ handle.remove()
40
+ nt = result['views'] - 1
41
+ patches = captured['level1_patch_attn'].reshape(nt, -1)
42
+ side = math.isqrt(patches.shape[1])
43
+ if side * side != patches.shape[1]:
44
+ raise ValueError('Patch attention does not form a square grid.')
45
+ attention = dict(
46
+ l1=captured['tile_attn'].reshape(nt),
47
+ l1_heads=captured['head_attn'],
48
+ l2=captured['level2_attn'].reshape(nt + 1),
49
+ patch=patches.reshape(nt, side, side),
50
+ gate=float(captured['gate'].reshape(-1)[0]),
51
+ )
52
+ for key in ('l1', 'l2', 'patch', 'l1_heads'):
53
+ weights = attention[key]
54
+ if not np.isfinite(weights).all() or (weights < 0).any():
55
+ raise ValueError('Attention contains invalid values.')
56
+ return result, attention
57
+
58
+
59
+ def tile_boxes(width, height, tile_size=224):
60
+ # Use the exact same edge-aligned coordinates and row-major order as inference.
61
+ from utils.tile_dataset_srm import get_start_positions
62
+ return [(x, y, x + tile_size, y + tile_size)
63
+ for y in get_start_positions(height, tile_size)
64
+ for x in get_start_positions(width, tile_size)]
65
+
66
+
67
+ def relative_scale(values):
68
+ values = np.asarray(values, dtype=np.float32)
69
+ low, high = float(values.min()), float(values.max())
70
+ if high - low < 1e-12:
71
+ return np.full_like(values, 0.5)
72
+ return (values - low) / (high - low)
73
+
74
+
75
+ def colors(values):
76
+ """Continuous dark-blue → cyan → yellow scale; no probability semantics."""
77
+ x = np.clip(np.asarray(values), 0, 1)
78
+ stops = np.array([[35, 47, 110], [31, 164, 172], [253, 225, 77]], dtype=np.float32)
79
+ first = np.minimum((x * 2).astype(int), 1)
80
+ fraction = (x * 2 - first)[..., None]
81
+ return (stops[first] * (1 - fraction) + stops[first + 1] * fraction).astype(np.uint8)
82
+
83
+
84
+ def preview_image(path):
85
+ with Image.open(path) as source:
86
+ image = source.convert('RGB')
87
+ image.thumbnail((1200, 1200))
88
+ return image
89
+
90
+
91
+ def draw_grid(path, boxes):
92
+ image = preview_image(path)
93
+ with Image.open(path) as original:
94
+ sx, sy = image.width / original.width, image.height / original.height
95
+ draw = ImageDraw.Draw(image)
96
+ font = ImageFont.load_default(size=14)
97
+ labels = []
98
+ for index, (x0, y0, x1, y1) in enumerate(boxes, 1):
99
+ rect = (int(x0 * sx), int(y0 * sy), min(image.width - 1, int(x1 * sx) - 1),
100
+ min(image.height - 1, int(y1 * sy) - 1))
101
+ draw.rectangle(rect, outline=(255, 212, 74), width=2)
102
+ label = f'T{index}'
103
+ left, top = rect[0] + 3, rect[1] + 3
104
+ # Edge alignment can place two rows just one pixel apart. Keep their
105
+ # exact crop borders while moving text labels to avoid hiding earlier IDs.
106
+ for candidate_top in range(top, max(top + 1, rect[3] - 15), 18):
107
+ extent = draw.textbbox((left, candidate_top), label, font=font)
108
+ if not any(left < old[2] + 3 and extent[2] + 3 > old[0]
109
+ and candidate_top < old[3] + 3 and extent[3] + 3 > old[1]
110
+ for old in labels):
111
+ top = candidate_top
112
+ break
113
+ extent = draw.textbbox((left, top), label, font=font)
114
+ labels.append((left, top, extent[2], extent[3]))
115
+ draw.rectangle((left - 1, top - 1, extent[2] + 2, extent[3] + 2), fill=(18, 28, 45))
116
+ draw.text((left, top), label, fill='white', font=font)
117
+ return image
118
+
119
+
120
+ def tile_overlay(path, boxes, weights):
121
+ image = preview_image(path)
122
+ with Image.open(path) as original:
123
+ sx, sy = image.width / original.width, image.height / original.height
124
+ summed = np.zeros((image.height, image.width), dtype=np.float32)
125
+ coverage = np.zeros_like(summed)
126
+ for box, weight in zip(boxes, weights, strict=True):
127
+ x0, y0, x1, y1 = box
128
+ x0, y0 = int(x0 * sx), int(y0 * sy)
129
+ x1, y1 = min(image.width, math.ceil(x1 * sx)), min(image.height, math.ceil(y1 * sy))
130
+ summed[y0:y1, x0:x1] += float(weight)
131
+ coverage[y0:y1, x0:x1] += 1
132
+ averaged = summed / np.maximum(coverage, 1)
133
+ heat = Image.fromarray(colors(relative_scale(averaged)))
134
+ return Image.blend(image, heat, 0.48)
135
+
136
+
137
+ def patch_view(state, tile_number):
138
+ if not state:
139
+ return None, None, '请先检测图片 / Run detection first.'
140
+ index = int(tile_number) - 1
141
+ if index < 0 or index >= len(state['boxes']):
142
+ raise ValueError('Tile number is out of range.')
143
+ box = state['boxes'][index]
144
+ with Image.open(state['path']) as source:
145
+ tile = source.convert('RGB').crop(box)
146
+ weights = state['attention']['patch'][index]
147
+ heat = Image.fromarray(colors(relative_scale(weights))).resize(tile.size, Image.Resampling.NEAREST)
148
+ overlay = Image.blend(tile, heat, 0.48)
149
+ description = (f'T{index + 1} · original box {list(box)} · patch grid '
150
+ f'{weights.shape[0]} × {weights.shape[1]} · '
151
+ f'patch weights sum = {float(weights.sum()):.6f}. '
152
+ '颜色在当前块内归一化;不能按颜色跨块比较。'
153
+ ' / Colors are scaled within this tile only. '
154
+ 'Small images retain the model’s original black padding.')
155
+ return tile, overlay, description
156
+
157
+
158
+ def top_tiles(state, k=4):
159
+ order = np.argsort(-state['attention']['l1'], kind='stable')[:k]
160
+ gallery = []
161
+ for index in order:
162
+ tile, overlay, _ = patch_view(state, int(index) + 1)
163
+ weight = float(state['attention']['l1'][index])
164
+ gallery.extend([(tile, f'T{index + 1} · L1 weight {weight:.6f}'),
165
+ (overlay, f'T{index + 1} · within-tile patch attention')])
166
+ return gallery
167
+
168
+
169
+ def head_view(state, selection):
170
+ if not state:
171
+ return None, '请先检测图片 / Run detection first.'
172
+ if selection == 'mean':
173
+ weights = state['attention']['l1']
174
+ label = 'Mean across L1 aggregation heads'
175
+ else:
176
+ index = int(selection.split('_')[-1]) - 1
177
+ weights = state['attention']['l1_heads'][index]
178
+ label = f'L1 aggregation head {index + 1}'
179
+ return tile_overlay(state['path'], state['boxes'], weights), (
180
+ f'{label} · sum = {float(weights.sum()):.6f}. '
181
+ '蓝→黄表示本图内相对权重低→高;重叠区域取覆盖分块权重的平均值。'
182
+ ' / Blue→yellow: lower→higher within this image; overlap uses mean weight. '
183
+ '精确权重见表格 / Exact weights are in the table.')
184
+
185
+
186
+ def parameter_rows(detector, branch, threshold, views):
187
+ core, visual = detector.model.core, detector.model.visual
188
+ mil1, mil2 = core.mil_level1.head.head, core.mil_level2.head.head
189
+ rows = [
190
+ ['Inference', 'Branch / threshold', f'{branch} / {threshold:.2f}'],
191
+ ['Inference', 'Precision / device', f'{visual.conv1.weight.dtype} / {detector.device}'],
192
+ ['Inference', 'CLIP chunk size', str(detector.model.eval_chunk_size)],
193
+ ['Inference', 'Views: global + local', f'1 + {views - 1} = {views}'],
194
+ ['Architecture', 'Backbone', 'Frozen CLIP ViT-L/14'],
195
+ ['Architecture', 'Local tile size', f'{int(detector.model.input_resolution)} × {int(detector.model.input_resolution)}'],
196
+ ['Architecture', 'CLIP patch size', str(visual.conv1.kernel_size[0])],
197
+ ['Architecture', 'Feature dimension', str(detector.model.embed_dim)],
198
+ ['Architecture', 'L1 across-tile aggregation heads', str(core.level1_aggregate_attn.num_heads)],
199
+ ['Architecture', 'L1 MIL pooling / hidden', f'{mil1.pooling} / {mil1.attn_V.out_features} ({mil1.attn_bag_mode})'],
200
+ ['Architecture', 'L2 MIL pooling / hidden', f'{mil2.pooling} / {mil2.attn_V.out_features} ({mil2.attn_bag_mode})'],
201
+ ['Architecture', 'Fusion / gate hidden', f'pool-logit gated / {core.pool_gate[0].out_features}'],
202
+ ['Architecture', 'Fusion MLP hidden', str(core.fusion_head[0].out_features)],
203
+ ['Inference', 'Cross-shuffle / dropout', 'Disabled in evaluation mode'],
204
+ ]
205
+ reference = Path(__file__).with_name('training_reference.json')
206
+ if reference.is_file():
207
+ training = json.loads(reference.read_text())
208
+ rows.extend(['Training only', key, json.dumps(value, ensure_ascii=False)]
209
+ for key, value in training.items())
210
+ return rows
211
+
212
+
213
+ def public_attention(state):
214
+ weights = state['attention']
215
+ return dict(
216
+ meaning='Relative model attention, not a forgery probability or causal explanation.',
217
+ display='Each heatmap is min-max scaled independently; overlap uses mean tile weight.',
218
+ local_tile_boxes=[list(box) for box in state['boxes']],
219
+ l1_tile_weights=weights['l1'].tolist(),
220
+ l1_aggregation_head_weights=weights['l1_heads'].tolist(),
221
+ l2_view_weights=weights['l2'].tolist(),
222
+ l2_view_order=['global'] + [f'T{i + 1}' for i in range(len(state['boxes']))],
223
+ patches_per_tile=list(weights['patch'].shape[1:]),
224
+ fusion_embedding_gate_l1=weights['gate'],
225
+ fusion_embedding_gate_l2=1 - weights['gate'],
226
+ )
demo/training_reference.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "seed": 16,
3
+ "epochs": 20,
4
+ "batch_size": 64,
5
+ "learning_rate": 0.0004,
6
+ "weight_decay": 0.05,
7
+ "dropout": 0.1,
8
+ "default_train_tiles": 16,
9
+ "train_set_size": 50000,
10
+ "v2_l1_cross_shuffle_p": 0.9,
11
+ "v2_l1_replace_ratio_range": [
12
+ 0.5,
13
+ 0.9
14
+ ],
15
+ "v2_l2_cross_shuffle_p": 0.9,
16
+ "v2_l2_replace_ratio_range": [
17
+ 0.1,
18
+ 0.8
19
+ ],
20
+ "v2_bag_token_shuffle_p": 0.5
21
+ }