ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Python复现“谷歌翻译20次”实验:从语义漂移分析到TTS演唱

用Python复现“谷歌翻译20次”实验:从语义漂移分析到TTS演唱 用谷歌翻译把一句话来回翻译 20 次再把最后生成的文字当作歌词唱出来是最近短视频平台上很常见的创意挑战。外行看是恶搞程序员的视角里却藏着一个很有意思的工程问题机器翻译输出是稳定的吗语义是怎么在多次往返中一点点丢掉的如果把这项能力封装成一个脚本我们要考虑哪些异常和限制这篇文章就把这个实验拆成可执行的模块从翻译源选型、20 轮循环、逐轮留痕到相似度分析、TTS 音频生成完整跑一遍。文章面向对 Python 脚本、机器翻译质量分析和多媒体小工具有兴趣的开发者。学完后你不仅能用代码复现“歌词漂移 20 次”的效果还能把同样的思路用到翻译质量评估、多语言数据清洗和文案走查上。1. 把“翻译 20 次后唱出来”拆成可以执行的子问题1.1 这个实验到底在测什么“谷歌翻译 20 次《人是猫》”这类挑战核心操作是把一段中文文本交给机器翻译翻译成英文再翻译回中文再翻译成英文如此往复。每一次往返结束文本都会因为翻译模型对上下文理解的差异而发生变化。若干轮之后语义会偏离原文很远甚至产生“一本正经胡说八道”的效果。原作者最后“自己唱出来”本质上是给漂移后的文本加了一层表现力。就算机器生成的文本语法没毛病但它往往和真实歌词的节奏、音调、情绪已经完全不匹配了。正是这种不匹配构成了创意的笑点。从技术角度这个项目可以拆成四件事低成本调用翻译服务最好能切换不同翻译源。用循环自动完成 N 次翻译而不是手工复制粘贴。把每一轮文本和状态保存下来便于观察漂移过程。把最终文本转成音频或者生成对齐歌词卡方便“唱出来”。本文会用一个最小脚本完成前三点用 TTS 工具完成第四点。整个项目不依赖复杂框架适合作为周末小项目复现。1.2 设计输出物如果输入文本是“人是猫猫是人白天睡觉晚上跑”一次完整运行结束后应该得到三类输出输出物格式作用逐轮翻译记录JSONL记录每一步的文本、语言方向、时间、长度分析结果终端表格 PNG 图观察语义相似度随轮次下降的趋势最终音频MP3/WAV把第 20 轮文本朗读出来供演唱练习使用JSONL 里的每一步都是一行独立 JSON即使程序中途崩溃也不会把前面的记录全丢掉。这是处理长流程任务时非常有用的设计。注意不要一开始就盯着“最终结果多好笑”先确认每一轮都成功落盘。翻译失败时能定位到具体轮次比重新跑 20 轮省时间得多。2. 为什么越翻越走样机器翻译的误差累积2.1 翻译是生成不是替换第一版机器翻译系统确实接近“查词典 规则调整”把中文词替换成英文词再按语法规则调整语序。这种系统对词序敏感的句子很容易翻错。现在的神经机器翻译完全不同。它把源文编码成语义向量再基于这个向量逐个生成目标语言 token。给定同一个源句模型会计算每个目标词的概率分布。多数情况下模型会选择概率最高的词但概率不是 1句子中任何一个词的取舍都带有统计偏差。一个词被换成近义词后续句子结构就可能跟着调整。举例来说原文人是猫猫是人白天睡觉晚上跑某轮英文翻译可能是People are cats, cats are people, sleeping by day and running at night再翻回中文可能是人是猫猫是人白天睡觉晚上跑步到这里还算稳定。但如果某一步模型把“人”理解为“人类”把“猫”理解为“猫科动物”后面就会沿着这个语义继续扩散。这不是一次性的错误而是逐步累积的过程。2.2 回译的误差不会自动消除有人会想既然中文翻英文会丢信息那再翻回中文不就把丢失的信息补回来了吗在机器翻译评测中确实有一种方法叫“回译”常用于数据增强用低资源语言翻译出大量伪平行语料再用它训练模型。但回译不是无损操作。它只能保证“源文的核心语义大概率还在”无法保证词义、语序、隐喻、风格和韵律都保留。原因是语言之间存在系统性差异中文经常省主语英文必须补主语。中文没有英语那种严格的时态变化英文模型要选择用过去时还是现在时。中文“他/她/它”发音相同书面文本却不同英文 all same he? no actually gender pronouns ambiguous in speech but textual. 多义词一旦被选中另一个含义就被丢弃。形容词、量词、语气词在不同语言里激活的语境不一样。当这种不确定性连续叠加 20 次浅层错误会逐渐变成深层次语义漂移。某些文本甚至会完全丢失原本指代对象变成逻辑通顺但与原文无关的新文本。2.3 用相似度把“走样”变成数字手工观察 20 条记录太累也不够客观。可以用文本相似度指标把“走样程度”量化。常用指标包括指标计算思路优点不足SequenceMatcher基于字符子序列匹配实现简单、无需安装 NLP 模型对同义词替换不敏感Levenshtein 距离编辑距离删除、插入、替换的次数适合发现字符级变化中文按字处理时语义价值有限Jaccard 相似度分词后集合交集 / 并集能反映关键词覆盖情况无视词序BLEU计算候选文本和参考文本 n-gram 重合机器翻译领域通用指标需要参考译文这里不一定有对这个项目来说difflib.SequenceMatcher已经足够看到趋势。如果想看关键词是否还保留可以结合jieba分词后计算 Jaccard。后面第五章会给出具体实现。3. 环境准备与翻译源选型3.1 Python 依赖与目录实验基于 Python 3.9 以上版本。新建一个目录例如lyric_chain在其中创建虚拟环境并安装依赖。mkdir lyric_chain cd lyric_chain python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate依赖文件requirements.txt内容如下deep-translator1.11.4 requests2.31.0 jieba0.42.1 edge-tts6.1.10 matplotlib3.9.0安装命令pip install -r requirements.txt依赖说明deep-translator负责把多个翻译服务包装成统一调用方式。requests用于调用自建翻译服务时的 HTTP 请求。jieba用于中文分词计算关键词保留率。edge-tts用于把最终文本合成为语音。matplotlib用于绘制逐轮相似度曲线。版本号不必死守安装时如果遇到依赖冲突使用 pip 解析出的兼容版本即可。落地前确认 Python 环境和依赖版本否则后面报错容易分不清是代码问题还是环境问题。3.2 翻译源如何选项目标题明确是“谷歌翻译”所以默认翻译源自然选谷歌。但纯工程实现不能把鸡蛋放在一个篮子里免费接口有请求频率限制某些网络环境下未必稳定。比较好的做法是把“翻译源”抽象成一个接口然后在配置里切换。可选翻译源对比如下翻译源调用方式请求限制适用场景Google 翻译免费接口deep-translator 的 GoogleTranslator有频率限制不稳定本地小规模实验、快速验证Google Cloud Translation API官方 REST/gRPC 接口按项目配额和付费额度生产级、需要稳定 SLALibreTranslate自建或第三方实例POST /translate取决于部署实例需要离线或私有化环境时百度/有道云翻译各云厂商 SDK/HTTP按账号额度国内网络环境中比较常用代码层面不要写死某一个翻译源。定义一个基础类TranslatorBackend每个翻译源实现一个子类主流程只依赖父类接口。下面代码是 Google 免费接口和 LibreTranslate 的实现示例。# translator_factory.py from typing import Dict class TranslatorBackend: def translate(self, text: str, target: str) - str: raise NotImplementedError class GoogleWebBackend(TranslatorBackend): 基于 deep-translator 的 Google 翻译封装适合低频率小规模实验。 def translate(self, text: str, target: str) - str: from deep_translator import GoogleTranslator return GoogleTranslator(sourceauto, targettarget).translate(text) class LibreTranslateBackend(TranslatorBackend): 自建 LibreTranslate 或其他兼容实现。 def __init__(self, base_url: str, api_key: str ): self.base_url base_url.rstrip(/) self.api_key api_key def translate(self, text: str, target: str) - str: import requests payload { q: text, source: auto, target: target, format: text, } headers {} if self.api_key: headers[Authorization] Bearer self.api_key resp requests.post( self.base_url /translate, jsonpayload, headersheaders, timeout20, ) resp.raise_for_status() return resp.json()[translatedText] def build_backend(name: str) - TranslatorBackend: if name google: return GoogleWebBackend() if name libre: return LibreTranslateBackend(http://127.0.0.1:5000) raise ValueError(funknown backend: {name})这段代码的关键点是主流程不需要关心底层是 Google 还是 LibreTranslate只要调用translate(text, target)就能拿到结果。后续如果想换成官方云 API只需要新增一个类不改主流程。3.3 连通性探针翻译源选好后先运行一个最小探针确认服务可用。探针不需要翻译完整歌词翻译一个短句即可。import sys from translator_factory import build_backend backend build_backend(google) text 人是猫 try: result backend.translate(text, en) print(source:, text) print(target: en) print(result:, result) except Exception as exc: print(translation failed:, type(exc).__name__, exc) sys.exit(1)如果输出类似result: People are cats的内容说明翻译链路是通的。这里出现 HTTP 429、403 或连接超时都属于翻译源接入问题应先解决接入再继续后面的 20 轮循环。4. 核心实现20 轮翻译与逐轮留痕4.1 语言方向设计“翻译 20 次”有两种常见理解在中文和英文之间往返 20 次每次调用都算一次翻译。让文本在不同语言间依次接力每切换一种语言算一次翻译。为了方便复现两种效果主脚本支持roundtrip和chain两种模式。roundtrip模式下语言序列固定为[en, zh-CN]奇数次调用翻译成英文偶数次翻译回中文。chain模式下可自定义语言序列未指定时使用[en, ja, fr, de, zh-CN]。4.2 主循环脚本下面是完整主脚本run_chain.py它负责三件事从命令行读取模式和轮数。每次翻译前记录状态翻译后立刻写入 JSONL。请求之间加入间隔规避频率限制。# run_chain.py import argparse import json import time from datetime import datetime, timezone, timedelta from translator_factory import build_backend CST timezone(timedelta(hours8)) LOG_FILE records.jsonl ROUNDTRIP_LANGS [en, zh-CN] CHAIN_LANGS [en, ja, fr, de, zh-CN] def now_text() - str: return datetime.now(CST).isoformat(timespecseconds) def append_record(record: dict) - None: with open(LOG_FILE, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n) def target_lang(step: int, mode: str, seq: list[str]) - str: if mode roundtrip: return seq[step % len(seq)] return seq[step % len(seq)] def main() - None: parser argparse.ArgumentParser(descriptionRun N-step machine translation chain.) parser.add_argument(--text, default人是猫猫是人白天睡觉晚上跑) parser.add_argument(--rounds, typeint, default20) parser.add_argument(--mode, choices[roundtrip, chain], defaultroundtrip) parser.add_argument(--interval, typefloat, default0.8) parser.add_argument(--backend, choices[google, libre], defaultgoogle) args parser.parse_args() seq ROUNDTRIP_LANGS if args.mode roundtrip else CHAIN_LANGS backend build_backend(args.backend) text args.text append_record({step: 0, direction: source, target: , text: text, time: now_text()}) for step in range(1, args.rounds 1): lang target_lang(step - 1, args.mode, seq) try: translated backend.translate(text, lang) except Exception as exc: print(f[step {step}] translate to {lang} failed: {type(exc).__name__}: {exc}) break if not translated: print(f[step {step}] empty result, stop) break append_record({ step: step, direction: fstep{step - 1}-{lang}, target: lang, text: translated, time: now_text(), }) text translated print(f[step {step:2}] target{lang:6} len{len(translated):5} text{translated}) if step args.rounds: time.sleep(args.interval) if __name__ __main__: main()运行方式python run_chain.py --text 人是猫猫是人白天睡觉晚上跑 --rounds 20 --mode roundtrip --backend google参数说明表格参数含义默认值建议--text起始文本“人是猫猫是人白天睡觉晚上跑”换成你准备测试的歌词片段或原创文本--rounds翻译调用次数20验证链路时先用 2 次--moderoundtrip 或 chainroundtrip想看逐轮漂移用 roundtrip想看跨语言接力用 chain--interval两次请求间隔秒数0.8免费接口建议不要小于 0.5--backend翻译源google不稳定时切到其它实现这段代码有意识地做了三个设计选择每次翻译完立刻写文件而不是等 20 轮结束后一次性写。前面跑过再多轮中途断掉也保留前 15 轮结果。失败时不静默吞掉而是打印错误并 break保留已经完成的记录可继续分析避免死循环刷请求。间隔时间参数化避免因为请求过密触发限流。4.3 空结果和失败处理免费翻译接口偶尔会返回空字符串。主脚本遇到空结果会停止避免把空字符串传给下一轮翻译后产生导致后续错误。如果希望失败后重试而不是直接停可以在 except 分支里加入指数退避。重试逻辑如下import time max_retries 3 for attempt in range(1, max_retries 1): try: translated backend.translate(text, lang) break except Exception as exc: print(f[step {step}] attempt {attempt} failed: {exc}) if attempt max_retries: raise time.sleep(2 ** attempt)指数退避比固定等待更有效它从 2 秒、4 秒、8 秒逐步加大间隔给服务恢复留出时间避免在限流窗口内反复请求加重问题。这是调用任何第三方接口都应该养成的习惯。4.4 运行与预期输出使用默认参数运行后终端会输出类似下面的内容[step 1] targeten len23 textPeople are cats, cats are people... [step 2] targetzh-CN len20 text人是猫猫是人... ... [step 20] targetzh-CN len35 text人类和猫科动物在昼夜节律上存在相似之处...需要强调第 20 轮的具体内容不是固定不变的。翻译引擎模型更新、请求状态、不同账号环境都会影响结果。你不应该期待每次运行得到一模一样的文字这正是这个实验里最大的变量。如果想复现一份“稳定的效果”可以把某一轮的输出完整保存到本地后续对比时以保存版本为准。5. 实验记录分析和可视化5.1 从 JSONL 里读回记录前面的脚本生成了records.jsonl下面用 Python 读取并计算每一轮相对原始文本的相似度。# analyze.py import difflib import json import jieba def load_records(pathrecords.jsonl): records [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if line: records.append(json.loads(line)) return records def char_similarity(a: str, b: str) - float: return difflib.SequenceMatcher(None, a, b).ratio() def keyword_similarity(a: str, b: str) - float: set_a set(jieba.lcut(a)) set_b set(jieba.lcut(b)) if not set_a: return 0.0 return len(set_a set_b) / len(set_a | set_b) if __name__ __main__: records load_records() if len(records) 2: print(no enough records) raise SystemExit(1) base_text records[0][text] print(f{step:6}{char_sim:10}{token_sim:10}text) print(- * 60) for index, record in enumerate(records): if index 0: continue text record[text] cs char_similarity(base_text, text) ts keyword_similarity(base_text, text) short text if len(text) 28 else text[:25] ... print(f{record[step]:6}{cs:10.3f}{ts:10.3f}{short})char_similarity用的是字符级公共子序列比例keyword_similarity则是分词后关键词集合的 Jaccard 值。两个指标一起看可以判断到底是文本顺序被打乱还是关键词本身就丢了。5.2 把漂移曲线画出来如果只想快速看趋势可以用 matplotlib 画相似度随轮次变化的折线图。# plot_similarity.py import json import difflib import matplotlib.pyplot as plt def load_records(pathrecords.jsonl): records [] with open(path, r, encodingutf-8) as f: for line in f: if line.strip(): records.append(json.loads(line)) return records records load_records() base_text records[0][text] steps [r[step] for r in records[1:]] scores [ difflib.SequenceMatcher(None, base_text, r[text]).ratio() for r in records[1:] ] plt.figure(figsize(8, 4)) plt.plot(steps, scores, markero) plt.xlabel(translation step) plt.ylabel(char similarity to source) plt.title(Semantic drift along translation chain) plt.grid(True, linestyle--, alpha0.6) plt.savefig(drift.png, dpi200, bbox_inchestight)正常情况下相似度会整体波动常见形态有两种呈现“平台期 阶梯下降”前几轮还能大致还原某一步出现关键近义词替换后相似度骤然下降。呈现“周期性锯齿”因为文本在中英文之间往返长度变化较大字符级相似度自然涨落但整体下移。看到这两种形态都不需要奇怪。如果相似度长期保持在 1.0很可能翻译源返回了原文说明文本太短、翻译触发不了变化或者接口返回逻辑有异常。此时应改用更长、更有歧义的文本再试。6. 把最终文本转成能跟着唱的音频6.1 用 TTS 生成参考诵读版第 20 轮文本通常是可读的中文句子但它不一定押韵也没有旋律。先用 TTS 朗读一遍完整文本可以作为后续演唱的“发音参照线”。edge-tts是一个在线 TTS 命令行工具使用示例edge-tts --voice zh-CN-XiaoxiaoNeural --file final_text.txt --write-media final_lyrics.mp3其中final_text.txt是上一小节从第 20 轮记录中提取的最终文本。也可以用 Python 调用import asyncio import edge_tts async def synthesise(text: str, output_path: str, voice: str zh-CN-XiaoxiaoNeural): communicate edge_tts.Communicate(text, voice) await communicate.save(output_path) final_text 第20轮自动生成的最终歌词文本 asyncio.run(synthesise(final_text, final_lyrics.mp3))需要注意edge-tts依赖在线语音服务是否可用取决于运行环境的网络和服务策略。如果你的环境无法访问它可以换成本地 TTS例如pyttsx3或者使用你所在环境可用的云厂商语音合成接口。6.2 逐句生成“歌词卡”“自己唱出来”最花时间的不是录音而是确认每一句该怎么断、怎么对节奏。可以把 20 轮翻译产生的每一轮结果都保留成一句文本生成“歌词卡”。歌词卡本质上是一个简单的 JSON{ title: 漂移练习曲, source: 人是猫猫是人白天睡觉晚上跑, final: 第20轮的最终文本, sections: [ { line: 第20轮文本第1句, original_length: 6, syllables: 6 }, { line: 第20轮文本第2句, original_length: 8, syllables: 8 } ] }如果你按字符串标点拆分再折算成汉字音节数就能清楚知道每一句比原曲多几个字或少几个字。演唱练习时多余的字可以弱读不足的部分可以拖长尾音或者加入语气词。6.3 生成节拍轨辅助练习真正演唱时需要知道速度。可以用 FFmpeg 手工生成一个简单节拍轨也可以直接在 Python 中调用pydub生成节拍音。如果安装pydub且环境里有 FFmpeg可以用下面的脚本生成一段节拍音from pydub import AudioSegment from pydub.generators import Sine bpm 90 beat_seconds 60.0 / bpm click_duration_ms 40 click_freq 880 click Sine(click_freq).to_audio_segment(durationclick_duration_ms) beat AudioSegment.silent(durationint(beat_seconds * 1000)) track AudioSegment.silent(duration0) for _ in range(32): track click beat track.export(metronome.mp3, formatmp3)之后再把人声和节拍轨混音ffmpeg -i vocal.mp3 -i metronome.mp3 -filter_complex amixinputs2:durationlongest output.mp3混音不是必须的。练习时戴耳机听节拍轨手机录音只录人声更容易得到干净的音轨。7. 排错清单20 轮翻译链常见问题7.1 高频错误快速定位20 轮循环看似简单实际跑起来会遇到不少问题。下面是高频现象和排查思路问题现象常见原因检查方式处理建议第 1 轮就报 429请求过于频繁或接口额度不足查看异常状态码和响应体增大--interval换翻译源等待一段时间再试报 403 或连接失败翻译服务在运行环境不可用先运行连通性探针按服务条款和环境限制接入可用端点使用自建翻译服务返回空字符串接口异常或文本为空打印repr(translated)停止本轮并重试检查是否有过滤逻辑丢弃文本连续 20 轮结果都不变文本过短触发不了语义变化检查records.jsonl换成更长的歌词句或带歧义的句子终端中文乱码控制台编码不是 UTF-8locale检查系统编码Windows 下设置PYTHONIOENCODINGutf-8中途断掉重新执行又从第 1 轮开始脚本没有续跑能力查看日志起始 step从records.jsonl最后一行恢复文本而不是从原始文本重跑7.2 断点续跑思路免费翻译接口的特点是随时可能抽风。如果跑到第 13 轮挂掉再从第 1 轮开始不仅浪费请求还可能因为流量变化得到完全不同的中间结果。断点续跑的核心是每次成功后把“当前最新文本”和“当前轮次”写进状态文件。下次启动时先读取状态文件如果有记录则从对应轮次继续。import json from pathlib import Path STATE_FILE Path(state.json) def save_state(step: int, text: str): STATE_FILE.write_text( json.dumps({step:
返回列表