+
diff --git a/web-pages/product-site/legacy/en/blog/sensevoice-finetuning-acceptance.html b/web-pages/product-site/legacy/en/blog/sensevoice-finetuning-acceptance.html
new file mode 100644
index 000000000..ac306643f
--- /dev/null
+++ b/web-pages/product-site/legacy/en/blog/sensevoice-finetuning-acceptance.html
@@ -0,0 +1,51 @@
+
+
+
+Did fine-tuning actually make your model better? | FunASR
+
+
+
+
+
+
+
+
+
+
Did fine-tuning actually make your model better?
+
2026-09-10 · Technical explanations · 6 min read
+
Your model now recognizes a new dialect, but the Mandarin it used to handle has become worse. The training loss is falling and the checkpoint is called best. Can you ship it? The useful question is whether the application improved under the same trustworthy evaluation rules.
+
This synthetic example makes the trade-off concrete: A has an overall CER of 12.4%, versus B at 12.9%, but A regresses by 6 percentage points on spontaneous Cantonese. Every number is invented for teaching. No acoustic training was run and these are not SenseVoice measurements.
+
Lowest loss, lowest aggregate CER and readiness to ship are different conclusions. Passing this example does not validate a real model. Your application must define acceptable old-domain regression in advance.
+
First, validate the ruler
+
In a SenseVoice continual-learning report, the developer withdrew earlier new-dialect CER results: references and training labels came from the same unverified process. Even whether the labels were verbatim transcripts or written paraphrases had not been checked. This can invalidate checkpoint selection without causing a training error.
+
Listen to a sample and record the labeling policy: number formatting, fillers, dialect words and whether paraphrasing is allowed. Separate speakers between training and evaluation, and independently check the origin of the references. Scoring a model against its own generated transcripts is not independent validation.
+
Distinguish the validation sets used for tuning and checkpoint selection from a separate test set reserved for final acceptance. If references or CER normalization change, re-evaluate the original model and every candidate under that same policy. Do not subtract an old baseline from a newly defined score. Repeatedly choosing configurations on a test set makes it part of selection.
+
Then, stop one aggregate score from making the decision
+
Suppose the application serves Mandarin, Cantonese and a new dialect. Each slice below has exactly 1,000 reference characters. CER counts substitutions, deletions and insertions, divided by reference characters. For this synthetic example only, each old-domain slice may regress by at most 1 percentage point. That limit is not an official recommendation.
+
Validation slice
Original
A
B
+
Mandarin · read
6%
5%
6%
+
Mandarin · spontaneous
8%
8%
8.5%
+
Cantonese · read
9%
9%
9.5%
+
Cantonese · spontaneous
12%
18%
12.5%
+
New dialect
60%
22%
28%
+
+Synthetic old-domain validation changes, not training curves or a measured benchmark. The dashed line is this example's chosen 1-point limit.
+
Total errors divided by total reference characters gives 19%, 12.4% and 12.9% for the original, A and B. A wins on both aggregate and new-dialect CER, yet violates the old-domain constraint. B qualifies for independent testing and service acceptance, not automatic deployment. With unequal real slice sizes, do not simply average percentages. Report every slice and its sample size so a large population cannot hide a smaller language.
+
Keep units explicit: moving from 12% CER to 18% adds 6 percentage points, a 50% relative increase. Relative regression is undefined when the baseline is zero. Small differences on small samples may also be noise: use more independent speakers and listen to errors rather than treating decimal places as proof of reliability.
+
Finally, read best literally and test the real output path
+
FunASR #3677 fixed missing selection metrics being treated as zero; the fix is included in 1.4.15. The maintained SenseVoice recipe explicitly ranks by total validation loss. Its acc_rich is not ASR accuracy or CER. The name model.pt.best refers only to the configured, valid metric; it cannot replace application constraints.
+
Use a new output directory when changing the ranking metric. Updating software cannot restore deleted checkpoints or automatically repair historical artificial-zero rankings. Re-evaluate candidates that remain. Averaged weights are another candidate to test, not an assumed improvement over every source checkpoint.
+
Run fixed audio through model output → exported runtime or server → client. After adding a language tag, the model may return valid text while an old downstream allowlist discards it. Preserve intermediate outputs and check the tokenizer, model mapping and parsing contract. Do not disable all validation merely to obtain nonempty text, or assume an existing vocabulary token is an unused slot that can be repurposed freely.
+
Keep a rollback-ready acceptance record: original and candidate weight hashes, data and annotation-policy versions, normalization rules, per-slice results, the fixed request and final client output. Use only appropriately authorized data. Check old-domain retention and silent empty-output failures in the real path before a gradual rollout. None of this claims that catastrophic forgetting or CTC blank bias has been solved.
+
Next, open the version-pinned SenseVoice continual fine-tuning guide. Write down your validation slices, separate test set and acceptance limits before starting the next training run.
+
+
diff --git a/web-pages/product-site/legacy/img/continual-eval-example.png b/web-pages/product-site/legacy/img/continual-eval-example.png
new file mode 100644
index 000000000..7a76a1350
Binary files /dev/null and b/web-pages/product-site/legacy/img/continual-eval-example.png differ
diff --git a/web-pages/product-site/scripts/render_continual_eval_figure.py b/web-pages/product-site/scripts/render_continual_eval_figure.py
new file mode 100644
index 000000000..0fb79d1f0
--- /dev/null
+++ b/web-pages/product-site/scripts/render_continual_eval_figure.py
@@ -0,0 +1,36 @@
+"""Reproduce the synthetic article figure with matplotlib==3.10.0 (not model results)."""
+import json
+from pathlib import Path
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+SITE = Path(__file__).resolve().parents[1]
+data = json.loads((SITE / "data/continual-eval-example.json").read_text())
+rows = [row for row in data["slices"] if row["old_domain"]]
+fig, ax = plt.subplots(figsize=(11, 5.5))
+fig.patch.set_facecolor("white")
+for offset, key, color, name in [(-0.16, "a_errors", "#c53a46", "Candidate A"),
+ (0.16, "b_errors", "#147566", "Candidate B")]:
+ changes = [100 * (row[key] - row["baseline_errors"]) / row["reference_characters"] for row in rows]
+ y = [i + offset for i in range(len(rows))]
+ ax.barh(y, changes, height=0.27, color=color, label=name)
+ for yi, change in zip(y, changes):
+ ax.text(max(0, change) + 0.08, yi, f"{change:+.1f} pp", va="center", fontsize=11, color="#202827")
+ax.axvline(data["old_domain_limit_pp"], color="#656e68", linestyle="--", linewidth=1.4)
+ax.text(1.1, -0.63, "Illustrative limit: +1 pp", fontsize=11, color="#444c48")
+ax.set_yticks(range(len(rows)), [row["label"] for row in rows])
+ax.invert_yaxis()
+ax.set_xlim(-1.5, 7.4)
+ax.set_xlabel("Change in validation CER from the original model (percentage points)")
+ax.set_title("A better overall score can hide a worse old domain", loc="left", pad=26, fontsize=16)
+ax.legend(loc="upper right", frameon=False)
+ax.spines[["top", "right", "left"]].set_visible(False)
+ax.tick_params(axis="y", length=0)
+fig.text(0.02, 0.01, "SYNTHETIC EXAMPLE. No model was measured. Limits are not FunASR recommendations.", fontsize=10)
+fig.subplots_adjust(left=0.28, right=0.97, bottom=0.22, top=0.80)
+output = SITE / "legacy/img/continual-eval-example.png"
+fig.savefig(output, dpi=140, metadata={"Software": "FunASR synthetic editorial example"})
+plt.close(fig)
+print(output)
diff --git a/web-pages/product-site/tests/browser/blog-editorial.spec.ts b/web-pages/product-site/tests/browser/blog-editorial.spec.ts
index 0fa4cb13e..ff675a83d 100644
--- a/web-pages/product-site/tests/browser/blog-editorial.spec.ts
+++ b/web-pages/product-site/tests/browser/blog-editorial.spec.ts
@@ -78,7 +78,7 @@ for (const prefix of ['', 'en/']) {
}
await page.locator(`[data-blog-more] a[href="/${prefix}blog/archive/"]`).click();
const archive = page.locator('[data-blog-view="archive"]');
- await expect(archive.locator('[data-blog-story]')).toHaveCount(35);
+ await expect(archive.locator('[data-blog-story]')).toHaveCount(36);
await page.screenshot({ path: testInfo.outputPath('archive.png') });
await archive.locator(`a[href="/${prefix}blog/self-hosted-deepgram-assemblyai-alternative.html"]`).click();
await expect(page.locator('article h1')).toBeVisible();
diff --git a/web-pages/product-site/tests/browser/continual-eval-article.spec.ts b/web-pages/product-site/tests/browser/continual-eval-article.spec.ts
new file mode 100644
index 000000000..096ddf03f
--- /dev/null
+++ b/web-pages/product-site/tests/browser/continual-eval-article.spec.ts
@@ -0,0 +1,38 @@
+import { expect, test } from '@playwright/test';
+
+for (const prefix of ['', 'en/']) {
+ for (const width of [320, 390, 1440]) {
+ test(`continual evaluation story ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => {
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(String(error)));
+ await page.setViewportSize({ width, height: 1000 });
+ await page.goto(`/${prefix}blog/explanations/`);
+ await page.locator(`a[data-blog-story][href="/${prefix}blog/sensevoice-finetuning-acceptance.html"]`).click();
+ const article = page.locator('article');
+ await expect(article.locator('h1')).toBeVisible();
+ await expect(article.locator('tbody tr')).toHaveCount(5);
+ const tableFits = await article.locator('table').evaluate((table) => {
+ const bounds = table.getBoundingClientRect();
+ const container = table.parentElement!.getBoundingClientRect();
+ return bounds.left >= container.left - 1 && bounds.right <= container.right + 1
+ && table.scrollWidth <= table.clientWidth + 1;
+ });
+ expect(tableFits, 'Every comparison column must fit without horizontal scrolling').toBeTruthy();
+ await expect(article.locator('[data-editorial="boundary"]')).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath('opening.png') });
+ await article.locator('figure').scrollIntoViewIfNeeded();
+ const image = await article.locator('figure img').evaluate((img: HTMLImageElement) => ({
+ width: img.naturalWidth, loaded: img.complete,
+ }));
+ expect(image.loaded).toBeTruthy();
+ expect(image.width).toBeGreaterThan(1000);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
+ await page.screenshot({ path: testInfo.outputPath('example.png') });
+ await expect(article.locator('[data-editorial="next-step"] a')).toHaveAttribute('href', /\/blob\/v1\.4\.15\//);
+ const peer = prefix ? '' : 'en/';
+ await page.locator(`.header-actions a[href="/${peer}blog/sensevoice-finetuning-acceptance.html"]`).click();
+ await expect(page).toHaveURL(new RegExp(`/${peer}blog/sensevoice-finetuning-acceptance\\.html$`));
+ expect(errors).toEqual([]);
+ });
+ }
+}
diff --git a/web-pages/product-site/tests/test_continual_eval_article.py b/web-pages/product-site/tests/test_continual_eval_article.py
new file mode 100644
index 000000000..bb7e89959
--- /dev/null
+++ b/web-pages/product-site/tests/test_continual_eval_article.py
@@ -0,0 +1,66 @@
+"""The continual-learning story separates examples from measured evidence."""
+
+import json
+from pathlib import Path
+import sys
+
+from bs4 import BeautifulSoup
+import pytest
+
+SITE = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SITE))
+from build import build
+
+SLUG = "sensevoice-finetuning-acceptance"
+
+
+@pytest.fixture(scope="module", params=["source", "built"])
+def root(request, tmp_path_factory):
+ if request.param == "source":
+ return SITE / "legacy"
+ output = tmp_path_factory.mktemp("continual-story")
+ build(output)
+ return output
+
+
+@pytest.mark.parametrize("prefix", ["", "en/"])
+def test_article_has_a_reader_path_and_honest_example(root, prefix):
+ soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser")
+ article = soup.select_one("article")
+ assert len(article.select("h1")) == 1
+ assert article.select_one('[data-editorial="opening"]')
+ example = article.select_one('[data-editorial="example"]').get_text()
+ assert "synthetic" in example.lower() or "合成" in example
+ boundary = article.select_one('[data-editorial="boundary"]').get_text()
+ assert "not" in boundary.lower() or "不等于" in boundary
+ assert len(article.select('[data-editorial="next-step"] a')) == 1
+ assert article.select_one('a[href*="issues/3388#issuecomment-5578763142"]')
+ assert article.select_one('a[href*="/blob/v1.4.15/examples/industrial_data_pretraining/sense_voice/CONTINUAL_FINETUNING"]')
+ assert article.select_one('a[href*="/pull/3677"]')
+ assert len(article.select("pre")) == 0, "Link the maintained recipe; do not duplicate it"
+ assert len(article.select("tbody tr")) == 5
+ image = article.select_one("figure img")
+ assert image.get("alt") and (root / image["src"].lstrip("/")).is_file()
+ assert article.select_one("figure figcaption")
+ assert soup.select_one('link[rel="canonical"]')["href"] == f"https://www.funasr.com/{prefix}blog/{SLUG}.html"
+ metadata = json.loads(soup.select_one('script[type="application/ld+json"]').get_text())
+ assert metadata["datePublished"] == "2026-09-10"
+ assert metadata["headline"] == article.h1.get_text()
+
+
+def test_synthetic_counts_support_the_decision_not_a_benchmark():
+ data = json.loads((SITE / "data/continual-eval-example.json").read_text())
+ assert data["synthetic"] is True and data["old_domain_limit_pp"] == 1
+ rows = data["slices"]
+ assert len(rows) == 5 and all(row["reference_characters"] == 1000 for row in rows)
+ assert [sum(row[name] for row in rows) / 50 for name in ("baseline_errors", "a_errors", "b_errors")] == [19, 12.4, 12.9]
+ old = [row for row in rows if row["old_domain"]]
+ assert max((r["a_errors"] - r["baseline_errors"]) / 10 for r in old) == 6
+ assert max((r["b_errors"] - r["baseline_errors"]) / 10 for r in old) == 0.5
+
+
+def test_story_is_discoverable_without_expanding_homepage():
+ data = json.loads((SITE / "data/blog.json").read_text())
+ row = next(entry for entry in data["articles"] if entry["slug"] == SLUG)
+ assert row["reviewed"] and row["category"] == "explanations"
+ assert len(data["selected"]) == 4 and SLUG not in data["selected"]