Files
L'électron rare f55093d6fe
ESP-IDF CI / Host Tests (Unity) (push) Successful in 1m8s
CI / firmware-native (push) Successful in 2m57s
Rust Protection Tests / Cargo test (host) (push) Failing after 3m21s
ESP-IDF CI / ESP-IDF Build (v5.4) (push) Failing after 6m55s
ESP-IDF CI / Memory Budget Gate (push) Has been skipped
qa-cicd-environments / qa-kxkm-s3-build (push) Successful in 8m53s
qa-cicd-environments / qa-sim-host (push) Successful in 2m2s
qa-cicd-environments / qa-kxkm-s3-memory-budget (push) Successful in 11m17s
chore: import KXKM Batterie Parallelator
Context: the project archive (KXKM_Batterie_Parallelator-main) had
no git history locally; a fresh repository is needed to host it on
git.saillant.cc (electron/KXKM_Batterie_Parallelator).

Approach: initialize a new repo on branch main, stage the archive
content, and harden .gitignore before the first commit.

Changes:
- Import the full project tree: firmware/, firmware-idf/,
  firmware-rs/, iosApp/, kxkm-bmu-app/, kxkm-api/, hardware/,
  docs/, specs/, scripts/, models/, tests/
- Keep project dotfiles tracked despite the trailing '.*' ignore
  rule: .github/, .claude/, .superpowers/, .gitattributes,
  .markdownlint.json
- Extend .gitignore: firmware/src/credentials.h (local secrets,
  template kept), kxkm-bmu-app/**/build/ (66 MB compiled iOS
  framework), .remember/ (session data)

Impact: the project can now be maintained on the self-hosted Gitea
forge with a clean, secret-free initial history.
2026-07-04 12:32:28 +02:00

807 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""
quantize_tflite.py — Quantize the FPNN battery SOH model for edge deployment.
Pipeline:
1. Load PyTorch .pt checkpoint
2. Export to ONNX (if not already present alongside the .pt)
3. Convert ONNX -> TFLite (via onnx2tf or tf.lite.TFLiteConverter)
OR fallback: ONNX Runtime INT8 quantization (onnxruntime.quantization)
4. Apply INT8 post-training quantization using representative data
5. Save quantized model
6. Print size comparison (float32 vs INT8)
7. Validate: compare MAPE between original and quantized on test set
Install requirements (pick ONE path):
Path A — TFLite (preferred for microcontroller deployment):
pip install torch onnx onnx2tf tensorflow numpy pandas pyarrow
Path B — ONNX Runtime quantization (fallback, no TF dependency):
pip install torch onnx onnxruntime numpy pandas pyarrow
Usage:
python scripts/ml/quantize_tflite.py \\
--model models/fpnn_soh.pt \\
--features data/features.parquet \\
--output models/fpnn_soh_int8.tflite
# Force ONNX Runtime fallback even if TFLite is available:
python scripts/ml/quantize_tflite.py \\
--model models/fpnn_soh.pt \\
--features data/features.parquet \\
--output models/fpnn_soh_int8.onnx \\
--backend onnxrt
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from math import ceil
from pathlib import Path
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("quantize")
# ---------------------------------------------------------------------------
# Feature columns — must match train_fpnn.py exactly
# ---------------------------------------------------------------------------
FEATURE_COLS = [
"V_mean",
"V_std",
"I_mean",
"I_std",
"dV_dt",
"dI_dt",
"ah_cons",
"ah_charge",
"V_min",
"V_max",
"I_max",
"samples",
"R_internal",
]
# ---------------------------------------------------------------------------
# FPNN model definition — duplicated from train_fpnn.py to keep script
# self-contained (no relative imports needed).
# ---------------------------------------------------------------------------
class PolynomialExpansion(nn.Module):
"""Expand input features to include all monomials up to *degree*."""
def __init__(self, n_features: int, degree: int = 3):
super().__init__()
self.n_features = n_features
self.degree = degree
pairs = []
for i in range(n_features):
for j in range(i, n_features):
pairs.append((i, j))
self.register_buffer("pair_idx", torch.tensor(pairs, dtype=torch.long))
triples = []
if degree >= 3:
for i in range(n_features):
for j in range(i, n_features):
for k in range(j, n_features):
triples.append((i, j, k))
self.register_buffer("triple_idx", torch.tensor(triples, dtype=torch.long))
else:
self.register_buffer("triple_idx", torch.zeros(0, 3, dtype=torch.long))
self.out_features = n_features + len(pairs) + len(triples)
def forward(self, x: torch.Tensor) -> torch.Tensor:
parts = [x]
a = x[:, self.pair_idx[:, 0]]
b = x[:, self.pair_idx[:, 1]]
parts.append(a * b)
if self.degree >= 3 and self.triple_idx.shape[0] > 0:
a3 = x[:, self.triple_idx[:, 0]]
b3 = x[:, self.triple_idx[:, 1]]
c3 = x[:, self.triple_idx[:, 2]]
parts.append(a3 * b3 * c3)
return torch.cat(parts, dim=1)
class FakeQuantize(nn.Module):
"""Simulate INT8 quantization (STE). Must match train_fpnn.py definition."""
def __init__(self, num_bits: int = 8, momentum: float = 0.01):
super().__init__()
self.num_bits = num_bits
self.qmin = -(2 ** (num_bits - 1))
self.qmax = 2 ** (num_bits - 1) - 1
self.register_buffer("running_min", torch.tensor(0.0))
self.register_buffer("running_max", torch.tensor(0.0))
self.register_buffer("initialized", torch.tensor(False))
self.momentum = momentum
self.enabled = False # Always disabled during quantization export
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x # No-op at export time
class FPNN(nn.Module):
"""Feature-based Polynomial Neural Network."""
def __init__(self, n_features: int, hidden: int = 32, degree: int = 3,
dropout: float = 0.1, qat: bool = False):
super().__init__()
self.poly = PolynomialExpansion(n_features, degree)
self.fq_input = FakeQuantize() if qat else None
self.fq_hidden = FakeQuantize() if qat else None
self.net = nn.Sequential(
nn.Linear(self.poly.out_features, hidden),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden, 1),
nn.Sigmoid(),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.poly(x)
if self.fq_input is not None:
x = self.fq_input(x)
x = self.net[0](x)
x = self.net[1](x)
if self.fq_hidden is not None:
x = self.fq_hidden(x)
x = self.net[2](x)
x = self.net[3](x)
x = self.net[4](x)
return x.squeeze(-1)
# ---------------------------------------------------------------------------
# Data loading (reuses train_fpnn.py logic for SOH proxy + normalisation)
# ---------------------------------------------------------------------------
def build_soh_proxy_voltage(df: pd.DataFrame) -> pd.Series:
grouped = df.groupby(["device", "channel"])["rest_voltage"]
vmin = grouped.transform("min")
vmax = grouped.transform("max")
span = (vmax - vmin).replace(0, 1.0)
return (df["rest_voltage"] - vmin) / span
def build_soh_proxy_capacity(df: pd.DataFrame) -> pd.Series:
grouped = df.groupby(["device", "channel"])["ah_cons"]
ah_max = grouped.transform("max").replace(0, 1.0)
return 1.0 - (df["ah_cons"] / ah_max)
def load_data(features_path: str, checkpoint: dict, return_metadata: bool = False) -> tuple:
"""Load features.parquet and prepare normalised arrays using checkpoint stats.
Returns (X_all, y_all) as float32 numpy arrays, normalised with the
same means/stds that were used during training.
"""
log.info("Loading features from %s", features_path)
df = pd.read_parquet(features_path)
soh_mode = checkpoint.get("soh_mode", "voltage")
if soh_mode == "capacity":
df["soh_proxy"] = build_soh_proxy_capacity(df)
else:
df["soh_proxy"] = build_soh_proxy_voltage(df)
required = FEATURE_COLS + ["soh_proxy"]
df = df.dropna(subset=required)
log.info("Usable rows after dropna: %d", len(df))
if df.empty:
log.error("No valid rows in features file.")
sys.exit(1)
X = df[FEATURE_COLS].values.astype(np.float32)
y = df["soh_proxy"].values.astype(np.float32)
# Normalise using training-time statistics from checkpoint
means = np.array(checkpoint["feature_means"], dtype=np.float32)
stds = np.array(checkpoint["feature_stds"], dtype=np.float32)
stds[stds == 0] = 1.0
X = (X - means) / stds
metadata = df[["device", "channel"]].reset_index(drop=True)
if return_metadata:
return X, y, metadata
return X, y
def _parse_percentile_clip(percentile_clip: tuple[float, float] | None) -> tuple[float, float] | None:
if percentile_clip is None:
return None
low, high = percentile_clip
if not (0.0 <= low < high <= 100.0):
raise ValueError("percentile clip must satisfy 0 <= low < high <= 100")
return low, high
def _clip_calibration_features(X: np.ndarray, percentile_clip: tuple[float, float] | None) -> np.ndarray:
parsed = _parse_percentile_clip(percentile_clip)
if parsed is None or len(X) == 0:
return X
low, high = parsed
bounds = np.percentile(X, [low, high], axis=0)
return np.clip(X, bounds[0], bounds[1]).astype(np.float32)
def _sample_random_indices(total_rows: int, n_samples: int, seed: int = 42) -> np.ndarray:
rng = np.random.default_rng(seed)
count = min(n_samples, total_rows)
return np.sort(rng.choice(total_rows, count, replace=False))
def _sample_stratified_indices(metadata: pd.DataFrame, n_samples: int, seed: int = 42) -> np.ndarray:
if metadata.empty:
return np.array([], dtype=np.int64)
rng = np.random.default_rng(seed)
indexed = metadata.copy()
indexed["_row"] = np.arange(len(indexed), dtype=np.int64)
grouped = indexed.groupby(["device", "channel"], dropna=False)["_row"].apply(list)
groups = [rows[:] for rows in grouped.tolist() if rows]
if not groups:
return np.array([], dtype=np.int64)
for rows in groups:
rng.shuffle(rows)
selected: list[int] = []
target = min(n_samples, len(indexed))
per_group = max(1, ceil(target / len(groups)))
for rows in groups:
take = min(per_group, len(rows), target - len(selected))
selected.extend(rows[:take])
if len(selected) >= target:
return np.array(sorted(selected[:target]), dtype=np.int64)
pending_groups = [rows[per_group:] for rows in groups]
while len(selected) < target:
progress = False
for rows in pending_groups:
if not rows:
continue
selected.append(rows.pop())
progress = True
if len(selected) >= target:
break
if not progress:
break
return np.array(sorted(selected[:target]), dtype=np.int64)
def build_calibration_data(
X_repr: np.ndarray,
metadata: pd.DataFrame | None,
n_samples: int,
strategy: str,
percentile_clip: tuple[float, float] | None,
) -> np.ndarray:
if len(X_repr) == 0:
return X_repr
if strategy == "stratified" and metadata is not None:
indices = _sample_stratified_indices(metadata.reset_index(drop=True), n_samples)
if len(indices) == 0:
indices = _sample_random_indices(len(X_repr), n_samples)
else:
indices = _sample_random_indices(len(X_repr), n_samples)
X_calib = X_repr[indices]
return _clip_calibration_features(X_calib, percentile_clip)
# ---------------------------------------------------------------------------
# ONNX export
# ---------------------------------------------------------------------------
def export_onnx(model: FPNN, n_features: int, onnx_path: Path) -> Path:
"""Export PyTorch model to ONNX if the file does not already exist."""
if onnx_path.exists():
log.info("ONNX file already exists: %s", onnx_path)
return onnx_path
log.info("Exporting to ONNX: %s", onnx_path)
model.eval()
dummy = torch.randn(1, n_features)
try:
torch.onnx.export(
model,
dummy,
str(onnx_path),
input_names=["features"],
output_names=["soh"],
dynamic_axes={"features": {0: "batch"}, "soh": {0: "batch"}},
opset_version=13,
dynamo=False,
)
except TypeError:
# Older PyTorch without dynamo kwarg
torch.onnx.export(
model,
dummy,
str(onnx_path),
input_names=["features"],
output_names=["soh"],
dynamic_axes={"features": {0: "batch"}, "soh": {0: "batch"}},
opset_version=13,
)
log.info("ONNX export done (%.1f KB)", onnx_path.stat().st_size / 1024)
return onnx_path
# ---------------------------------------------------------------------------
# Path A: ONNX -> TFLite via onnx2tf + TFLite converter
# ---------------------------------------------------------------------------
def quantize_tflite(onnx_path: Path, output_path: Path, X_calib: np.ndarray) -> Path:
"""Convert ONNX to TFLite with INT8 post-training quantization.
Uses onnx2tf to convert ONNX -> SavedModel, then tf.lite.TFLiteConverter
with a representative dataset for full INT8 quantization.
"""
import onnx2tf # noqa: F811
import tensorflow as tf
# Step 1: ONNX -> TF SavedModel
saved_model_dir = output_path.parent / "_tflite_savedmodel"
log.info("Converting ONNX -> SavedModel via onnx2tf ...")
onnx2tf.convert(
input_onnx_file_path=str(onnx_path),
output_folder_path=str(saved_model_dir),
non_verbose=True,
)
log.info("SavedModel written to %s", saved_model_dir)
# Step 2: SavedModel -> TFLite (float32, for size comparison)
converter_f32 = tf.lite.TFLiteConverter.from_saved_model(str(saved_model_dir))
tflite_f32 = converter_f32.convert()
f32_path = output_path.with_suffix(".f32.tflite")
f32_path.write_bytes(tflite_f32)
log.info("Float32 TFLite: %s (%.1f KB)", f32_path, len(tflite_f32) / 1024)
# Step 3: SavedModel -> TFLite INT8 with representative dataset
def representative_dataset():
for i in range(len(X_calib)):
yield [X_calib[i:i+1].astype(np.float32)]
converter_int8 = tf.lite.TFLiteConverter.from_saved_model(str(saved_model_dir))
converter_int8.optimizations = [tf.lite.Optimize.DEFAULT]
converter_int8.representative_dataset = representative_dataset
# Full integer quantization (input/output remain float for compatibility)
converter_int8.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter_int8.inference_input_type = tf.int8
converter_int8.inference_output_type = tf.float32
try:
tflite_int8 = converter_int8.convert()
except Exception as exc:
log.warning("Full INT8 failed (%s), falling back to dynamic range quantization", exc)
converter_int8 = tf.lite.TFLiteConverter.from_saved_model(str(saved_model_dir))
converter_int8.optimizations = [tf.lite.Optimize.DEFAULT]
converter_int8.representative_dataset = representative_dataset
tflite_int8 = converter_int8.convert()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(tflite_int8)
log.info("INT8 TFLite: %s (%.1f KB)", output_path, len(tflite_int8) / 1024)
# Cleanup SavedModel temp dir
import shutil
shutil.rmtree(saved_model_dir, ignore_errors=True)
return output_path
def validate_tflite(tflite_path: Path, X_test: np.ndarray, y_test: np.ndarray) -> dict:
"""Run TFLite inference and compute metrics."""
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path=str(tflite_path))
interpreter.allocate_tensors()
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
input_dtype = input_details[0]["dtype"]
input_scale = input_details[0].get("quantization_parameters", {}).get("scales", [])
input_zp = input_details[0].get("quantization_parameters", {}).get("zero_points", [])
predictions = []
for i in range(len(X_test)):
sample = X_test[i:i+1].astype(np.float32)
# Quantize input if the model expects int8
if input_dtype == np.int8 and len(input_scale) > 0 and input_scale[0] != 0:
sample = (sample / input_scale[0] + input_zp[0]).astype(np.int8)
interpreter.set_tensor(input_details[0]["index"], sample)
interpreter.invoke()
out = interpreter.get_tensor(output_details[0]["index"])
predictions.append(float(out.flatten()[0]))
y_pred = np.array(predictions, dtype=np.float32)
return _compute_metrics(y_test, y_pred)
# ---------------------------------------------------------------------------
# Path B: ONNX Runtime quantization (fallback)
# ---------------------------------------------------------------------------
def _collect_activation_ranges(
onnx_path: Path,
X_calib: np.ndarray,
percentile: tuple[float, float] = (0.1, 99.9),
) -> dict:
"""Run forward pass on calibration data, collect per-tensor activation ranges.
Uses percentile clipping to reduce the impact of outliers on quantization
ranges. Returns TensorQuantOverrides dict for quantize_static().
Target tensors (from FPNN ONNX graph):
/poly/Concat_output_0 — post polynomial expansion (104-dim, most critical)
/net.1/Relu_output_0 — post ReLU (64-dim)
"""
import onnxruntime as ort
import onnx
TARGET_TENSORS = ["/poly/Concat_output_0", "/net.1/Relu_output_0"]
model = onnx.load(str(onnx_path))
# Add target tensors as model outputs so we can read them
for tensor_name in TARGET_TENSORS:
model.graph.output.append(onnx.helper.make_tensor_value_info(tensor_name, onnx.TensorProto.FLOAT, None))
# Run inference to collect activations
sess = ort.InferenceSession(model.SerializeToString())
input_name = sess.get_inputs()[0].name
activations = {t: [] for t in TARGET_TENSORS}
for i in range(len(X_calib)):
results = sess.run(None, {input_name: X_calib[i:i+1].astype(np.float32)})
# Output order: original output + appended tensors
for j, tensor_name in enumerate(TARGET_TENSORS):
activations[tensor_name].append(results[1 + j].flatten())
overrides = {}
for tensor_name in TARGET_TENSORS:
vals = np.concatenate(activations[tensor_name])
lo, hi = np.percentile(vals, [percentile[0], percentile[1]])
overrides[tensor_name] = [{"rmin": np.float32(lo), "rmax": np.float32(hi)}]
actual_min, actual_max = vals.min(), vals.max()
log.info("Percentile range %s: [%.4f, %.4f] (abs: [%.4f, %.4f], shrink %.1f%%)",
tensor_name, lo, hi, actual_min, actual_max,
100.0 * (1.0 - (hi - lo) / (actual_max - actual_min + 1e-8)))
return overrides
def quantize_onnxrt(
onnx_path: Path,
output_path: Path,
X_calib: np.ndarray,
quant_format_name: str,
per_channel: bool,
qat_ranges: dict | None = None,
percentile_range_override: bool = True,
) -> Path:
"""Quantize ONNX model to INT8 using onnxruntime.quantization.
This is the fallback when TensorFlow/onnx2tf are not available.
Output is an INT8-quantized .onnx file.
When percentile_range_override is True, collects activation ranges via forward
pass with percentile clipping and injects them as TensorQuantOverrides.
"""
from onnxruntime.quantization import (
CalibrationDataReader,
QuantFormat,
QuantType,
quantize_static,
)
class BatteryCalibrationReader(CalibrationDataReader):
"""Feeds representative battery data for INT8 calibration."""
def __init__(self, X: np.ndarray):
self.X = X
self.pos = 0
def get_next(self):
if self.pos >= len(self.X):
return None
self.pos += 1
idx = self.pos - 1
return {"features": self.X[idx:idx+1].astype(np.float32)}
# Ensure output has .onnx extension for this path
if output_path.suffix == ".tflite":
output_path = output_path.with_suffix(".int8.onnx")
log.info("Quantizing ONNX model with onnxruntime (static INT8) ...")
calibration_reader = BatteryCalibrationReader(X_calib)
quant_format = QuantFormat.QDQ if quant_format_name == "qdq" else QuantFormat.QOperator
extra_options = {}
if percentile_range_override:
log.info("Collecting percentile activation ranges for key tensors ...")
overrides = _collect_activation_ranges(onnx_path, X_calib, percentile=(0.1, 99.9))
if overrides:
extra_options["TensorQuantOverrides"] = overrides
log.info("Applying percentile range override for %d tensors", len(overrides))
quantize_static(
model_input=str(onnx_path),
model_output=str(output_path),
calibration_data_reader=calibration_reader,
quant_format=quant_format,
per_channel=per_channel,
weight_type=QuantType.QInt8,
activation_type=QuantType.QInt8,
extra_options=extra_options if extra_options else None,
)
log.info("INT8 ONNX: %s (%.1f KB)", output_path, output_path.stat().st_size / 1024)
return output_path
def validate_onnxrt(model_path: Path, X_test: np.ndarray, y_test: np.ndarray) -> dict:
"""Run ONNX Runtime inference and compute metrics."""
import onnxruntime as ort
sess = ort.InferenceSession(str(model_path))
input_name = sess.get_inputs()[0].name
y_pred = sess.run(None, {input_name: X_test.astype(np.float32)})[0].flatten()
return _compute_metrics(y_test, y_pred)
# ---------------------------------------------------------------------------
# Metrics (mirrors train_fpnn.py)
# ---------------------------------------------------------------------------
def _compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
ss_res = np.sum((y_true - y_pred) ** 2)
ss_tot = np.sum((y_true - y_true.mean()) ** 2)
r2 = float(1.0 - ss_res / (ss_tot + 1e-8))
mask = y_true > 0.05
if mask.sum() > 0:
mape = float(np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100.0)
else:
mape = float("nan")
return {"MAPE": mape, "RMSE": rmse, "R2": r2}
# ---------------------------------------------------------------------------
# PyTorch reference inference (for validation baseline)
# ---------------------------------------------------------------------------
def validate_pytorch(model: FPNN, X_test: np.ndarray, y_test: np.ndarray) -> dict:
"""Run PyTorch inference and compute metrics (baseline)."""
model.eval()
with torch.no_grad():
y_pred = model(torch.from_numpy(X_test)).numpy()
return _compute_metrics(y_test, y_pred)
# ---------------------------------------------------------------------------
# Backend detection
# ---------------------------------------------------------------------------
def detect_backend() -> str:
"""Detect available conversion backend: 'tflite' or 'onnxrt'."""
try:
import onnx2tf # noqa: F401
import tensorflow # noqa: F401
return "tflite"
except ImportError:
pass
try:
import onnxruntime # noqa: F401
from onnxruntime.quantization import quantize_static # noqa: F401
return "onnxrt"
except ImportError:
pass
return "none"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Quantize FPNN battery SOH model to INT8 (TFLite or ONNX Runtime)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--model", default="models/fpnn_soh.pt",
help="Path to PyTorch checkpoint (.pt)",
)
parser.add_argument(
"--features", default="data/features.parquet",
help="Path to features.parquet for calibration + validation",
)
parser.add_argument(
"--output", default="models/fpnn_soh_int8.tflite",
help="Output path for quantized model",
)
parser.add_argument(
"--backend", choices=["tflite", "onnxrt", "auto"], default="auto",
help="Quantization backend: 'tflite' (ONNX->TFLite), 'onnxrt' (ONNX Runtime INT8), 'auto'",
)
parser.add_argument(
"--calib-samples", type=int, default=2000,
help="Number of representative calibration samples to use",
)
parser.add_argument(
"--calib-strategy", choices=["random", "stratified"], default="stratified",
help="Calibration sampling strategy",
)
parser.add_argument(
"--percentile-clip", nargs=2, type=float, metavar=("LOW", "HIGH"), default=None,
help="Optional percentile clipping applied to calibration features only",
)
parser.add_argument(
"--quant-format", choices=["qdq", "qoperator"], default="qdq",
help="ONNX Runtime quantization graph format",
)
parser.add_argument(
"--per-tensor", action="store_false", dest="per_channel",
help="Disable per-channel quantization for ONNX Runtime backend",
)
parser.set_defaults(per_channel=True)
args = parser.parse_args()
t0 = time.time()
# --- 1. Load checkpoint ---
model_path = Path(args.model)
if not model_path.exists():
log.error("Model not found: %s", model_path)
sys.exit(1)
log.info("Loading checkpoint: %s", model_path)
checkpoint = torch.load(model_path, map_location="cpu", weights_only=False)
n_features = checkpoint["n_features"]
hidden = checkpoint["hidden"]
degree = checkpoint["degree"]
dropout = checkpoint.get("dropout", 0.1)
qat = checkpoint.get("qat", False)
model = FPNN(n_features, hidden=hidden, degree=degree, dropout=dropout, qat=qat)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()
n_params = sum(p.numel() for p in model.parameters())
log.info("FPNN loaded — degree=%d, hidden=%d, params=%d", degree, hidden, n_params)
# --- 2. Load data ---
features_path = Path(args.features)
if not features_path.exists():
log.error("Features file not found: %s", features_path)
sys.exit(1)
X_all, y_all, metadata = load_data(str(features_path), checkpoint, return_metadata=True)
# Use last 20% as test set, rest as calibration representative data
n_test = max(1, int(0.2 * len(X_all)))
X_repr = X_all[:-n_test]
X_test = X_all[-n_test:]
y_test = y_all[-n_test:]
metadata_repr = metadata.iloc[:-n_test].reset_index(drop=True)
log.info("Calibration samples: %d, Test samples: %d", len(X_repr), len(X_test))
X_calib = build_calibration_data(
X_repr,
metadata_repr,
n_samples=args.calib_samples,
strategy=args.calib_strategy,
percentile_clip=tuple(args.percentile_clip) if args.percentile_clip else None,
)
log.info(
"Calibration config — strategy=%s, samples=%d, percentile_clip=%s, per_channel=%s, quant_format=%s",
args.calib_strategy,
len(X_calib),
tuple(args.percentile_clip) if args.percentile_clip else None,
args.per_channel,
args.quant_format,
)
# --- 3. ONNX export ---
onnx_path = model_path.with_suffix(".onnx")
export_onnx(model, n_features, onnx_path)
# --- 4. Detect backend ---
backend = args.backend
if backend == "auto":
backend = detect_backend()
if backend == "none":
log.error(
"No quantization backend available.\n"
"Install one of:\n"
" Path A (TFLite): pip install onnx2tf tensorflow\n"
" Path B (ONNX RT): pip install onnxruntime\n"
)
sys.exit(1)
log.info("Using backend: %s", backend)
# --- 5. Quantize ---
output_path = Path(args.output)
if backend == "tflite":
quant_path = quantize_tflite(onnx_path, output_path, X_calib)
quant_metrics = validate_tflite(quant_path, X_test, y_test)
else:
quant_path = quantize_onnxrt(
onnx_path,
output_path,
X_calib,
quant_format_name=args.quant_format,
per_channel=args.per_channel,
)
quant_metrics = validate_onnxrt(quant_path, X_test, y_test)
# --- 6. PyTorch baseline metrics ---
pt_metrics = validate_pytorch(model, X_test, y_test)
# --- 7. Size comparison ---
pt_size = model_path.stat().st_size
onnx_size = onnx_path.stat().st_size
quant_size = quant_path.stat().st_size
elapsed = time.time() - t0
log.info("=" * 64)
log.info("FPNN Quantization Summary")
log.info("=" * 64)
log.info(" Backend: %s", backend)
log.info("")
log.info(" --- Model sizes ---")
log.info(" PyTorch (.pt): %7.1f KB", pt_size / 1024)
log.info(" ONNX (float32): %7.1f KB", onnx_size / 1024)
log.info(" Quantized INT8: %7.1f KB", quant_size / 1024)
log.info(" Compression: %.1fx (vs .pt) %.1fx (vs ONNX)",
pt_size / max(quant_size, 1), onnx_size / max(quant_size, 1))
log.info("")
log.info(" --- Accuracy (test set, %d samples) ---", len(X_test))
log.info(" %-18s MAPE %% RMSE R2", "Model")
log.info(" %-18s %6.2f %.6f %.4f", "PyTorch (float32)",
pt_metrics["MAPE"], pt_metrics["RMSE"], pt_metrics["R2"])
log.info(" %-18s %6.2f %.6f %.4f", "Quantized (INT8)",
quant_metrics["MAPE"], quant_metrics["RMSE"], quant_metrics["R2"])
mape_delta = quant_metrics["MAPE"] - pt_metrics["MAPE"]
log.info("")
log.info(" MAPE degradation: %+.2f pp", mape_delta)
if abs(mape_delta) < 1.0:
log.info(" -> Quantization quality: EXCELLENT (< 1pp MAPE loss)")
elif abs(mape_delta) < 3.0:
log.info(" -> Quantization quality: GOOD (< 3pp MAPE loss)")
else:
log.warning(" -> Quantization quality: DEGRADED (>= 3pp MAPE loss)")
log.info("")
log.info(" Output: %s", quant_path)
log.info(" Time: %.1f s", elapsed)
log.info("=" * 64)
if __name__ == "__main__":
main()