from __future__ import annotations import copy import json from pathlib import Path import numpy as np import pandas as pd import torch import trackio from model import BitMLP, parameter_count from packing import pack_binary_model from safetensors.torch import save_file from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split from torch import nn from torch.nn import functional as F from torch.utils.data import DataLoader, TensorDataset PROJECT_DIR = Path(__file__).resolve().parent ARTIFACT_DIR = PROJECT_DIR / "artifacts" / "bitforge-1bit" DATA_DIR = PROJECT_DIR / "data" def seed_everything(seed: int) -> None: np.random.seed(seed) torch.manual_seed(seed) torch.set_num_threads(1) def make_loader( images: np.ndarray, labels: np.ndarray, *, shuffle: bool, seed: int, ) -> DataLoader: return DataLoader( TensorDataset( torch.from_numpy(images.astype(np.float32)), torch.from_numpy(labels.astype(np.int64)), ), batch_size=256, shuffle=shuffle, generator=torch.Generator().manual_seed(seed), ) @torch.inference_mode() def evaluate(model: nn.Module, loader: DataLoader) -> dict: model.eval() correct = 0 total = 0 losses = [] for images, labels in loader: logits = model(images) losses.append(float(F.cross_entropy(logits, labels))) correct += int((logits.argmax(1) == labels).sum()) total += len(labels) return {"accuracy": correct / total, "cross_entropy": float(np.mean(losses))} def train_teacher( model: BitMLP, train_loader: DataLoader, validation_loader: DataLoader ) -> BitMLP: optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-4) best = copy.deepcopy(model.state_dict()) best_accuracy = 0.0 for epoch in range(1, 101): model.train() for images, labels in train_loader: loss = F.cross_entropy(model(images), labels) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 5 == 0: validation = evaluate(model, validation_loader) trackio.log( { "teacher_epoch": epoch, "teacher_validation_accuracy": validation["accuracy"], } ) if validation["accuracy"] > best_accuracy: best_accuracy = validation["accuracy"] best = copy.deepcopy(model.state_dict()) model.load_state_dict(best) return model def train_student( name: str, model: BitMLP, teacher: BitMLP, train_loader: DataLoader, validation_loader: DataLoader, ) -> BitMLP: optimizer = torch.optim.AdamW(model.parameters(), lr=1.5e-3, weight_decay=2e-5) best = copy.deepcopy(model.state_dict()) best_accuracy = 0.0 temperature = 2.5 teacher.eval() for epoch in range(1, 151): model.train() for images, labels in train_loader: logits = model(images) with torch.no_grad(): teacher_logits = teacher(images) hard = F.cross_entropy(logits, labels) soft = F.kl_div( F.log_softmax(logits / temperature, dim=1), F.softmax(teacher_logits / temperature, dim=1), reduction="batchmean", ) * temperature**2 loss = 0.45 * hard + 0.55 * soft optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() if epoch % 5 == 0: validation = evaluate(model, validation_loader) trackio.log( { f"{name}_epoch": epoch, f"{name}_validation_accuracy": validation["accuracy"], } ) if validation["accuracy"] > best_accuracy: best_accuracy = validation["accuracy"] best = copy.deepcopy(model.state_dict()) model.load_state_dict(best) return model def main() -> None: seed_everything(2043) digits = load_digits() images = (digits.images / 16.0).astype(np.float32) labels = digits.target.astype(np.int64) indices = np.arange(len(images)) train_indices, test_indices = train_test_split( indices, test_size=0.25, random_state=2043, stratify=labels ) train_indices, validation_indices = train_test_split( train_indices, test_size=0.18, random_state=3043, stratify=labels[train_indices], ) train_loader = make_loader( images[train_indices], labels[train_indices], shuffle=True, seed=2043 ) validation_loader = make_loader( images[validation_indices], labels[validation_indices], shuffle=False, seed=3043, ) test_loader = make_loader( images[test_indices], labels[test_indices], shuffle=False, seed=4043 ) teacher = BitMLP("fp32") binary = BitMLP("binary") ternary = BitMLP("ternary") trackio.init( project="bitforge-1bit", name="binary-ternary-distillation-v1", config={ "parameters_per_variant": parameter_count(teacher), "teacher_epochs": 100, "student_epochs": 150, "binary_matrix_weight_bits": 1, }, ) teacher = train_teacher(teacher, train_loader, validation_loader) binary = train_student( "binary", binary, teacher, train_loader, validation_loader ) ternary = train_student( "ternary", ternary, teacher, train_loader, validation_loader ) ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) save_file(teacher.state_dict(), ARTIFACT_DIR / "fp32.safetensors") save_file(binary.state_dict(), ARTIFACT_DIR / "binary_qat.safetensors") save_file(ternary.state_dict(), ARTIFACT_DIR / "ternary_qat.safetensors") packing = pack_binary_model(binary, ARTIFACT_DIR / "binary_weights.npz") fp32_payload_bytes = parameter_count(teacher) * 4 results = { "benchmark": "BitForge 1-bit", "parameters_per_variant": parameter_count(teacher), "matrix_weight_count": int( teacher.hidden.weight.numel() + teacher.output.weight.numel() ), "test": { "fp32": evaluate(teacher, test_loader), "binary_weight": evaluate(binary, test_loader), "ternary_weight": evaluate(ternary, test_loader), }, "storage": { "fp32_parameter_payload_bytes": fp32_payload_bytes, **packing, "measured_payload_compression": fp32_payload_bytes / packing["packed_payload_bytes"], }, "precision_boundary": { "matrix_weights": "one packed bit in binary variant", "scales": "float32 per output channel", "biases": "float32", "activations": "float32", }, } (ARTIFACT_DIR / "evaluation.json").write_text( json.dumps(results, indent=2), encoding="utf-8" ) DATA_DIR.mkdir(parents=True, exist_ok=True) pd.DataFrame( { "source_index": indices, "label": labels, "split": np.select( [ np.isin(indices, train_indices), np.isin(indices, validation_indices), ], ["train", "validation"], default="test", ), } ).to_parquet(DATA_DIR / "split_manifest.parquet", index=False) trackio.log( { "fp32_test_accuracy": results["test"]["fp32"]["accuracy"], "binary_test_accuracy": results["test"]["binary_weight"]["accuracy"], "ternary_test_accuracy": results["test"]["ternary_weight"]["accuracy"], "binary_payload_compression": results["storage"][ "measured_payload_compression" ], } ) trackio.finish() print(json.dumps(results, indent=2)) if __name__ == "__main__": main()