diff --git a/web-pages/product-site/content/legacy-manifest.json b/web-pages/product-site/content/legacy-manifest.json index 1c59b5497..030b3a13f 100644 --- a/web-pages/product-site/content/legacy-manifest.json +++ b/web-pages/product-site/content/legacy-manifest.json @@ -3,6 +3,9 @@ "source": "https://www.funasr.com/ public static corpus", "captured": "2026-07-26", "files": { + "blog/sensevoice-finetuning-acceptance.html": "df48083c643321de767b17f9055bd28e26552974e6fb97c834ba3680b8ff0612", + "en/blog/sensevoice-finetuning-acceptance.html": "a45590971d7b41fb126b59053fa4bf25b7a9882d8d9b4a73c7ed982b2fb01f62", + "img/continual-eval-example.png": "20b7a0de64fdd08ea761d67a7ccd7ce9a421f3669d9a212b176124630cc0f259", "blog/fun-asr-nano-transformers.html": "139401dd9790362bb7c8d935eb1241f51af67ae6135e5c268699f64707941719", "en/blog/fun-asr-nano-transformers.html": "0cb7a1cab4210a29622b17154b3ad59b0f2d6be67829ef12060f5d5780738c8f", "img/fun-asr-nano-native-waveform.png": "930517464ffb47bc7f9051dbdcb5422e1b3daa5c6abaa455a8bf072359ae0f0b", diff --git a/web-pages/product-site/data/blog.json b/web-pages/product-site/data/blog.json index cd29becd0..86d79e3e6 100644 --- a/web-pages/product-site/data/blog.json +++ b/web-pages/product-site/data/blog.json @@ -9,6 +9,19 @@ ], "lead_image": "/img/funclip-v2-1-0-interface.jpg", "articles": [ + { + "slug": "sensevoice-finetuning-acceptance", + "category": "explanations", + "reviewed": true, + "zh": { + "title": "微调后,怎样确认模型真的更好?", + "summary": "从一次持续学习反馈出发,先验证评测尺子,再选择 checkpoint,最后检查真实服务。" + }, + "en": { + "title": "Did fine-tuning actually make your model better?", + "summary": "Validate the ruler, choose a checkpoint under old-domain constraints, then test the real service." + } + }, { "slug": "cantonese-speech-recognition", "category": "applications", diff --git a/web-pages/product-site/data/continual-eval-example.json b/web-pages/product-site/data/continual-eval-example.json new file mode 100644 index 000000000..57c744c3d --- /dev/null +++ b/web-pages/product-site/data/continual-eval-example.json @@ -0,0 +1,46 @@ +{ + "synthetic": true, + "old_domain_limit_pp": 1, + "slices": [ + { + "label": "Mandarin / read", + "old_domain": true, + "reference_characters": 1000, + "baseline_errors": 60, + "a_errors": 50, + "b_errors": 60 + }, + { + "label": "Mandarin / spontaneous", + "old_domain": true, + "reference_characters": 1000, + "baseline_errors": 80, + "a_errors": 80, + "b_errors": 85 + }, + { + "label": "Cantonese / read", + "old_domain": true, + "reference_characters": 1000, + "baseline_errors": 90, + "a_errors": 90, + "b_errors": 95 + }, + { + "label": "Cantonese / spontaneous", + "old_domain": true, + "reference_characters": 1000, + "baseline_errors": 120, + "a_errors": 180, + "b_errors": 125 + }, + { + "label": "New dialect", + "old_domain": false, + "reference_characters": 1000, + "baseline_errors": 600, + "a_errors": 220, + "b_errors": 280 + } + ] +} diff --git a/web-pages/product-site/legacy/blog/sensevoice-finetuning-acceptance.html b/web-pages/product-site/legacy/blog/sensevoice-finetuning-acceptance.html new file mode 100644 index 000000000..acabfafa9 --- /dev/null +++ b/web-pages/product-site/legacy/blog/sensevoice-finetuning-acceptance.html @@ -0,0 +1,51 @@ + + + +微调后,怎样确认模型真的更好? | FunASR + + + + + + + + +
+

微调后,怎样确认模型真的更好?

+

2026-09-10 · 技术解读 · 阅读约 6 分钟

+

你让模型学会了新方言,却发现原来能听懂的普通话变差了。训练曲线在下降,文件名也叫 best,为什么仍不能放心上线?要回答的不是“训得顺不顺”,而是“用同一把可信的尺子,业务有没有变好”。

+

下面用一个合成案例说明:A 的整体 CER 为 12.4%,B 为 12.9%,但 A 在粤语自发口语上退化了 6 个百分点。所有数字都是教学构造,没有运行声学训练,也不是 SenseVoice 的测量结果。

+

最低 loss、最低整体 CER 和可上线是三个不同结论。通过本文的例子不等于真实模型通过验收;旧域退化阈值必须由你的业务提前决定。

+

第一步:先确认尺子,再比较模型

+

一次 SenseVoice 持续微调反馈中,开发者主动撤回了早期的新方言 CER:参考文本和训练标签来自同一套未经独立核验的流程,连“逐字转写还是书面语意译”都没有先确认。这种错误不一定让训练报错,却会让选点依据失效。

+

先抽样回听,并写下标注规范:数字怎么写、语气词留不留、方言词如何记录、是否允许意译。训练与评估要隔离说话人,也要检查参考文本来源。把模型自己的转写再次当参考答案,并不能独立验证它。

+

区分用于调参和选点的验证集,以及最终验收时才使用的独立测试集。修改了参考文本或 CER 归一化规则,就在同一新口径下重测原模型和所有候选;不要把旧基线与新分数直接相减。若总在同一测试集上试配置,它实际上已经参与了选点。

+

第二步:不要让一个总分替你做决定

+

假设业务同时服务普通话、粤语和新方言。下面每一格都恰有 1,000 个参考字符,CER 是替换、删除、插入错误数之和除以参考字符数。这个合成例子把旧域每格的允许退化设为 最多 1 个百分点,只是演示,不是官方推荐阈值。

+ + + + + + +
验证切片原模型AB
普通话 · 朗读6%5%6%
普通话 · 自发口语8%8%8.5%
粤语 · 朗读9%9%9.5%
粤语 · 自发口语12%18%12.5%
新方言60%22%28%
+
合成例子中 A 的粤语自发口语 CER 增加 6 个百分点,B 的旧域最差增加 0.5 个百分点
合成验证数据的旧域变化,不是训练曲线或实测 benchmark。虚线为这个例子自定的 1 个百分点门槛。
+

按总错误数除以总参考字符数计算,原模型、A、B 分别为 19%、12.4%、12.9%。A 的整体和新方言分数都更好,却违反旧域约束;B 只获得进入独立测试与服务验收的资格,不是自动获准上线。真实切片大小不同时,不能直接平均各格百分比;同时报告每格样本量,避免大集合掩盖小语种。

+

也不要混淆单位:CER 从 12% 到 18% 是增加 6 个百分点,相对增加 50%。如果原基线为零,相对退化没有定义。少量样本上的小差异也可能只是波动,需要更多独立说话人和误例回听,不能把小数位当成可靠性的证明。

+

第三步:认清 best,再验证真正的出口

+

FunASR #3677修复了选点指标缺失被当作零的路径,已包含在 1.4.15。维护中的 SenseVoice 微调脚本明确按总验证 loss 排名;acc_rich 不等于 ASR 准确率或 CER。因此 model.pt.best 的“best”只对应所配置的有效指标,不能代替上面的业务约束。

+

更换排名指标要用新输出目录。升级软件不会恢复已删掉的 checkpoint,也不能自动纠正历史虚假零值排名。对仍保留的候选重新评估;平均权重也应作为一个新的候选复测,而不是默认优于参与平均的模型。

+

最后用固定音频穿过模型输出 → 导出或服务端 → 客户端。新增语言 tag 后,模型可能正确输出,下游旧白名单却把结果丢掉。保留各层原始结果,核对 tokenizer、模型映射和解析约定;不要为得到非空文本而关掉所有校验,也不要把词表中已有的 token 当作可随意复用的空闲槽位。

+

留下一份可回滚的验收记录:基线与候选权重散列、数据与标注规范版本、归一化规则、每格结果、固定请求及客户端输出。样本须有合法使用授权。先确认真实链路没有旧域回退和静默空输出,再逐步放量;这不是对灾难性遗忘或 CTC blank 偏置已经解决的承诺。

+

下一步,打开固定版本的SenseVoice 持续微调指南,先写好你的验证切片、独立测试集和接受阈值,再开始下一轮训练。

+
+ 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 sliceOriginalAB
Mandarin · read6%5%6%
Mandarin · spontaneous8%8%8.5%
Cantonese · read9%9%9.5%
Cantonese · spontaneous12%18%12.5%
New dialect60%22%28%
+
Synthetic example: A regresses 6 percentage points on spontaneous Cantonese; B's worst old-domain regression is 0.5 points
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"]