Angshul commited on
Commit
7ee20db
·
verified ·
1 Parent(s): f8cf4de

Upload 6 files

Browse files
examples/deepseek_7b.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datasets import load_dataset
2
+ from layer_interactions import Order2Pruner
3
+
4
+ MODEL = "deepseek-ai/deepseek-llm-7b-base"
5
+
6
+ texts = [
7
+ row["text"]
8
+ for row in load_dataset(
9
+ "Salesforce/wikitext", "wikitext-2-raw-v1", split="train"
10
+ )
11
+ if row["text"].strip()
12
+ ]
13
+
14
+ with Order2Pruner(MODEL, dtype="bfloat16") as pruner:
15
+ result = pruner.fit(
16
+ texts=texts,
17
+ n_sequences=32,
18
+ sequence_length=128,
19
+ seed=42,
20
+ checkpoint_path="deepseek_order2.json",
21
+ max_delete=23, # enough for 30 -> 7
22
+ )
23
+
24
+ print("30 -> 15:", result.select(15))
25
+ print("30 -> 7:", result.select(7))
examples/olmoe_7b.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datasets import load_dataset
2
+ from layer_interactions import Order2Pruner
3
+
4
+ MODEL = "allenai/OLMoE-1B-7B-0924"
5
+
6
+ texts = [
7
+ row["text"]
8
+ for row in load_dataset(
9
+ "Salesforce/wikitext", "wikitext-2-raw-v1", split="train"
10
+ )
11
+ if row["text"].strip()
12
+ ]
13
+
14
+ with Order2Pruner(MODEL, dtype="bfloat16") as pruner:
15
+ result = pruner.fit(
16
+ texts=texts,
17
+ n_sequences=32,
18
+ sequence_length=128,
19
+ seed=42,
20
+ checkpoint_path="olmoe_order2.json",
21
+ max_delete=12, # enough for 16 -> 4
22
+ )
23
+
24
+ print("16 -> 8:", result.select(8))
25
+ print("16 -> 4:", result.select(4))
26
+
27
+ # Optional: materialize and save one compressed checkpoint.
28
+ # pruner.save_pruned("olmoe_order2_8", 8, result=result)
layer_interactions/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .pruner import Order2Pruner
2
+ from .result import Order2Result
3
+
4
+ __all__ = ["Order2Pruner", "Order2Result"]
5
+ __version__ = "0.1.0"
layer_interactions/pruner.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gc
4
+ import itertools
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import random
9
+ import shutil
10
+ import tempfile
11
+ from typing import Iterable, Sequence
12
+
13
+ import psutil
14
+ import torch
15
+ import torch.nn.functional as F
16
+ from transformers import AutoModelForCausalLM, AutoTokenizer, PreTrainedModel, PreTrainedTokenizerBase
17
+
18
+ from .result import Order2Result
19
+
20
+
21
+ _LAYER_PATHS = (
22
+ "model.layers", # Llama/Mistral/DeepSeek/OLMoE and compatible models
23
+ "transformer.h", # GPT-2 style
24
+ "gpt_neox.layers", # GPT-NeoX style
25
+ )
26
+
27
+
28
+ def _resolve_dtype(dtype: str | torch.dtype) -> torch.dtype:
29
+ if isinstance(dtype, torch.dtype):
30
+ return dtype
31
+ table = {
32
+ "bfloat16": torch.bfloat16,
33
+ "bf16": torch.bfloat16,
34
+ "float16": torch.float16,
35
+ "fp16": torch.float16,
36
+ "float32": torch.float32,
37
+ "fp32": torch.float32,
38
+ }
39
+ try:
40
+ return table[dtype.lower()]
41
+ except KeyError as exc:
42
+ raise ValueError(f"Unsupported dtype: {dtype}") from exc
43
+
44
+
45
+ def _get_attr_path(obj, path: str):
46
+ for part in path.split("."):
47
+ obj = getattr(obj, part)
48
+ return obj
49
+
50
+
51
+ def _set_attr_path(obj, path: str, value) -> None:
52
+ parts = path.split(".")
53
+ parent = obj
54
+ for part in parts[:-1]:
55
+ parent = getattr(parent, part)
56
+ setattr(parent, parts[-1], value)
57
+
58
+
59
+ class Order2Pruner:
60
+ """Second-order interaction layer pruning for Hugging Face causal LMs.
61
+
62
+ The method measures the NLL after deleting each layer and each pair of
63
+ layers, forms a quadratic deletion-loss surrogate, then greedily deletes
64
+ the layer with minimum current marginal predicted NLL increase.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ model: str | Path | PreTrainedModel,
70
+ tokenizer: str | Path | PreTrainedTokenizerBase | None = None,
71
+ *,
72
+ dtype: str | torch.dtype = "bfloat16",
73
+ layer_path: str | None = None,
74
+ device_map: str | dict | None = "auto",
75
+ gpu_memory_gib: int | None = None,
76
+ cpu_memory_gib: int | None = None,
77
+ activation_headroom_gib: float = 4.0,
78
+ offload_dir: str | Path | None = None,
79
+ local_files_only: bool = False,
80
+ trust_remote_code: bool = False,
81
+ ) -> None:
82
+ self.model_source = model
83
+ self.tokenizer_source = tokenizer
84
+ self.dtype = _resolve_dtype(dtype)
85
+ self.layer_path = layer_path
86
+ self.device_map = device_map
87
+ self.gpu_memory_gib = gpu_memory_gib
88
+ self.cpu_memory_gib = cpu_memory_gib
89
+ self.activation_headroom_gib = float(activation_headroom_gib)
90
+ self.local_files_only = local_files_only
91
+ self.trust_remote_code = trust_remote_code
92
+ self.model: PreTrainedModel | None = model if isinstance(model, PreTrainedModel) else None
93
+ self.tokenizer: PreTrainedTokenizerBase | None = (
94
+ tokenizer if isinstance(tokenizer, PreTrainedTokenizerBase) else None
95
+ )
96
+ self._owns_offload_dir = offload_dir is None
97
+ self.offload_dir = Path(offload_dir) if offload_dir else Path(
98
+ tempfile.mkdtemp(prefix="layer_interactions_offload_")
99
+ )
100
+ self.offload_dir.mkdir(parents=True, exist_ok=True)
101
+ self._resolved_layer_path: str | None = None
102
+ self.result: Order2Result | None = None
103
+
104
+ @property
105
+ def depth(self) -> int:
106
+ self._ensure_loaded()
107
+ return len(self._layers())
108
+
109
+ def _auto_max_memory(self) -> dict | None:
110
+ if not torch.cuda.is_available():
111
+ return None
112
+
113
+ free_gib = torch.cuda.mem_get_info()[0] / 2**30
114
+ gpu = self.gpu_memory_gib
115
+ if gpu is None:
116
+ gpu = max(1, int(free_gib - self.activation_headroom_gib))
117
+
118
+ cpu_free_gib = psutil.virtual_memory().available / 2**30
119
+ cpu = self.cpu_memory_gib
120
+ if cpu is None:
121
+ cpu = max(2, int(cpu_free_gib - 4.0))
122
+
123
+ return {0: f"{gpu}GiB", "cpu": f"{cpu}GiB"}
124
+
125
+ def _ensure_loaded(self) -> None:
126
+ if self.tokenizer is None:
127
+ source = self.tokenizer_source or self.model_source
128
+ if isinstance(source, PreTrainedModel):
129
+ raise ValueError("Pass a tokenizer when model is an already-instantiated model.")
130
+ self.tokenizer = AutoTokenizer.from_pretrained(
131
+ source,
132
+ local_files_only=self.local_files_only,
133
+ trust_remote_code=self.trust_remote_code,
134
+ use_fast=True,
135
+ )
136
+ if self.tokenizer.pad_token_id is None:
137
+ self.tokenizer.pad_token = self.tokenizer.eos_token
138
+
139
+ if self.model is None:
140
+ kwargs = dict(
141
+ local_files_only=self.local_files_only,
142
+ trust_remote_code=self.trust_remote_code,
143
+ dtype=self.dtype,
144
+ low_cpu_mem_usage=True,
145
+ )
146
+ if self.device_map is not None:
147
+ kwargs["device_map"] = self.device_map
148
+ max_memory = self._auto_max_memory()
149
+ if max_memory is not None and self.device_map == "auto":
150
+ kwargs.update(
151
+ max_memory=max_memory,
152
+ offload_folder=str(self.offload_dir),
153
+ offload_state_dict=True,
154
+ offload_buffers=True,
155
+ )
156
+ self.model = AutoModelForCausalLM.from_pretrained(self.model_source, **kwargs)
157
+
158
+ self.model.config.use_cache = False
159
+ if hasattr(self.model, "generation_config"):
160
+ self.model.generation_config.use_cache = False
161
+ self.model.eval()
162
+ self._resolve_layer_path()
163
+
164
+ def _resolve_layer_path(self) -> str:
165
+ if self._resolved_layer_path is not None:
166
+ return self._resolved_layer_path
167
+ self._ensure_model_exists_for_resolution()
168
+ candidates = (self.layer_path,) if self.layer_path else _LAYER_PATHS
169
+ for path in candidates:
170
+ if path is None:
171
+ continue
172
+ try:
173
+ value = _get_attr_path(self.model, path)
174
+ except AttributeError:
175
+ continue
176
+ if isinstance(value, (torch.nn.ModuleList, list, tuple)):
177
+ self._resolved_layer_path = path
178
+ return path
179
+ raise ValueError(
180
+ "Could not find transformer layers automatically. "
181
+ "Pass layer_path, e.g. layer_path='model.layers'."
182
+ )
183
+
184
+ def _ensure_model_exists_for_resolution(self) -> None:
185
+ if self.model is None:
186
+ raise RuntimeError("Model has not been loaded.")
187
+
188
+ def _layers(self):
189
+ self._ensure_model_exists_for_resolution()
190
+ path = self._resolved_layer_path or self._resolve_layer_path()
191
+ return _get_attr_path(self.model, path)
192
+
193
+ def _set_layers(self, layers: Sequence[torch.nn.Module]) -> None:
194
+ self._ensure_model_exists_for_resolution()
195
+ path = self._resolved_layer_path or self._resolve_layer_path()
196
+ _set_attr_path(self.model, path, torch.nn.ModuleList(list(layers)))
197
+
198
+ for k, block in enumerate(self._layers()):
199
+ if hasattr(block, "layer_idx"):
200
+ block.layer_idx = k
201
+ if hasattr(block, "self_attn") and hasattr(block.self_attn, "layer_idx"):
202
+ block.self_attn.layer_idx = k
203
+
204
+ if hasattr(self.model.config, "num_hidden_layers"):
205
+ self.model.config.num_hidden_layers = len(layers)
206
+
207
+ def calibration_batches(
208
+ self,
209
+ texts: Sequence[str],
210
+ *,
211
+ n_sequences: int = 32,
212
+ sequence_length: int = 128,
213
+ seed: int = 42,
214
+ tokenizer_chunk_size: int = 4096,
215
+ ) -> list[torch.Tensor]:
216
+ self._ensure_loaded()
217
+ joined = "\n\n".join(str(x) for x in texts if str(x).strip())
218
+ enc = self.tokenizer(
219
+ joined,
220
+ add_special_tokens=False,
221
+ truncation=True,
222
+ max_length=tokenizer_chunk_size,
223
+ return_overflowing_tokens=True,
224
+ return_attention_mask=False,
225
+ )
226
+ ids = torch.tensor(
227
+ list(itertools.chain.from_iterable(enc["input_ids"])), dtype=torch.long
228
+ )
229
+ max_start = len(ids) - sequence_length - 1
230
+ if max_start < 0:
231
+ raise ValueError(
232
+ f"Calibration corpus has {len(ids)} tokens; need at least {sequence_length + 1}."
233
+ )
234
+ if max_start + 1 < n_sequences:
235
+ raise ValueError(
236
+ f"Not enough distinct start positions for {n_sequences} calibration sequences."
237
+ )
238
+ starts = random.Random(seed).sample(range(max_start + 1), n_sequences)
239
+ return [ids[s : s + sequence_length].unsqueeze(0) for s in starts]
240
+
241
+ def _input_device(self) -> torch.device:
242
+ emb = self.model.get_input_embeddings()
243
+ for p in emb.parameters():
244
+ if p.device.type != "meta":
245
+ return p.device
246
+ return torch.device("cpu")
247
+
248
+ def score_nll(self, batches: Iterable[torch.Tensor]) -> float:
249
+ self._ensure_loaded()
250
+ dev = self._input_device()
251
+ total = 0.0
252
+ ntok = 0
253
+ with torch.inference_mode():
254
+ for cpu_x in batches:
255
+ x = cpu_x.to(dev)
256
+ output = self.model(input_ids=x, use_cache=False)
257
+ logits = output.logits[:, :-1, :]
258
+ target = x[:, 1:].to(logits.device)
259
+ loss = F.cross_entropy(
260
+ logits.reshape(-1, logits.shape[-1]).float(),
261
+ target.reshape(-1),
262
+ reduction="sum",
263
+ )
264
+ total += float(loss.detach().cpu())
265
+ ntok += int(target.numel())
266
+ del x, output, logits, target, loss
267
+ if ntok == 0:
268
+ raise ValueError("No calibration tokens were scored.")
269
+ return total / ntok
270
+
271
+ @staticmethod
272
+ def _atomic_write(path: Path, obj: dict) -> None:
273
+ path.parent.mkdir(parents=True, exist_ok=True)
274
+ tmp = path.with_suffix(path.suffix + ".tmp")
275
+ tmp.write_text(json.dumps(obj, indent=2))
276
+ os.replace(tmp, path)
277
+
278
+ def fit(
279
+ self,
280
+ *,
281
+ texts: Sequence[str] | None = None,
282
+ batches: Sequence[torch.Tensor] | None = None,
283
+ n_sequences: int = 32,
284
+ sequence_length: int = 128,
285
+ seed: int = 42,
286
+ checkpoint_path: str | Path | None = None,
287
+ resume: bool = True,
288
+ max_delete: int | None = None,
289
+ ) -> Order2Result:
290
+ """Measure baseline/single/pair NLLs and construct the greedy order-2 path."""
291
+ self._ensure_loaded()
292
+ if batches is None:
293
+ if texts is None:
294
+ raise ValueError("Pass either texts=... or batches=...")
295
+ batches = self.calibration_batches(
296
+ texts,
297
+ n_sequences=n_sequences,
298
+ sequence_length=sequence_length,
299
+ seed=seed,
300
+ )
301
+
302
+ depth = self.depth
303
+ ckpt = Path(checkpoint_path) if checkpoint_path else None
304
+ state = {
305
+ "method": "order-2 interaction greedy",
306
+ "depth": depth,
307
+ "baseline_nll": None,
308
+ "single_nll": {},
309
+ "pair_nll": {},
310
+ "complete": False,
311
+ }
312
+ if ckpt and resume and ckpt.exists():
313
+ loaded = json.loads(ckpt.read_text())
314
+ if int(loaded.get("depth", depth)) != depth:
315
+ raise ValueError("Checkpoint depth does not match the loaded model.")
316
+ state.update(loaded)
317
+ state.setdefault("single_nll", {})
318
+ state.setdefault("pair_nll", {})
319
+
320
+ original = list(self._layers())
321
+ try:
322
+ if state["baseline_nll"] is None:
323
+ self._set_layers(original)
324
+ state["baseline_nll"] = self.score_nll(batches)
325
+ if ckpt:
326
+ self._atomic_write(ckpt, state)
327
+
328
+ for i in range(depth):
329
+ key = str(i)
330
+ if key in state["single_nll"]:
331
+ continue
332
+ kept = [b for j, b in enumerate(original) if j != i]
333
+ self._set_layers(kept)
334
+ state["single_nll"][key] = self.score_nll(batches)
335
+ if ckpt:
336
+ self._atomic_write(ckpt, state)
337
+ self._set_layers(original)
338
+
339
+ total_pairs = depth * (depth - 1) // 2
340
+ for i in range(depth):
341
+ for j in range(i + 1, depth):
342
+ key = f"{i},{j}"
343
+ if key in state["pair_nll"]:
344
+ continue
345
+ kept = [b for q, b in enumerate(original) if q not in (i, j)]
346
+ self._set_layers(kept)
347
+ state["pair_nll"][key] = self.score_nll(batches)
348
+ if ckpt:
349
+ self._atomic_write(ckpt, state)
350
+ self._set_layers(original)
351
+ print(f"pair {key:>7s} | {len(state['pair_nll'])}/{total_pairs}", flush=True)
352
+
353
+ result = Order2Result(
354
+ depth=depth,
355
+ baseline_nll=float(state["baseline_nll"]),
356
+ single_nll={int(k): float(v) for k, v in state["single_nll"].items()},
357
+ pair_nll={
358
+ tuple(int(x) for x in k.split(",")): float(v)
359
+ for k, v in state["pair_nll"].items()
360
+ },
361
+ )
362
+ result.build_interactions().build_greedy_path(max_delete=max_delete)
363
+ self.result = result
364
+ if ckpt:
365
+ result.save_json(ckpt)
366
+ return result
367
+ finally:
368
+ self._set_layers(original)
369
+
370
+ def select(self, target_layers: int, result: Order2Result | None = None) -> dict[str, list[int]]:
371
+ result = result or self.result
372
+ if result is None:
373
+ raise RuntimeError("Call fit() first or pass result=...")
374
+ return result.select(target_layers)
375
+
376
+ def apply(
377
+ self,
378
+ target_layers: int,
379
+ *,
380
+ result: Order2Result | None = None,
381
+ ) -> PreTrainedModel:
382
+ """Apply a selection to the currently loaded model in place."""
383
+ self._ensure_loaded()
384
+ result = result or self.result
385
+ if result is None:
386
+ raise RuntimeError("Call fit() first or pass result=...")
387
+ selection = result.select(target_layers)
388
+ original = list(self._layers())
389
+ retained = selection["retained_layers"]
390
+ self._set_layers([original[i] for i in retained])
391
+ return self.model
392
+
393
+ def prune(
394
+ self,
395
+ target_layers: int,
396
+ *,
397
+ texts: Sequence[str] | None = None,
398
+ batches: Sequence[torch.Tensor] | None = None,
399
+ n_sequences: int = 32,
400
+ sequence_length: int = 128,
401
+ seed: int = 42,
402
+ checkpoint_path: str | Path | None = None,
403
+ resume: bool = True,
404
+ ) -> tuple[PreTrainedModel, Order2Result]:
405
+ result = self.fit(
406
+ texts=texts,
407
+ batches=batches,
408
+ n_sequences=n_sequences,
409
+ sequence_length=sequence_length,
410
+ seed=seed,
411
+ checkpoint_path=checkpoint_path,
412
+ resume=resume,
413
+ max_delete=self.depth - target_layers,
414
+ )
415
+ model = self.apply(target_layers, result=result)
416
+ return model, result
417
+
418
+ def save_pruned(
419
+ self,
420
+ output_dir: str | Path,
421
+ target_layers: int,
422
+ *,
423
+ result: Order2Result | None = None,
424
+ safe_serialization: bool = True,
425
+ ) -> Path:
426
+ """Apply a selection and save the pruned model/tokenizer with HF save_pretrained()."""
427
+ model = self.apply(target_layers, result=result)
428
+ output_dir = Path(output_dir)
429
+ output_dir.mkdir(parents=True, exist_ok=True)
430
+ model.save_pretrained(output_dir, safe_serialization=safe_serialization)
431
+ if self.tokenizer is not None:
432
+ self.tokenizer.save_pretrained(output_dir)
433
+ (output_dir / "layer_interaction_selection.json").write_text(
434
+ json.dumps((result or self.result).select(target_layers), indent=2)
435
+ )
436
+ return output_dir
437
+
438
+ def close(self, *, drop_model: bool = False) -> None:
439
+ if drop_model:
440
+ self.model = None
441
+ self.tokenizer = None
442
+ gc.collect()
443
+ if torch.cuda.is_available():
444
+ torch.cuda.empty_cache()
445
+ try:
446
+ torch.cuda.ipc_collect()
447
+ except Exception:
448
+ pass
449
+ if self._owns_offload_dir:
450
+ shutil.rmtree(self.offload_dir, ignore_errors=True)
451
+
452
+ def __enter__(self) -> "Order2Pruner":
453
+ return self
454
+
455
+ def __exit__(self, exc_type, exc, tb) -> None:
456
+ self.close(drop_model=True)
layer_interactions/result.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Order2Result:
11
+ """Measurements and selections produced by the order-2 interaction method."""
12
+
13
+ depth: int
14
+ baseline_nll: float
15
+ single_nll: dict[int, float]
16
+ pair_nll: dict[tuple[int, int], float]
17
+ first_order: dict[int, float] = field(default_factory=dict)
18
+ second_order: dict[tuple[int, int], float] = field(default_factory=dict)
19
+ delete_order: list[int] = field(default_factory=list)
20
+ greedy_path: list[dict[str, Any]] = field(default_factory=list)
21
+
22
+ def build_interactions(self) -> "Order2Result":
23
+ d0 = float(self.baseline_nll)
24
+ self.first_order = {
25
+ i: float(self.single_nll[i]) - d0 for i in range(self.depth)
26
+ }
27
+ self.second_order = {}
28
+ for i in range(self.depth):
29
+ for j in range(i + 1, self.depth):
30
+ self.second_order[(i, j)] = (
31
+ float(self.pair_nll[(i, j)])
32
+ - float(self.single_nll[i])
33
+ - float(self.single_nll[j])
34
+ + d0
35
+ )
36
+ return self
37
+
38
+ def build_greedy_path(self, max_delete: int | None = None) -> "Order2Result":
39
+ if not self.first_order or not self.second_order:
40
+ self.build_interactions()
41
+
42
+ if max_delete is None:
43
+ max_delete = self.depth - 1
44
+ if not 0 <= max_delete < self.depth:
45
+ raise ValueError(f"max_delete must be in [0, {self.depth - 1}]")
46
+
47
+ deleted: list[int] = []
48
+ deleted_set: set[int] = set()
49
+ path: list[dict[str, Any]] = []
50
+ cumulative = 0.0
51
+
52
+ for step in range(max_delete):
53
+ candidates: list[tuple[float, int]] = []
54
+ for i in range(self.depth):
55
+ if i in deleted_set:
56
+ continue
57
+ interaction = sum(
58
+ self.second_order[tuple(sorted((i, j)))] for j in deleted
59
+ )
60
+ marginal = self.first_order[i] + interaction
61
+ candidates.append((marginal, i))
62
+
63
+ marginal, chosen = min(candidates, key=lambda z: (z[0], z[1]))
64
+ deleted.append(chosen)
65
+ deleted_set.add(chosen)
66
+ cumulative += marginal
67
+ path.append(
68
+ {
69
+ "step": step + 1,
70
+ "deleted_layer": chosen,
71
+ "marginal_predicted_nll_change": float(marginal),
72
+ "cumulative_predicted_nll_change": float(cumulative),
73
+ }
74
+ )
75
+
76
+ self.delete_order = deleted
77
+ self.greedy_path = path
78
+ return self
79
+
80
+ def select(self, target_layers: int) -> dict[str, list[int]]:
81
+ if not 1 <= target_layers <= self.depth:
82
+ raise ValueError(f"target_layers must be in [1, {self.depth}]")
83
+ n_delete = self.depth - target_layers
84
+ if len(self.delete_order) < n_delete:
85
+ self.build_greedy_path(max_delete=n_delete)
86
+ deleted = list(self.delete_order[:n_delete])
87
+ deleted_set = set(deleted)
88
+ retained = [i for i in range(self.depth) if i not in deleted_set]
89
+ return {"retained_layers": retained, "deleted_layers": deleted}
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "method": "order-2 interaction greedy",
94
+ "depth": self.depth,
95
+ "baseline_nll": float(self.baseline_nll),
96
+ "single_nll": {str(k): float(v) for k, v in self.single_nll.items()},
97
+ "pair_nll": {f"{i},{j}": float(v) for (i, j), v in self.pair_nll.items()},
98
+ "first_order_delta": {str(k): float(v) for k, v in self.first_order.items()},
99
+ "second_order_interaction": {
100
+ f"{i},{j}": float(v) for (i, j), v in self.second_order.items()
101
+ },
102
+ "delete_order": list(self.delete_order),
103
+ "greedy_path": list(self.greedy_path),
104
+ "complete": True,
105
+ }
106
+
107
+ def save_json(self, path: str | Path) -> None:
108
+ path = Path(path)
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ tmp = path.with_suffix(path.suffix + ".tmp")
111
+ tmp.write_text(json.dumps(self.to_dict(), indent=2))
112
+ tmp.replace(path)
113
+
114
+ @classmethod
115
+ def from_dict(cls, obj: dict[str, Any]) -> "Order2Result":
116
+ pair = {}
117
+ for key, value in obj.get("pair_nll", {}).items():
118
+ i, j = (int(x) for x in key.split(","))
119
+ pair[(i, j)] = float(value)
120
+ second = {}
121
+ for key, value in obj.get("second_order_interaction", {}).items():
122
+ i, j = (int(x) for x in key.split(","))
123
+ second[(i, j)] = float(value)
124
+ result = cls(
125
+ depth=int(obj["depth"]),
126
+ baseline_nll=float(obj["baseline_nll"]),
127
+ single_nll={int(k): float(v) for k, v in obj.get("single_nll", {}).items()},
128
+ pair_nll=pair,
129
+ first_order={
130
+ int(k): float(v) for k, v in obj.get("first_order_delta", {}).items()
131
+ },
132
+ second_order=second,
133
+ delete_order=[int(x) for x in obj.get("delete_order", [])],
134
+ greedy_path=list(obj.get("greedy_path", [])),
135
+ )
136
+ return result
137
+
138
+ @classmethod
139
+ def load_json(cls, path: str | Path) -> "Order2Result":
140
+ return cls.from_dict(json.loads(Path(path).read_text()))
tests/test_result.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from layer_interactions.result import Order2Result
2
+
3
+
4
+ def test_interactions_and_selection():
5
+ # D0 = 1.0. First-order deletion costs: [0.1, 0.2, 0.05]
6
+ # Pair (0,2) has a positive interaction of +0.3, so after deleting 2,
7
+ # deleting 1 is cheaper than deleting 0.
8
+ r = Order2Result(
9
+ depth=3,
10
+ baseline_nll=1.0,
11
+ single_nll={0: 1.1, 1: 1.2, 2: 1.05},
12
+ pair_nll={
13
+ (0, 1): 1.3,
14
+ (0, 2): 1.45,
15
+ (1, 2): 1.25,
16
+ },
17
+ )
18
+ r.build_interactions().build_greedy_path(max_delete=2)
19
+ assert r.delete_order == [2, 1]
20
+ assert r.select(1)["retained_layers"] == [0]
21
+
22
+
23
+ def test_roundtrip_dict():
24
+ r = Order2Result(
25
+ depth=2,
26
+ baseline_nll=1.0,
27
+ single_nll={0: 1.1, 1: 1.2},
28
+ pair_nll={(0, 1): 1.35},
29
+ ).build_interactions().build_greedy_path(max_delete=1)
30
+ rr = Order2Result.from_dict(r.to_dict())
31
+ assert rr.depth == 2
32
+ assert rr.single_nll == r.single_nll
33
+ assert rr.pair_nll == r.pair_nll
34
+ assert rr.delete_order == r.delete_order