Horizon-Labs's picture
Multilingual hate speech detector base v1.0
c1e5dc4 verified
Raw History Blame Contribute Delete
5.83 kB
"""Try int8 quantization settings for an exported ONNX classifier; keep the best one.
python train/quant_sweep.py MODEL_DIR EVAL_DIR -> MODEL_DIR/onnx/model_quantized.onnx (best config), sweep.json
Agreement is measured against the fp32 ONNX model on up to 400 real eval texts.
"""
import glob, json, os, sys, time
import numpy as np, pandas as pd, onnx, onnxruntime as ort
from onnxruntime.quantization import quantize_dynamic, QuantType
from transformers import AutoTokenizer
d, ev = sys.argv[1], sys.argv[2]
tok = AutoTokenizer.from_pretrained(d)
texts = []
for p in sorted(glob.glob(f"{ev}/*.parquet")):
df = pd.read_parquet(p)
texts += df.sample(min(len(df), int(os.environ.get("QN", 30))), random_state=0).text.str[:3000].tolist()
texts = texts[:int(os.environ.get("QMAX", 400))]
MC = bool(os.environ.get("MULTICLASS")) # language identifier: compare the argmax label instead of a 0.5 threshold
so = ort.SessionOptions(); so.intra_op_num_threads = 8
def probs(path):
s = ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
out = []
t = time.time()
for x in texts:
e = tok(x, truncation=True, max_length=1024, return_tensors="np")
lo = s.run(None, {"input_ids": e["input_ids"], "attention_mask": e["attention_mask"]})[0][0]
# MULTILABEL=1 (content-safety guard): score = sigmoid of logit 0 ("unsafe"); else 2-class softmax P(label 1)
if MC:
out.append(float(np.argmax(lo))); continue
# MULTILABEL=all (emotion model): all sigmoid outputs; a text agrees only if every label agrees at 0.5
if os.environ.get("MULTILABEL") == "all":
out.append(1 / (1 + np.exp(-lo))); continue
out.append(1 / (1 + np.exp(-lo[0])) if os.environ.get("MULTILABEL") else 1 / (1 + np.exp(lo[0] - lo[1])))
return np.array(out), (time.time() - t) / len(texts)
agree_bin = lambda p, ref: ((p > 0.5) == (ref > 0.5)).reshape(len(p), -1).all(1).mean()
ref, t_ref = probs(f"{d}/onnx/model.onnx")
m = onnx.load(f"{d}/onnx/model.onnx", load_external_data=False)
names = [n.name for n in m.graph.node]
matmuls = [n.name for n in m.graph.node if n.op_type in ("MatMul", "Gemm")]
head = [n for n in matmuls if any(k in n.lower() for k in ("classifier", "head", "dense"))]
print(len(matmuls), "matmuls; head-like:", head[:10], flush=True)
# Activation-quantizing configs (dynamic MatMul int8) all failed on mmBERT (agreement <= 94.5%, 2026-09-23):
# ModernBERT activations have outliers. Weight-only schemes keep activations in fp32.
configs = {
"gather_only": dict(op_types_to_quantize=["Gather"]),
}
res = {"fp32_ms": t_ref * 1000}
best = None
for name, kw in configs.items():
out = f"{d}/onnx/q_{name}.onnx"
kw = dict(kw); wt = kw.pop("weight_type", QuantType.QInt8)
try:
quantize_dynamic(f"{d}/onnx/model.onnx", out, weight_type=wt, **kw)
p, t = probs(out)
except Exception as e:
print(name, "failed", e, flush=True); continue
r = dict(max_diff=float(np.abs(p - ref).max()), mean_diff=float(np.abs(p - ref).mean()),
agree=float((p == ref).mean() if MC else agree_bin(p, ref)), ms=t * 1000, mb=os.path.getsize(out) / 1e6)
res[name] = r
print(name, r, flush=True)
if best is None or r["mean_diff"] < res[best]["mean_diff"]:
best = name
# gather int8 + MatMul 4-bit weight-only (block-wise), activations fp32
try:
from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer
for bits_name, src in [("gather_int8_matmul_q4", f"{d}/onnx/q_gather_only.onnx")]:
if not os.path.exists(src):
continue
mm = onnx.load(src)
q = MatMulNBitsQuantizer(mm, block_size=32, is_symmetric=True)
q.process()
out = f"{d}/onnx/q_{bits_name}.onnx"
q.model.save_model_to_file(out, use_external_data_format=False)
p, t = probs(out)
r = dict(max_diff=float(np.abs(p - ref).max()), mean_diff=float(np.abs(p - ref).mean()),
agree=float((p == ref).mean() if MC else agree_bin(p, ref)), ms=t * 1000, mb=os.path.getsize(out) / 1e6)
res[bits_name] = r; print(bits_name, r, flush=True)
# GATHER_ONLY=1: never pick the q4 variant (rejected for hallucination-guard-small: mean |p diff| .033)
if r["agree"] >= 0.995 and r["mb"] < res[best]["mb"] and not os.environ.get("GATHER_ONLY"):
best = bits_name
except Exception as e:
print("nbits failed", e, flush=True)
# fp16 (for WebGPU / GPU users), saved separately as model_fp16.onnx
try:
from onnxconverter_common import float16
m16 = float16.convert_float_to_float16(onnx.load(f"{d}/onnx/model.onnx"), keep_io_types=True)
onnx.save(m16, f"{d}/onnx/model_fp16.onnx")
p, t = probs(f"{d}/onnx/model_fp16.onnx")
res["fp16"] = dict(max_diff=float(np.abs(p - ref).max()), agree=float((p == ref).mean() if MC else agree_bin(p, ref)),
ms=t * 1000, mb=os.path.getsize(f"{d}/onnx/model_fp16.onnx") / 1e6)
print("fp16", res["fp16"], flush=True)
if res["fp16"]["agree"] < 0.995:
os.remove(f"{d}/onnx/model_fp16.onnx")
except Exception as e:
print("fp16 failed", e, flush=True)
json.dump(dict(res, best=best, n=len(texts)), open(f"{d}/onnx_sweep.json", "w"), indent=1)
print("best", best, res.get(best), flush=True)
MIN_AGREE = float(os.environ.get("MIN_AGREE", 0.995)) # decision agreement with fp32 needed to ship model_quantized.onnx
if best and res[best]["agree"] >= MIN_AGREE:
os.replace(f"{d}/onnx/q_{best}.onnx", f"{d}/onnx/model_quantized.onnx")
else:
print(f"no int8 config agrees >= {MIN_AGREE}; removing model_quantized.onnx", flush=True)
if os.path.exists(f"{d}/onnx/model_quantized.onnx"):
os.remove(f"{d}/onnx/model_quantized.onnx")
for f in glob.glob(f"{d}/onnx/q_*.onnx"):
os.remove(f)