aiml
SearchMy learning
Menu

Lesson 11 of 20Module 4

Quantize a model and evaluate the regression

Convert real model linear layers to INT8 and compare development likelihood. Inspect the observed regression before accepting a serving candidate.

Prerequisites

Python, basic causal attention, and the preceding inference lessons.

By the end, you can

  • Quantize a model and evaluate the regression.
  • Explain the bottleneck, measurement boundary, and tradeoffs in this implementation.
  • Reject a converted candidate when the recorded development score regresses.
Runtime
Python 3.12+
Dependencies
Offline checks have no external imports; live model execution uses torch 2.14.1 and transformers 5.20.2

Lab code, tests and fixtures (ZIP, 109 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A service owner wants lower memory use without silently degrading code-assistant responses. Apply a real quantizer, preserve the baseline, and evaluate a candidate before treating lower precision as an optimization.

What you run. Fresh CPU INT8 model execution and recorded development-text likelihoods. No FP8, INT4, or NVFP4 device execution is claimed.

This lesson builds on reuse prompt prefixes without overloading a warm replica.

Evaluate the converted model

Quantization represents values with fewer bits and associated scales. Weight precision, activation precision, and KV precision are separate decisions. Supported kernels and conversion methods depend on the runtime and hardware.

The live experiment applies dynamic INT8 quantization to the model’s linear modules on CPU. It compares the likelihood of manually written development texts before and after conversion. Perplexity here summarizes next-token negative log likelihood; lower is better on these texts. It is not an assessment of code correctness or broad model quality.

Baseline modelINT8 conversionLikelihoodcomparisonweightsscoresBaseline modelINT8 conversionLikelihood comparisonweightsscores
Follow the named data through the downloadable program. The article identifies whether the result is measured execution, a controlled fixture, or a planning estimate.

quantize_live.py loads the pinned model, scores the same development texts, converts real linear modules, and records the new scores. It uses a pinned legacy PyTorch quantization API whose deprecation warning is preserved in the run notes; it is an investigation of a candidate, not a recommended new deployment API.

Inspect the quantized candidate

Download the lab from this page, extract it, and run these commands from its directory. The default command reads saved run records or runs the explicitly described local algorithm; it never downloads a model or calls a paid API.

Run the local analysis and checksTerminal · bash
python3 lab.py
python3 -m unittest -v test_lab.py
Read lab.py
lab.pySource · pythonDownload
"""Compare actual baseline and quantized-model development likelihood records offline."""

import json, math
from pathlib import Path


def compare_scores(baseline, candidate):
    if not baseline or len(baseline) != len(candidate):
        raise ValueError("Matched development cases are required")
    for a, b in zip(baseline, candidate):
        if a["text"] != b["text"] or a["predicted_tokens"] != b["predicted_tokens"]:
            raise ValueError("Cases or token denominators differ")
        if a["predicted_tokens"] < 1 or any(
            not math.isfinite(x["nll"]) or x["nll"] < 0 for x in [a, b]
        ):
            raise ValueError("Invalid likelihood data")
    tokens = sum(a["predicted_tokens"] for a in baseline)
    before = math.fsum(a["nll"] * a["predicted_tokens"] for a in baseline) / tokens
    after = math.fsum(a["nll"] * a["predicted_tokens"] for a in candidate) / tokens
    return {
        "predicted_tokens": tokens,
        "baseline_token_weighted_nll": before,
        "candidate_token_weighted_nll": after,
        "nll_increase": after - before,
        "likelihood_regression": after > before,
    }


def main():
    record = json.loads(Path("quantization-run.json").read_text())
    print(
        json.dumps(
            {
                "execution_mode": "offline analysis of fresh CPU INT8 model execution",
                "model_id": record["model_id"],
                "model_revision": record["model_revision"],
                "quantized_linear_modules": record["quantized_linear_modules"],
                "comparison": compare_scores(
                    record["baseline_fp32"]["cases"], record["candidate_int8"]["cases"]
                ),
                "limits": "Development likelihood only; no held-out task-quality, GPU performance or production acceptance.",
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()
Read quantize_live.py
quantize_live.pySource · pythonDownload
"""Real CPU INT8 dynamic quantization and development-text likelihood comparison."""

import argparse
import copy
import hashlib
import json
import math
import platform
import time
from pathlib import Path


def score(model, tokenizer, texts, torch):
    results = []
    with torch.inference_mode():
        for text in texts:
            ids = tokenizer(text, return_tensors="pt")["input_ids"]
            start = time.perf_counter()
            logits = model(ids, use_cache=False).logits
            loss = torch.nn.functional.cross_entropy(
                logits[:, :-1].float().reshape(-1, logits.shape[-1]),
                ids[:, 1:].reshape(-1),
            )
            results.append(
                {
                    "text": text,
                    "predicted_tokens": ids.shape[-1] - 1,
                    "nll": float(loss),
                    "elapsed_ms": round((time.perf_counter() - start) * 1000, 3),
                }
            )
    weighted = sum(r["nll"] * r["predicted_tokens"] for r in results) / sum(
        r["predicted_tokens"] for r in results
    )
    return {
        "cases": results,
        "token_weighted_nll": weighted,
        "perplexity": math.exp(weighted),
    }


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--model-path", required=True)
    p.add_argument("--output", required=True)
    a = p.parse_args()
    import torch, transformers
    from transformers import AutoModelForCausalLM, AutoTokenizer

    torch.set_num_threads(4)
    torch.manual_seed(0)
    tokenizer = AutoTokenizer.from_pretrained(a.model_path, local_files_only=True)
    model = AutoModelForCausalLM.from_pretrained(
        a.model_path,
        local_files_only=True,
        dtype=torch.float32,
        attn_implementation="eager",
    ).eval()
    texts = [
        "An inference service should release the request cache when a client cancels.",
        "A bounded queue rejects excess requests instead of allowing memory to grow without a limit.",
        "Measure time to first token separately from the time between later output tokens.",
    ]
    baseline = score(model, tokenizer, texts, torch)
    torch.backends.quantized.engine = "qnnpack"
    quantized = torch.ao.quantization.quantize_dynamic(
        model, {torch.nn.Linear}, dtype=torch.qint8, inplace=False
    ).eval()
    candidate = score(quantized, tokenizer, texts, torch)
    qmodules = sum(
        isinstance(m, torch.ao.nn.quantized.dynamic.Linear) for m in quantized.modules()
    )
    report = {
        "execution_mode": "fresh local CPU inference; dynamic INT8 Linear modules",
        "environment": {
            "python": platform.python_version(),
            "torch": torch.__version__,
            "transformers": transformers.__version__,
            "platform": platform.platform(),
            "quantized_engine": torch.backends.quantized.engine,
            "threads": torch.get_num_threads(),
        },
        "model_id": "Qwen/Qwen2.5-0.5B-Instruct",
        "model_revision": "7ae557604adf67be50417f59c2c2f167def9a775",
        "license": "Apache-2.0",
        "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        "quantized_linear_modules": qmodules,
        "baseline_fp32": baseline,
        "candidate_int8": candidate,
        "perplexity_delta": candidate["perplexity"] - baseline["perplexity"],
        "limits": "Three manually written development texts, not held-out task-quality evaluation. Timing includes forward and loss calculation. No GPU quantized kernels, FP8, INT4, or NVFP4 tested.",
    }
    Path(a.output).write_text(json.dumps(report, indent=2) + "\n")


if __name__ == "__main__":
    main()

The real converted model worsened the development-text likelihood score. That result rejects this particular candidate rather than proving that INT8 is generally unsuitable. A different supported quantizer and a proper task evaluation may lead to a different decision.

Inspect the complete local result
Recorded local resultRecorded output · jsonDownload
{
  "execution_mode": "offline analysis of fresh CPU INT8 model execution",
  "model_id": "Qwen/Qwen2.5-0.5B-Instruct",
  "model_revision": "7ae557604adf67be50417f59c2c2f167def9a775",
  "quantized_linear_modules": 169,
  "comparison": {
    "predicted_tokens": 41,
    "baseline_token_weighted_nll": 6.035825868932212,
    "candidate_token_weighted_nll": 7.285529497193127,
    "nll_increase": 1.2497036282609146,
    "likelihood_regression": true
  },
  "limits": "Development likelihood only; no held-out task-quality, GPU performance or production acceptance."
}
Inspect the actual execution record
Actual execution recordRecorded output · jsonDownload
{
  "execution_mode": "fresh local CPU inference; dynamic INT8 Linear modules",
  "environment": {
    "python": "3.12.8",
    "torch": "2.14.1",
    "transformers": "5.20.2",
    "platform": "macOS-27.0.1-arm64-arm-64bit",
    "quantized_engine": "qnnpack",
    "threads": 4
  },
  "model_id": "Qwen/Qwen2.5-0.5B-Instruct",
  "model_revision": "7ae557604adf67be50417f59c2c2f167def9a775",
  "license": "Apache-2.0",
  "source_sha256": "c4d751f71c0c4a409fe091000f640597b21f8a41cd8ef310f783f6ed4ade98ab",
  "quantized_linear_modules": 169,
  "baseline_fp32": {
    "cases": [
      {
        "text": "An inference service should release the request cache when a client cancels.",
        "predicted_tokens": 13,
        "nll": 6.5931525230407715,
        "elapsed_ms": 114.118
      },
      {
        "text": "A bounded queue rejects excess requests instead of allowing memory to grow without a limit.",
        "predicted_tokens": 15,
        "nll": 5.608316421508789,
        "elapsed_ms": 87.19
      },
      {
        "text": "Measure time to first token separately from the time between later output tokens.",
        "predicted_tokens": 13,
        "nll": 5.9717793464660645,
        "elapsed_ms": 70.992
      }
    ],
    "token_weighted_nll": 6.035825868932212,
    "perplexity": 418.14399921617934
  },
  "candidate_int8": {
    "cases": [
      {
        "text": "An inference service should release the request cache when a client cancels.",
        "predicted_tokens": 13,
        "nll": 7.135319709777832,
        "elapsed_ms": 554.379
      },
      {
        "text": "A bounded queue rejects excess requests instead of allowing memory to grow without a limit.",
        "predicted_tokens": 15,
        "nll": 7.456194877624512,
        "elapsed_ms": 71.903
      },
      {
        "text": "Measure time to first token separately from the time between later output tokens.",
        "predicted_tokens": 13,
        "nll": 7.2388176918029785,
        "elapsed_ms": 61.35
      }
    ],
    "token_weighted_nll": 7.285529497193127,
    "perplexity": 1459.033482494216
  },
  "perplexity_delta": 1040.8894832780368,
  "limits": "Three manually written development texts, not held-out task-quality evaluation. Timing includes forward and loss calculation. No GPU quantized kernels, FP8, INT4, or NVFP4 tested."
}
Saved output report for quantize a model and evaluate the regression; execution mode and values come from the linked raw result.

This image is an application-generated report of the saved program result, not a new inference run. Open the full-resolution report or read the raw result.

Reject an unacceptable candidate

A smaller representation is useful only if the runtime executes it efficiently and the application accepts its output quality. This experiment produced a regression; the candidate is not accepted. CPU quantization results do not establish FP8 or INT4 behavior on a GPU. FP8, INT4, and NVFP4 need separate supported implementations and evaluations; none was tested here.

Mismatched evaluation cases. The analyzer refuses to compare different texts or token denominators, because such a change would confound the quantization comparison.

Invalid likelihood data. A negative or nonfinite likelihood value is rejected. A valid higher candidate likelihood loss remains visible as a regression rather than being relabeled a pass.

Read the executable checks
Executable checksSource · pythonDownload
import unittest
from lab import compare_scores


class QuantizationEvidenceTests(unittest.TestCase):
    def case(self, nll=2, text="text"):
        return {"text": text, "predicted_tokens": 3, "nll": nll}

    def test_likelihood_regression_is_visible(self):
        report = compare_scores([self.case()], [self.case(3)])
        self.assertTrue(report["likelihood_regression"])
        self.assertEqual(report["nll_increase"], 1)

    def test_comparing_different_cases_is_rejected(self):
        with self.assertRaises(ValueError):
            compare_scores([self.case()], [self.case(text="other")])

    def test_invalid_likelihood_is_rejected(self):
        for nll in [float("nan"), float("inf"), -1]:
            with self.assertRaises(ValueError):
                compare_scores([self.case()], [self.case(nll)])


if __name__ == "__main__":
    unittest.main()


class GuardBoundaryTests(unittest.TestCase):
    def test_rejects_invalid_boundary_inputs_with_specific_reason(self):
        import lab
        cases = [('compare_scores([],[])', 'Matched development')]
        for code, reason in cases:
            with self.subTest(code=code):
                with self.assertRaisesRegex((ValueError, MemoryError, KeyError, RuntimeError), reason):
                    exec(code, vars(lab).copy())

Use the pinned live runtime from the first lesson for model commands. A path placeholder refers to your explicitly downloaded model; these commands do not fetch one automatically.

Run the live implementationTerminal · bash
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-live.txt
.venv/bin/python quantize_live.py --model-path /path/to/downloaded/model --output new-quantization-run.json

Try a change and explain the result

Inspect each text’s likelihood before reading the aggregate. Decide whether this candidate should proceed to task evaluation. Explain what an independent code-completion evaluation set would need to check before release.

A converted model uses fewer weight bits but worsens the development likelihood score. What follows?

Choose one answer, then check your reasoning.

Read the explanation

Conversion success and quality acceptance are different. A small development set is still not a held-out task evaluation.

Choose a supported quantization path

Keep calibration/development and held-out evaluation distinct. Test the exact deployed quantization backend, dtype, and model revision. Check current supported APIs and migration guidance; do not ship a candidate merely because conversion completed.

References

Sources checked October 24, 2026. Runtime-specific commands and results apply to the recorded versions; linked rolling documentation can change. The application scenarios are teaching examples, not claims about an author’s production incident.

Written by Arjun Prabhulal.

Module 4

Make inference faster and cheaper