"""project 训练侧:用真实 MNIST 训练「单图多对象检测」大模型 → 函数化 → 导出产物。
完整链路(本项目要做的实质对比):
路线 A(传统):训练 → 成熟模型 → 部署(Rust 读 model.json 稠密前向)
路线 B(函数化):训练 → 成熟模型 → 函数化 → 生成物 → 部署(Rust 读
functional.json 函数前向)
任务:真实 MNIST「单图多对象检测」(YOLO 式固定锚框网格)——
- 每张 28×28 画布随机放 1~2 个数字(各缩放到 ~8px),各落到 2×2 网格的不同格
- 输出:S×S 网格,每格预测 [objectness(1) + 类别 one-hot(10) + bbox 偏移(4)] = 15 维
- 大模型骨干 MLP:784 → 4096 → 2048 → 1024,网格头 → 4×15=60
- 函数化对象:fc2(4096→2048)宽层,冗余足、函数化收益大
产出(artifacts/):
- model.json :成熟模型完整权重(路线 A 稠密部署用)
- functional.json :函数化生成物(fc2 层函数化,路线 B 用)
- stats.json :Python 侧四维 + 精度实测
"""
import json
import os
import struct
import sys
import numpy as np
import torch
import torch.nn as nn
HERE = os.path.dirname(os.path.abspath(__file__))
ART = os.path.join(HERE, 'artifacts')
_PY_EXAMPLES = os.path.abspath(os.path.join(HERE, '..', 'python'))
_SRC = os.path.abspath(os.path.join(HERE, '..', '..', 'src'))
for _p in (_PY_EXAMPLES, _SRC):
if _p not in sys.path:
sys.path.insert(0, _p)
import nrfunc
from _common import load_mnist
IMG = 28
DIM = IMG * IMG
GRID = 2
CELL = IMG // GRID
N_CLASS = 10
HEAD_PER_CELL = 1 + N_CLASS + 4
HEAD_DIM = GRID * GRID * HEAD_PER_CELL
def make_detection(x_mnist, y_mnist, seed=0, canvas=IMG, grid=GRID):
"""把 MNIST 造「单图多对象检测」:每张画布放 1~4 个数字,各落不同格。
返回 (x, grid_label):
x:(n, 784) 28×28 画布,含 1~4 个缩小的数字
grid_label:(n, GRID*GRID*15) 每格 [objectness, 10类one-hot, bbox偏移4]
"""
rng = np.random.default_rng(seed)
n = x_mnist.shape[0]
out = np.zeros((n, DIM), dtype=np.float32)
label = np.zeros((n, GRID * GRID * HEAD_PER_CELL), dtype=np.float32)
scale = 8
cell = canvas // grid
for i in range(n):
img = x_mnist[i].reshape(IMG, IMG)
ys, xs = np.where(img > 0.1)
if len(xs) == 0:
continue
n_obj = int(rng.integers(1, 3))
cells = rng.choice(grid * grid, size=n_obj, replace=False)
for ci, cell_id in enumerate(cells):
if ci == 0:
digit_img = img
true_cls = int(y_mnist[i])
else:
j = int(rng.integers(0, len(x_mnist)))
ys_j, xs_j = np.where(x_mnist[j].reshape(IMG, IMG) > 0.1)
if len(xs_j) == 0:
continue
dj = x_mnist[j].reshape(IMG, IMG)
dj = dj[ys_j.min():ys_j.max() + 1, xs_j.min():xs_j.max() + 1]
digit_img = dj
true_cls = int(y_mnist[j])
d_ys, d_xs = np.where(digit_img > 0.1)
if len(d_xs) == 0:
continue
digit = digit_img[d_ys.min():d_ys.max() + 1, d_xs.min():d_xs.max() + 1]
dh, dw = digit.shape
yy = np.linspace(0, dh - 1, scale).round().astype(int)
xx = np.linspace(0, dw - 1, scale).round().astype(int)
digit_rs = digit[yy][:, xx]
gy, gx = cell_id // grid, cell_id % grid
off = cell - scale
oy = int(rng.integers(0, off + 1))
ox = int(rng.integers(0, off + 1))
py = gy * cell + oy
px = gx * cell + ox
img_buf = out[i].reshape(canvas, canvas)
img_buf[py:py + scale, px:px + scale] = np.maximum(
img_buf[py:py + scale, px:px + scale], digit_rs)
base = cell_id * HEAD_PER_CELL
label[i, base] = 1.0
label[i, base + 1 + true_cls] = 1.0
label[i, base + 1 + N_CLASS + 0] = (px - gx * cell) / cell
label[i, base + 1 + N_CLASS + 1] = (py - gy * cell) / cell
label[i, base + 1 + N_CLASS + 2] = scale / cell
label[i, base + 1 + N_CLASS + 3] = scale / cell
return out, label
class DetectNet(nn.Module):
"""单图多对象检测大模型:骨干 784→4096→2048→1024 + 网格头 → 60。"""
def __init__(self, n_in=DIM, h1=4096, h2=2048, h3=1024, grid=GRID):
super().__init__()
self.fc1 = nn.Linear(n_in, h1)
self.fc2 = nn.Linear(h1, h2)
self.fc3 = nn.Linear(h2, h3)
self.head = nn.Linear(h3, grid * grid * HEAD_PER_CELL)
def forward(self, x):
z = torch.relu(self.fc1(x))
z = torch.relu(self.fc2(z))
z = torch.relu(self.fc3(z))
return self.head(z)
def train_model(model, x, label, epochs=20, batch=256, lr=1e-3, seed=0, dev='cpu'):
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
model.apply(lambda m: m.reset_parameters()
if hasattr(m, 'reset_parameters') else None)
model = model.to(dev)
xt = torch.as_tensor(x, device=dev)
lt = torch.as_tensor(label, device=dev)
opt = torch.optim.Adam(model.parameters(), lr=lr)
bce = nn.BCEWithLogitsLoss()
n = len(xt)
GRID2 = GRID * GRID
for e in range(1, epochs + 1):
perm = torch.randperm(n, device=dev)
model.train()
tl = 0.0
for b in range(0, n, batch):
ids = perm[b:b + batch]
out = model(xt[ids])
B = out.shape[0]
out = out.view(B, GRID2, HEAD_PER_CELL)
obj_logit = out[:, :, 0]
obj_tgt = lt[ids].view(B, GRID2, HEAD_PER_CELL)[:, :, 0]
loss_obj = bce(obj_logit, obj_tgt)
cls_logit = out[:, :, 1:1 + N_CLASS]
cls_tgt = lt[ids].view(B, GRID2, HEAD_PER_CELL)[:, :, 1:1 + N_CLASS]
mask = obj_tgt > 0.5
loss_cls = 0.0
if mask.any():
loss_cls = nn.functional.cross_entropy(
cls_logit[mask], cls_tgt[mask].argmax(1))
box_pred = out[:, :, 1 + N_CLASS:]
box_tgt = lt[ids].view(B, GRID2, HEAD_PER_CELL)[:, :, 1 + N_CLASS:]
loss_box = 0.0
if mask.any():
loss_box = nn.functional.mse_loss(box_pred[mask], box_tgt[mask])
loss = loss_obj + loss_cls + 5.0 * loss_box
opt.zero_grad()
loss.backward()
opt.step()
tl += loss.item() * B
if e % 5 == 0 or e == epochs:
print(f' epoch {e:2d}/{epochs} loss={tl / n:.4f}')
model.eval()
return model
@torch.no_grad()
def evaluate(model, x, label, dev='cpu'):
"""评估:分类准确率(有对象格)+ 对象检出 recall(objectness 是否命中)。"""
xt = torch.as_tensor(x, device=dev)
lt = torch.as_tensor(label, device=dev)
out = model(xt).view(len(xt), GRID * GRID, HEAD_PER_CELL)
obj_pred = (torch.sigmoid(out[:, :, 0]) > 0.5).float()
obj_tgt = lt.view(len(xt), GRID * GRID, HEAD_PER_CELL)[:, :, 0]
has_obj = obj_tgt > 0.5
recall = (obj_pred[has_obj] == 1).float().mean().item() if has_obj.any() else 1.0
cls_pred = out[:, :, 1:1 + N_CLASS].argmax(-1)
cls_tgt = lt.view(len(xt), GRID * GRID, HEAD_PER_CELL)[:, :, 1:1 + N_CLASS].argmax(-1)
hit = has_obj & (obj_pred == 1)
cls_acc = (cls_pred[hit] == cls_tgt[hit]).float().mean().item() if hit.any() else 0.0
return float(cls_acc), float(recall)
def _m(layer):
"""Linear 层权重导出为 numpy(含 bias 尾维),返回 (n_out, n_in+1)。"""
w = layer.weight.detach().cpu().numpy()
b = layer.bias.detach().cpu().numpy().reshape(-1, 1)
return np.hstack([w, b])
def _pack_dense_layers(layers, bits=32):
"""把多个稠密层权重([(name, (n_out,n_in) ndarray), ...])打成二进制块。
格式(小端):
n_layers(4B) + 每层 [n_out(4B) + n_in(4B) + bits(1B) + 权重]
bits=32:直存 f32[n_out*n_in]
bits=8/4:per-array 对称量化(与 nrfunc.io.to_bytes 同口径),
存 scale(1 × f32) + 打包 int8/int4 字节
"""
out = bytearray()
out += struct.pack('<i', len(layers))
for _, w in layers:
w = np.asarray(w, dtype=np.float32).astype('<f4')
n_out, n_in = w.shape
out += struct.pack('<ii', n_out, n_in)
if bits == 32:
out += struct.pack('<B', 32)
out += w.tobytes()
elif bits in (8, 4):
out += struct.pack('<B', bits)
packed, scale = _quantize(w, bits)
out += packed
out += np.array([scale], dtype='<f4').tobytes()
else:
raise ValueError(f'bits 仅支持 32/8/4,收到 {bits}')
return bytes(out)
def _quantize(w, bits):
"""per-array 对称量化(与 nrfunc.io.to_bytes 的 _q 同口径:单个全局 scale)。
返回 (packed_bytes, scale):packed 为 int8/int4 打包字节,scale 为标量。
"""
w = w.astype(np.float64)
qmax = 2 ** (bits - 1) - 1
amax = float(np.abs(w).max()) if w.size else 0.0
scale = max(amax, 1e-12) / qmax
q = np.round(w / scale).clip(-qmax, qmax).astype(np.int16)
off = (q + qmax).astype(np.uint16)
flat = off.flatten()
if bits == 8:
packed = flat.astype(np.uint8).tobytes()
elif bits == 4:
if flat.size % 2:
flat = np.concatenate([flat, np.zeros(1, dtype=np.uint16)])
lo = flat[0::2].astype(np.uint8)
hi = flat[1::2].astype(np.uint8)
packed = (lo | (hi << 4)).tobytes()
else:
raise ValueError(f'量化仅支持 8/4 bit,收到 {bits}')
return packed, float(scale)
DENSE_BITS = 8
FUNC_BITS = 8
def export_dense(model, path, bits=DENSE_BITS):
"""路线 A:成熟模型全距密权重 → 二进制(极限:int8 量化)。层序固定 fc1/fc2/fc3/head。"""
layers = [
('fc1', _m(model.fc1)), ('fc2', _m(model.fc2)),
('fc3', _m(model.fc3)), ('head', _m(model.head)),
]
with open(path, 'wb') as f:
f.write(_pack_dense_layers(layers, bits=bits))
def export_functional(model, res, path, bits=FUNC_BITS):
"""路线 B:函数化生成物 → 二进制(极限:函数参数 int8 量化)。
格式:fc1/fc3/head 三层距密权重块(int8)+ fc2 区域函数块(NRFN 二进制,to_bytes bits)。
前部 = _pack_dense_layers([fc1, fc3, head], bits)
后部 = func_block_len(8B) + to_bytes(res, bits)
"""
from nrfunc.io import to_bytes
dense = _pack_dense_layers([
('fc1', _m(model.fc1)), ('fc3', _m(model.fc3)), ('head', _m(model.head)),
], bits=bits)
func_blob = to_bytes(res, bits=bits)
with open(path, 'wb') as f:
f.write(dense)
f.write(struct.pack('<Q', len(func_blob)))
f.write(func_blob)
def main():
os.makedirs(ART, exist_ok=True)
dev = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'设备:{dev}')
tr_x, tr_y, te_x, te_y = load_mnist()
x_tr, l_tr = make_detection(tr_x, tr_y, seed=0)
x_te, l_te = make_detection(te_x, te_y, seed=1)
print(f'数据:train={len(x_tr)} test={len(x_te)},输入 28×28={DIM},'
f'任务=多对象检测({GRID}×{GRID}网格,每格1对象,最多2对象)')
model = DetectNet()
n_param = sum(p.numel() for p in model.parameters())
print(f'模型参数:{n_param / 1e6:.2f}M')
train_model(model, x_tr, l_tr, dev=dev)
cls_before, recall_before = evaluate(model, x_te, l_te, dev=dev)
print(f'成熟模型:cls acc={cls_before * 100:.2f}% '
f'检测 recall={recall_before * 100:.2f}%')
print('\n--- 量化方案掉点评估(int8)---')
layers_map = [
('fc1', model.fc1), ('fc2', model.fc2), ('fc3', model.fc3), ('head', model.head),
]
def _quant_replace(mode):
backup = {}
for name, layer in layers_map:
w = layer.weight.data
backup[name] = w.clone()
wn = w.cpu().numpy()
if mode == 'array':
amax = np.abs(wn).max()
scale = amax / 127.0
q = np.round(wn / scale).clip(-127, 127) * scale
else:
amax = np.abs(wn).max(axis=1, keepdims=True)
scale = np.maximum(amax, 1e-12) / 127.0
q = np.round(wn / scale).clip(-127, 127) * scale
layer.weight.data.copy_(torch.as_tensor(q, dtype=w.dtype, device=w.device))
return backup
def _restore(backup):
for name in backup:
layer = dict((n, l) for n, l in layers_map)[name]
layer.weight.data.copy_(backup[name])
for mode in ('array', 'row'):
bk = _quant_replace(mode)
acc, _ = evaluate(model, x_te, l_te, dev=dev)
_restore(bk)
drop = (cls_before - acc) * 100
print(f' per-{mode} 量化:cls acc={acc*100:.2f}% 掉点={drop:+.2f}pt')
fc2_w = _m(model.fc2)
print(f'函数化对象:fc2 {fc2_w.shape}({fc2_w.size} 参数)')
def eval_fn(result):
if result is None:
return False
recon = nrfunc.reconstruct(result)
with torch.no_grad():
bw = model.fc2.weight.data.clone()
bb = model.fc2.bias.data.clone()
model.fc2.weight.copy_(torch.as_tensor(
recon[:, :-1], dtype=torch.float32, device=dev))
model.fc2.bias.copy_(torch.as_tensor(
recon[:, -1], dtype=torch.float32, device=dev))
acc, _ = evaluate(model, x_te, l_te, dev=dev)
model.fc2.weight.copy_(bw)
model.fc2.bias.copy_(bb)
return (cls_before - acc) * 100 < 3.0
alloc = nrfunc.auto_alloc(fc2_w, signal='auto', eval_fn=eval_fn)
print(f'auto_alloc 决策:{alloc["decision"]} order={alloc["order"]} '
f'K={alloc["K"]} r={alloc["r"]},内存省 {alloc["saving"] * 100:.1f}%')
if alloc['result'] is not None:
res = alloc['result']
recon = nrfunc.reconstruct(res)
with torch.no_grad():
model.fc2.weight.copy_(torch.as_tensor(
recon[:, :-1], dtype=torch.float32, device=dev))
model.fc2.bias.copy_(torch.as_tensor(
recon[:, -1], dtype=torch.float32, device=dev))
cls_after, recall_after = evaluate(model, x_te, l_te, dev=dev)
print(f'函数化后:cls acc={cls_after * 100:.2f}% '
f'recall={recall_after * 100:.2f}% 掉点={(cls_before - cls_after) * 100:.2f}pt')
else:
res = None
cls_after, recall_after = cls_before, recall_before
print('函数化被跳过,精度无变化')
export_dense(model, os.path.join(ART, 'model.bin'))
if res is not None:
export_functional(model, res, os.path.join(ART, 'functional.bin'))
vol_dense = os.path.getsize(os.path.join(ART, 'model.bin'))
vol_func = (os.path.getsize(os.path.join(ART, 'functional.bin'))
if res is not None else vol_dense)
stats = {
'task': 'MNIST 多对象检测(YOLO式 2×2 网格,每格1对象,最多2对象)',
'n_input': DIM,
'n_fc1': model.fc1.out_features,
'n_fc2': model.fc2.out_features,
'n_fc3': model.fc3.out_features,
'n_head': model.head.out_features,
'n_param': n_param,
'grid': GRID, 'n_class': N_CLASS, 'n_per_cell': HEAD_PER_CELL,
'func_layer': 'fc2',
'cls_acc_before': cls_before, 'cls_acc_after': cls_after,
'recall_before': recall_before, 'recall_after': recall_after,
'drop_pt': round((cls_before - cls_after) * 100, 4),
'auto_alloc_decision': alloc['decision'],
'auto_alloc_order': alloc['order'],
'auto_alloc_K': alloc['K'],
'auto_alloc_r': alloc['r'],
'auto_alloc_signal': alloc['signal'],
'auto_alloc_probed_signal': alloc['probed_signal'],
'vol_dense_bytes': vol_dense,
'vol_functional_bytes': vol_func,
}
with open(os.path.join(ART, 'stats.json'), 'w', encoding='utf-8') as f:
json.dump(stats, f, indent=2, ensure_ascii=False)
print('\n==== 训练侧完成 ====')
print(f'体积:dense {vol_dense}B vs functional {vol_func}B '
f'(省 {(1 - vol_func / vol_dense) * 100:.1f}%)')
if __name__ == '__main__':
main()