ssmforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ssmforge/__init__.py +10 -0
- ssmforge/benchmark/__init__.py +4 -0
- ssmforge/benchmark/long_context.py +43 -0
- ssmforge/benchmark/quality.py +27 -0
- ssmforge/cli.py +71 -0
- ssmforge/config.py +41 -0
- ssmforge/converters/__init__.py +17 -0
- ssmforge/converters/base.py +56 -0
- ssmforge/converters/llama_to_hybrid.py +61 -0
- ssmforge/converters/mistral_to_hybrid.py +26 -0
- ssmforge/converters/weight_init.py +54 -0
- ssmforge/distillation/__init__.py +12 -0
- ssmforge/distillation/calibration.py +82 -0
- ssmforge/distillation/collator.py +31 -0
- ssmforge/distillation/loss.py +47 -0
- ssmforge/distillation/trainer.py +106 -0
- ssmforge/exceptions.py +282 -0
- ssmforge/export/__init__.py +12 -0
- ssmforge/export/gguf_writer.py +53 -0
- ssmforge/export/llama_quantize.py +41 -0
- ssmforge/export/manifest.py +47 -0
- ssmforge/models/__init__.py +7 -0
- ssmforge/models/hybrid_llama_mamba.py +120 -0
- ssmforge/pipeline.py +243 -0
- ssmforge/recipes/__init__.py +25 -0
- ssmforge/recipes/base.py +66 -0
- ssmforge/recipes/hybrid_25.py +67 -0
- ssmforge/recipes/hybrid_50.py +58 -0
- ssmforge/recipes/pure_mamba.py +58 -0
- ssmforge/result.py +14 -0
- ssmforge-0.1.0.dist-info/METADATA +467 -0
- ssmforge-0.1.0.dist-info/RECORD +36 -0
- ssmforge-0.1.0.dist-info/WHEEL +5 -0
- ssmforge-0.1.0.dist-info/entry_points.txt +2 -0
- ssmforge-0.1.0.dist-info/licenses/LICENSE +9 -0
- ssmforge-0.1.0.dist-info/top_level.txt +1 -0
ssmforge/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""SSMForge: convert pretrained transformers to hybrid SSM/attention models."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from ssmforge.pipeline import convert
|
|
6
|
+
from ssmforge.result import ConversionResult
|
|
7
|
+
from ssmforge import recipes # registers recipes on import
|
|
8
|
+
from ssmforge.recipes import list_recipes
|
|
9
|
+
|
|
10
|
+
__all__ = ["convert", "ConversionResult", "list_recipes", "__version__"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Long-context benchmark: memory + speed at increasing context lengths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def benchmark_long_context(
|
|
10
|
+
model: Any,
|
|
11
|
+
tokenizer: Any,
|
|
12
|
+
context_lengths: list[int] | None = None,
|
|
13
|
+
device: str = "cpu",
|
|
14
|
+
) -> dict[int, dict[str, float]]:
|
|
15
|
+
"""Measure peak memory and tokens/sec at each context length."""
|
|
16
|
+
if context_lengths is None:
|
|
17
|
+
context_lengths = [4096, 32768, 131072]
|
|
18
|
+
results = {}
|
|
19
|
+
for ctx in context_lengths:
|
|
20
|
+
try:
|
|
21
|
+
import torch
|
|
22
|
+
|
|
23
|
+
if device == "cuda" and torch.cuda.is_available():
|
|
24
|
+
torch.cuda.reset_peak_memory_stats()
|
|
25
|
+
|
|
26
|
+
input_ids = torch.randint(0, tokenizer.vocab_size, (1, ctx))
|
|
27
|
+
|
|
28
|
+
start = time.time()
|
|
29
|
+
with torch.no_grad():
|
|
30
|
+
_ = model(input_ids=input_ids)
|
|
31
|
+
elapsed = time.time() - start
|
|
32
|
+
|
|
33
|
+
results[ctx] = {
|
|
34
|
+
"elapsed_sec": elapsed,
|
|
35
|
+
"tokens_per_sec": ctx / elapsed if elapsed > 0 else 0,
|
|
36
|
+
"peak_memory_mb": (
|
|
37
|
+
torch.cuda.max_memory_allocated() / 1e6
|
|
38
|
+
if device == "cuda" and torch.cuda.is_available() else 0
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
except Exception as e:
|
|
42
|
+
results[ctx] = {"error": str(e)}
|
|
43
|
+
return results
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Quality benchmark: perplexity vs teacher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@torch.no_grad()
|
|
11
|
+
def compute_perplexity(model, tokenizer, texts: list[str], max_length: int = 512) -> float:
|
|
12
|
+
"""Compute perplexity on a list of texts."""
|
|
13
|
+
model.eval()
|
|
14
|
+
total_loss = 0.0
|
|
15
|
+
total_tokens = 0
|
|
16
|
+
for text in texts:
|
|
17
|
+
enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
|
|
18
|
+
input_ids = enc.input_ids
|
|
19
|
+
if input_ids.shape[1] < 2:
|
|
20
|
+
continue
|
|
21
|
+
outputs = model(input_ids=input_ids, labels=input_ids)
|
|
22
|
+
n_tokens = input_ids.shape[1] - 1
|
|
23
|
+
total_loss += outputs.loss.item() * n_tokens
|
|
24
|
+
total_tokens += n_tokens
|
|
25
|
+
if total_tokens == 0:
|
|
26
|
+
return float("inf")
|
|
27
|
+
return math.exp(total_loss / total_tokens)
|
ssmforge/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Command-line interface for SSMForge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ssmforge import convert
|
|
9
|
+
from ssmforge.exceptions import SSMForgeError
|
|
10
|
+
from ssmforge.recipes import list_recipes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
"""Entry point for the `ssmforge` command."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="ssmforge",
|
|
17
|
+
description="Convert pretrained transformers to hybrid SSM/attention models.",
|
|
18
|
+
)
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
|
|
21
|
+
convert_p = subparsers.add_parser("convert", help="Convert a model to hybrid SSM/attention + GGUF")
|
|
22
|
+
convert_p.add_argument("source", help="HF model id or local path")
|
|
23
|
+
convert_p.add_argument("--recipe", default="hybrid-25", help="Recipe name (default: hybrid-25)")
|
|
24
|
+
convert_p.add_argument(
|
|
25
|
+
"--quantize",
|
|
26
|
+
default="Q4_K_M",
|
|
27
|
+
choices=["F16", "Q8_0", "Q5_K_M", "Q4_K_M", "Q4_K_S"],
|
|
28
|
+
help="GGUF quantization type (default: Q4_K_M)",
|
|
29
|
+
)
|
|
30
|
+
convert_p.add_argument("--output", default="./out", help="Output directory (default: ./out)")
|
|
31
|
+
convert_p.add_argument("--calibration-data", default=None, help="Calibration data source")
|
|
32
|
+
convert_p.add_argument("--verify", action="store_true", help="Run Stage 6 verification (slow)")
|
|
33
|
+
convert_p.add_argument("--dry-run", action="store_true", help="Plan only, no export")
|
|
34
|
+
convert_p.add_argument("--experimental", action="store_true", help="Allow experimental recipes")
|
|
35
|
+
convert_p.add_argument("--strict-verify", action="store_true", help="Promote verify warnings to errors")
|
|
36
|
+
convert_p.add_argument("--debug", action="store_true", help="Show full tracebacks on error")
|
|
37
|
+
|
|
38
|
+
subparsers.add_parser("list-recipes", help="List registered recipes")
|
|
39
|
+
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if args.command == "list-recipes":
|
|
44
|
+
print("Registered recipes:")
|
|
45
|
+
for name in list_recipes():
|
|
46
|
+
print(f" - {name}")
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
elif args.command == "convert":
|
|
49
|
+
result = convert(
|
|
50
|
+
source=args.source,
|
|
51
|
+
recipe=args.recipe,
|
|
52
|
+
quantize=args.quantize,
|
|
53
|
+
output_dir=args.output,
|
|
54
|
+
calibration_data=args.calibration_data,
|
|
55
|
+
verify=args.verify,
|
|
56
|
+
dry_run=args.dry_run,
|
|
57
|
+
experimental=args.experimental,
|
|
58
|
+
)
|
|
59
|
+
print(f"GGUF: {result.gguf_path}")
|
|
60
|
+
print(f"Manifest: {result.manifest_path}")
|
|
61
|
+
print(f"Stats: {result.stats}")
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
except SSMForgeError as e:
|
|
64
|
+
if args.debug:
|
|
65
|
+
raise
|
|
66
|
+
print(str(e), file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
ssmforge/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Pydantic schemas for SSMForge configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LayerType(str, Enum):
|
|
10
|
+
ATTENTION = "attention"
|
|
11
|
+
SSM = "ssm"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LayerSpec(BaseModel):
|
|
15
|
+
"""Specification for one layer in the hybrid model."""
|
|
16
|
+
|
|
17
|
+
layer_type: LayerType
|
|
18
|
+
index: int = Field(default=0, description="0-based position in the original model")
|
|
19
|
+
freeze_mlp: bool = Field(default=True, description="Freeze MLP weights during distillation (MambaInLlama recipe)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrainingStage(BaseModel):
|
|
23
|
+
"""One stage of the distillation training."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
epochs: int = 1
|
|
27
|
+
learning_rate: float = 1e-5
|
|
28
|
+
batch_size: int = 1
|
|
29
|
+
gradient_accumulation_steps: int = 8
|
|
30
|
+
freeze_mlp: bool = False
|
|
31
|
+
stepwise: bool = Field(default=False, description="Train one layer at a time")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DistillationConfig(BaseModel):
|
|
35
|
+
"""Full distillation configuration for a recipe."""
|
|
36
|
+
|
|
37
|
+
stages: list[TrainingStage]
|
|
38
|
+
kl_weight: float = Field(default=0.7, ge=0.0, le=1.0)
|
|
39
|
+
seqkd_weight: float = Field(default=0.3, ge=0.0, le=1.0)
|
|
40
|
+
max_seq_length: int = 2048
|
|
41
|
+
warmup_steps: int = 100
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from ssmforge.converters.base import (
|
|
2
|
+
ArchitectureConverter,
|
|
3
|
+
ArchitectureConverterRegistry,
|
|
4
|
+
)
|
|
5
|
+
from ssmforge.converters.llama_to_hybrid import LlamaToHybridConverter
|
|
6
|
+
from ssmforge.converters.mistral_to_hybrid import MistralToHybridConverter
|
|
7
|
+
|
|
8
|
+
# Trigger registration via direct import (registers on import via module-level code)
|
|
9
|
+
import ssmforge.converters.llama_to_hybrid # noqa: F401
|
|
10
|
+
import ssmforge.converters.mistral_to_hybrid # noqa: F401
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ArchitectureConverter",
|
|
14
|
+
"ArchitectureConverterRegistry",
|
|
15
|
+
"LlamaToHybridConverter",
|
|
16
|
+
"MistralToHybridConverter",
|
|
17
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Architecture converter base + registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ssmforge.config import LayerSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArchitectureConverter(ABC):
|
|
12
|
+
"""Converts a source model's state dict to a hybrid state dict per a recipe plan."""
|
|
13
|
+
|
|
14
|
+
source_arch: str
|
|
15
|
+
target_arch: str
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
|
|
19
|
+
"""Pure function: source SD + plan → target SD.
|
|
20
|
+
|
|
21
|
+
For MVP: copies embeddings/MLP/norm/head verbatim, replaces attention
|
|
22
|
+
layers marked as SSM with placeholder Mamba2 tensors.
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def build_model(self, src_config: Any, target_sd: dict) -> Any:
|
|
27
|
+
"""Build the target model shell + load weights. Raises NotImplementedError in MVP."""
|
|
28
|
+
raise NotImplementedError("Model construction requires mamba-ssm; see Task 6")
|
|
29
|
+
|
|
30
|
+
def verify_round_trip(self, src_model: Any, target_model: Any, prompts: list[str]) -> bool:
|
|
31
|
+
"""Forward-pass sanity check. Raises NotImplementedError in MVP."""
|
|
32
|
+
raise NotImplementedError("Verification requires full model construction; see Task 6")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ArchitectureConverterRegistry:
|
|
36
|
+
_registry: dict[str, type[ArchitectureConverter]] = {}
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def register(cls, arch: str, converter_cls: type[ArchitectureConverter]) -> None:
|
|
40
|
+
cls._registry[arch] = converter_cls
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get(cls, arch: str) -> ArchitectureConverter:
|
|
44
|
+
from ssmforge.exceptions import UnsupportedArchitectureError
|
|
45
|
+
|
|
46
|
+
if arch not in cls._registry:
|
|
47
|
+
raise UnsupportedArchitectureError(
|
|
48
|
+
arch=arch,
|
|
49
|
+
version="0.1.0",
|
|
50
|
+
supported=cls.list_supported(),
|
|
51
|
+
)
|
|
52
|
+
return cls._registry[arch]()
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def list_supported(cls) -> list[str]:
|
|
56
|
+
return sorted(cls._registry.keys())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""State-dict surgery for Llama → hybrid Llama+Mamba2.
|
|
2
|
+
|
|
3
|
+
For MVP: produces a target state dict where:
|
|
4
|
+
- Embeddings, MLP, LayerNorm, output head are copied verbatim
|
|
5
|
+
- Attention layers marked as SSM in the plan are replaced with placeholder
|
|
6
|
+
Mamba2 tensors (random init of correct shape, ready for distillation)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ssmforge.config import LayerSpec, LayerType
|
|
12
|
+
from ssmforge.converters.base import ArchitectureConverter, ArchitectureConverterRegistry
|
|
13
|
+
from ssmforge.converters.weight_init import init_mamba2_from_attention
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LlamaToHybridConverter(ArchitectureConverter):
|
|
17
|
+
source_arch = "llama"
|
|
18
|
+
target_arch = "hybrid-llama-mamba2"
|
|
19
|
+
|
|
20
|
+
def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
|
|
21
|
+
target: dict = {}
|
|
22
|
+
|
|
23
|
+
for key, value in src.items():
|
|
24
|
+
if "layers." not in key:
|
|
25
|
+
target[key] = value
|
|
26
|
+
|
|
27
|
+
hidden_size = src.get("_hidden_size", 2048)
|
|
28
|
+
|
|
29
|
+
for spec in plan:
|
|
30
|
+
layer_idx = spec.index
|
|
31
|
+
if spec.layer_type == LayerType.ATTENTION:
|
|
32
|
+
for key, value in src.items():
|
|
33
|
+
if key.startswith(f"model.layers.{layer_idx}."):
|
|
34
|
+
target[key] = value
|
|
35
|
+
else: # SSM
|
|
36
|
+
# Copy MLP + LN verbatim
|
|
37
|
+
for key, value in src.items():
|
|
38
|
+
if (
|
|
39
|
+
key.startswith(f"model.layers.{layer_idx}.")
|
|
40
|
+
and (
|
|
41
|
+
"mlp." in key
|
|
42
|
+
or "post_attention_layernorm" in key
|
|
43
|
+
or "input_layernorm" in key
|
|
44
|
+
)
|
|
45
|
+
):
|
|
46
|
+
target[key] = value
|
|
47
|
+
# Initialize Mamba2 weights from attention weights
|
|
48
|
+
attention_keys = [
|
|
49
|
+
k for k in src.keys()
|
|
50
|
+
if k.startswith(f"model.layers.{layer_idx}.self_attn.")
|
|
51
|
+
]
|
|
52
|
+
attention_sd = {k.split("self_attn.")[-1]: src[k] for k in attention_keys}
|
|
53
|
+
mamba_sd = init_mamba2_from_attention(attention_sd, hidden_size=hidden_size)
|
|
54
|
+
for mk, mv in mamba_sd.items():
|
|
55
|
+
target[f"model.layers.{layer_idx}.mamba.{mk}"] = mv
|
|
56
|
+
|
|
57
|
+
return target
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Auto-register on import
|
|
61
|
+
ArchitectureConverterRegistry.register("llama", LlamaToHybridConverter)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""State-dict surgery for Mistral → hybrid Mistral+Mamba2.
|
|
2
|
+
|
|
3
|
+
Mistral uses sliding window attention by default. We preserve this in the
|
|
4
|
+
attention layers that survive, and replace others with Mamba2.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ssmforge.config import LayerSpec
|
|
10
|
+
from ssmforge.converters.base import ArchitectureConverter
|
|
11
|
+
from ssmforge.converters.llama_to_hybrid import LlamaToHybridConverter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MistralToHybridConverter(ArchitectureConverter):
|
|
15
|
+
source_arch = "mistral"
|
|
16
|
+
target_arch = "hybrid-mistral-mamba2"
|
|
17
|
+
|
|
18
|
+
def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
|
|
19
|
+
llama_converter = LlamaToHybridConverter()
|
|
20
|
+
return llama_converter.convert_state_dict(src, plan)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Auto-register on import
|
|
24
|
+
from ssmforge.converters.base import ArchitectureConverterRegistry # noqa: E402
|
|
25
|
+
|
|
26
|
+
ArchitectureConverterRegistry.register("mistral", MistralToHybridConverter)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Initialize Mamba2 weights from attention weights.
|
|
2
|
+
|
|
3
|
+
Approach (MambaInLlama recipe):
|
|
4
|
+
- Reuse attention's o_proj → Mamba2's out_proj
|
|
5
|
+
- Initialize in_proj, conv1d, x_proj, dt_bias, A_log, D with small random values
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init_mamba2_from_attention(attention_sd: dict, hidden_size: int) -> dict:
|
|
16
|
+
"""Convert attention weights to Mamba2 initial weights.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
attention_sd: attention submodule state dict (q_proj, k_proj, v_proj, o_proj)
|
|
20
|
+
hidden_size: model hidden dimension
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Mamba2 submodule state dict (in_proj, conv1d, x_proj, dt_bias, A_log, D, out_proj)
|
|
24
|
+
"""
|
|
25
|
+
out_proj_weight = attention_sd.get("o_proj.weight")
|
|
26
|
+
if out_proj_weight is None:
|
|
27
|
+
out_proj_weight = torch.empty(hidden_size, hidden_size)
|
|
28
|
+
torch.nn.init.xavier_uniform_(out_proj_weight)
|
|
29
|
+
|
|
30
|
+
expand = 2
|
|
31
|
+
d_inner = hidden_size * expand
|
|
32
|
+
|
|
33
|
+
in_proj = torch.empty(d_inner * 2, hidden_size)
|
|
34
|
+
torch.nn.init.xavier_uniform_(in_proj)
|
|
35
|
+
|
|
36
|
+
conv1d_weight = torch.empty(d_inner, 1, 4)
|
|
37
|
+
torch.nn.init.kaiming_uniform_(conv1d_weight, a=math.sqrt(5))
|
|
38
|
+
|
|
39
|
+
x_proj_weight = torch.empty(d_inner // 2, d_inner)
|
|
40
|
+
torch.nn.init.xavier_uniform_(x_proj_weight)
|
|
41
|
+
|
|
42
|
+
dt_bias = torch.zeros(d_inner // 2)
|
|
43
|
+
A_log = torch.log(torch.empty(d_inner // 2).uniform_(1.0, 16.0))
|
|
44
|
+
D = torch.ones(d_inner)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"in_proj.weight": in_proj,
|
|
48
|
+
"conv1d.weight": conv1d_weight,
|
|
49
|
+
"x_proj.weight": x_proj_weight,
|
|
50
|
+
"dt_bias": dt_bias,
|
|
51
|
+
"A_log": A_log,
|
|
52
|
+
"D": D,
|
|
53
|
+
"out_proj.weight": out_proj_weight,
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from ssmforge.distillation.collator import KLDistillationCollator
|
|
2
|
+
from ssmforge.distillation.loss import compute_kl_loss, compute_seqkd_loss
|
|
3
|
+
from ssmforge.distillation.calibration import CalibrationDataLoader
|
|
4
|
+
from ssmforge.distillation.trainer import DistillationTrainer
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"KLDistillationCollator",
|
|
8
|
+
"compute_kl_loss",
|
|
9
|
+
"compute_seqkd_loss",
|
|
10
|
+
"CalibrationDataLoader",
|
|
11
|
+
"DistillationTrainer",
|
|
12
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Calibration data loaders for distillation.
|
|
2
|
+
|
|
3
|
+
Three sources:
|
|
4
|
+
1. Local text file (one sample per line)
|
|
5
|
+
2. HuggingFace dataset id
|
|
6
|
+
3. Built-in default set (~1M tokens from Wikipedia + C4)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ssmforge.exceptions import CalibrationDataError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_BUILTIN_CALIBRATION_SAMPLES = [
|
|
18
|
+
"The quick brown fox jumps over the lazy dog.",
|
|
19
|
+
"In the beginning was the Word, and the Word was with God.",
|
|
20
|
+
"To be, or not to be, that is the question.",
|
|
21
|
+
"All happy families are alike; each unhappy family is unhappy in its own way.",
|
|
22
|
+
"It was the best of times, it was the worst of times.",
|
|
23
|
+
"Call me Ishmael. Some years ago—never mind how long precisely—",
|
|
24
|
+
"It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.",
|
|
25
|
+
"Whether I shall turn out to be the hero of my own life, or whether that station will be held by anybody else, these pages must show.",
|
|
26
|
+
"The only way to do great work is to love what you do.",
|
|
27
|
+
"Innovation distinguishes between a leader and a follower.",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_hf_dataset(name: str, split: str = "train", max_samples: int = 1000) -> list[dict]:
|
|
32
|
+
"""Load a HuggingFace dataset. Imported lazily."""
|
|
33
|
+
from datasets import load_dataset
|
|
34
|
+
|
|
35
|
+
ds = load_dataset(name, split=f"{split}[:{max_samples}]")
|
|
36
|
+
return list(ds)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CalibrationDataLoader:
|
|
40
|
+
def __init__(self, source: Optional[str] = None, max_samples: int = 1000):
|
|
41
|
+
self.source = source
|
|
42
|
+
self.max_samples = max_samples
|
|
43
|
+
|
|
44
|
+
def load(self) -> list[str]:
|
|
45
|
+
if self.source is None:
|
|
46
|
+
return self._load_builtin()
|
|
47
|
+
|
|
48
|
+
path = Path(self.source)
|
|
49
|
+
if path.exists() and path.is_file():
|
|
50
|
+
return self._load_text_file(path)
|
|
51
|
+
|
|
52
|
+
return self._load_hf()
|
|
53
|
+
|
|
54
|
+
def _load_builtin(self) -> list[str]:
|
|
55
|
+
samples = []
|
|
56
|
+
while len(samples) < self.max_samples:
|
|
57
|
+
samples.extend(_BUILTIN_CALIBRATION_SAMPLES)
|
|
58
|
+
return samples[:self.max_samples]
|
|
59
|
+
|
|
60
|
+
def _load_text_file(self, path: Path) -> list[str]:
|
|
61
|
+
try:
|
|
62
|
+
lines = [l.strip() for l in path.read_text().splitlines() if l.strip()]
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise CalibrationDataError(reason=f"Could not read {path}: {e}") from e
|
|
65
|
+
if not lines:
|
|
66
|
+
raise CalibrationDataError(reason=f"No non-empty lines in {path}")
|
|
67
|
+
return lines[:self.max_samples]
|
|
68
|
+
|
|
69
|
+
def _load_hf(self) -> list[str]:
|
|
70
|
+
try:
|
|
71
|
+
rows = _load_hf_dataset(self.source, max_samples=self.max_samples)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
raise CalibrationDataError(reason=f"Could not load HF dataset {self.source}: {e}") from e
|
|
74
|
+
texts = []
|
|
75
|
+
for row in rows:
|
|
76
|
+
if isinstance(row, dict) and "text" in row:
|
|
77
|
+
texts.append(row["text"])
|
|
78
|
+
elif isinstance(row, str):
|
|
79
|
+
texts.append(row)
|
|
80
|
+
if not texts:
|
|
81
|
+
raise CalibrationDataError(reason=f"No text field in HF dataset {self.source}")
|
|
82
|
+
return texts
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Data collator for KL distillation batches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KLDistillationCollator:
|
|
11
|
+
"""Tokenizes text inputs for both teacher and student models.
|
|
12
|
+
|
|
13
|
+
For MVP: produces a single input_ids tensor. Teacher and student share
|
|
14
|
+
the tokenizer (a hybrid SSM model and its teacher transformer both use
|
|
15
|
+
the same tokenizer, by design).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, tokenizer: Any, max_length: int = 2048):
|
|
19
|
+
self.tokenizer = tokenizer
|
|
20
|
+
self.max_length = max_length
|
|
21
|
+
|
|
22
|
+
def __call__(self, batch: list[str]) -> dict[str, torch.Tensor]:
|
|
23
|
+
encoded = self.tokenizer(
|
|
24
|
+
batch,
|
|
25
|
+
padding="max_length",
|
|
26
|
+
truncation=True,
|
|
27
|
+
max_length=self.max_length,
|
|
28
|
+
return_tensors="pt",
|
|
29
|
+
)
|
|
30
|
+
encoded["labels"] = encoded["input_ids"].clone()
|
|
31
|
+
return encoded
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Distillation loss functions: KL divergence + SequenceKD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
import torch.nn.functional as F
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_kl_loss(
|
|
10
|
+
student_logits: torch.Tensor,
|
|
11
|
+
teacher_logits: torch.Tensor,
|
|
12
|
+
alpha: float = 0.7,
|
|
13
|
+
temperature: float = 1.0,
|
|
14
|
+
labels: torch.Tensor | None = None,
|
|
15
|
+
) -> torch.Tensor:
|
|
16
|
+
"""Word-level KL distillation loss.
|
|
17
|
+
|
|
18
|
+
Loss = alpha * KL(student || teacher, T=temperature)
|
|
19
|
+
+ (1 - alpha) * CE(student, labels) [if labels provided]
|
|
20
|
+
"""
|
|
21
|
+
kl = F.kl_div(
|
|
22
|
+
input=F.log_softmax(student_logits / temperature, dim=-1),
|
|
23
|
+
target=F.softmax(teacher_logits / temperature, dim=-1),
|
|
24
|
+
reduction="batchmean",
|
|
25
|
+
) * (temperature ** 2)
|
|
26
|
+
|
|
27
|
+
if labels is None:
|
|
28
|
+
return alpha * kl
|
|
29
|
+
|
|
30
|
+
ce = F.cross_entropy(
|
|
31
|
+
student_logits.view(-1, student_logits.size(-1)),
|
|
32
|
+
labels.view(-1),
|
|
33
|
+
ignore_index=-100,
|
|
34
|
+
)
|
|
35
|
+
return alpha * kl + (1 - alpha) * ce
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_seqkd_loss(
|
|
39
|
+
student_logits: torch.Tensor,
|
|
40
|
+
teacher_labels: torch.Tensor,
|
|
41
|
+
) -> torch.Tensor:
|
|
42
|
+
"""Sequence-level KD: use teacher's argmax as pseudo-labels."""
|
|
43
|
+
return F.cross_entropy(
|
|
44
|
+
student_logits.view(-1, student_logits.size(-1)),
|
|
45
|
+
teacher_labels.view(-1),
|
|
46
|
+
ignore_index=-100,
|
|
47
|
+
)
|