NLP处理技巧
### 数据探索:先看统计,再看分布 拿到文本数据后,先用 pandas 快速摸清数据特点:总量、长度、类别分布、数据质量,再决定建模方案。 ```python # 🔍 第一步:基础统计信息 print(f"📊 数据总量: {len(df)} 条") print(f"📏 平均文本长度: {df['length'].mean():.1f} 个字符") print(f"📈 文本长度范围: {df['length'].min()} - {df['length'].max()} 个字符") print(f"🏷️ 意图类别数: {df['intent'].nunique()} 个") # 🎯 第二步:类别分布分析(value_counts + 百分比) intent_counts = df['intent'].value_counts() for intent, count in intent_counts.items(): percentage = count / len(df) * 100 print(f" {intent}: {count}条 ({percentage:.1f}%)") # 📈 第三步:数据质量评估 print(f"✅ 数据完整性: {(1 - df['text'].isnull().sum()/len(df))*100:.1f}%") print(f"🔄 数据重复率: {(df.duplicated().sum()/len(df))*100:.1f}%") print(f"⚠️ 异常数据: {sum((df['length'] < 2) | (df['length'] > 50))} 条") ``` **要点** - `value_counts()` 一行搞定类别分布,配合百分比看类别是否平衡 - 长度统计用 `min/max/mean`,判断是短文本还是长文本任务 - 质量三指标:空值率、重复率、异常长度(过短/过长)
### 画图不乱码:中文字体自动配置 跨平台兼容方案:准备一份按优先级排列的候选字体列表,检测系统实际已安装的字体,取第一个可用的;再配一个中英文兜底标题函数。 ```python import matplotlib.pyplot as plt import matplotlib.font_manager as fm def setup_chinese_font(): """自动检测并设置当前系统可用的中文字体,解决中文乱码问题""" candidates = [ 'Microsoft YaHei', 'SimHei', 'SimSun', # Windows 'PingFang SC', 'Hiragino Sans GB', 'Arial Unicode MS', # macOS 'Noto Sans CJK SC', 'Source Han Sans SC', # Linux (noto) 'WenQuanYi Zen Hei', 'WenQuanYi Micro Hei', # Linux (wqy) ] installed = {f.name for f in fm.fontManager.ttflist} # 系统已安装字体 available = [f for f in candidates if f in installed] if not available: return False plt.rcParams['font.sans-serif'] = available + ['DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方块 return True chinese_font_available = setup_chinese_font() def safe_title(chinese_title, english_title): """字体不可用时自动降级为英文标题""" return chinese_title if chinese_font_available else english_title ``` **要点** - `fontManager.ttflist` 拿到系统真实字体,避免凭空设置导致找不到字体 - `axes.unicode_minus = False` 修复负号变方块的问题 - `safe_title()` 兜底:没有中文字体时图也不会乱码
### 一张画布六种图:数据探索可视化 用 `plt.subplot(2, 3, N)` 把 6 个常用图画在同一张画布上,一次看清数据全貌。 ```python plt.figure(figsize=(15, 10)) # 1. 饼图:类别占比,autopct 显示百分比 plt.subplot(2, 3, 1) plt.pie(intent_counts.values, labels=intent_counts.index, autopct='%1.1f%%', startangle=90) plt.title('意图分布') # 2. 直方图:文本长度分布 plt.subplot(2, 3, 2) plt.hist(df['length'], bins=6, alpha=0.7, color='skyblue', edgecolor='black') plt.title('文本长度分布') # 3. 分组直方图:按类别叠加对比 plt.subplot(2, 3, 3) for intent in df['intent'].unique(): lengths = df[df['intent'] == intent]['length'].values plt.hist(lengths, alpha=0.6, label=intent, bins=5) plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8) # 4. 条形图:Counter 统计高频字符 Top N plt.subplot(2, 3, 4) char_freq = Counter(all_words) # 中文可按字符统计 chars, frequencies = zip(*char_freq.most_common(8)) plt.bar(chars, frequencies, color='lightgreen', alpha=0.7) plt.title('高频字符分析') # 5. 条形图做数据质量检查:完整/空值/重复/异常 plt.subplot(2, 3, 5) plt.bar(quality_metrics.keys(), quality_metrics.values(), color=['green', 'red', 'orange', 'yellow'], alpha=0.7) plt.xticks(rotation=90) # 标签竖排,避免汉字重叠 # 6. 箱线图:类别 vs 数值的关系 plt.subplot(2, 3, 6) df.boxplot(column='length', by='intent', ax=plt.gca()) plt.xticks(rotation=90) plt.suptitle('') # 去掉 boxplot 自动生成的标题 plt.tight_layout() # 自动调整子图间距 plt.show() ``` **要点** - 饼图看占比、直方图看分布、条形图看 Top N、箱线图看类别间差异 - `xticks(rotation=90)` 解决中文标签重叠 - `df.boxplot(by=...)` 要配 `plt.suptitle('')` 去掉多余的自动标题 - 最后 `tight_layout()` 防止子图互相挤压
### 文本清理:先过滤,再清洗 清理分两条线:**行级过滤**(删掉不合格的行)和**内容清洗**(正则清理文本内容)。 ```python import re # 步骤1: 删除空文本和空标签 df_clean = df_raw[ (df_raw['text'].str.strip() != '') & (df_raw['intent'].str.strip() != '') ].copy() # 步骤2: 删除重复数据 df_clean = df_clean.drop_duplicates(subset=['text', 'intent']) # 步骤3: 删除纯符号文本(去掉标点后不足 2 个有效字符) def is_valid_text(text): clean = re.sub(r'[^\w\s]', '', text.strip()) return len(clean) >= 2 df_clean = df_clean[df_clean['text'].apply(is_valid_text)] # 步骤4: 文本清理函数(正则一条龙) def clean_text(text): """文本清理函数""" if pd.isna(text): return "" text = str(text) text = re.sub(r'https?://[^\s]+', '', text) # 去除网址 text = re.sub(r'\S+@\S+\.\S+', '', text) # 去除邮箱 text = re.sub(r'1[3-9]\d{9}', '[手机号]', text) # 手机号脱敏 text = re.sub(r'([!?。,])\1+', r'\1', text) # 多个标点变一个 text = re.sub(r'[🀀-🿿]', '', text) # 去除 emoji text = re.sub(r'\s+', ' ', text).strip() # 去除多余空格 return text df_clean['text'] = df_clean['text'].apply(clean_text) # 步骤5: 清洗后再次过滤过短文本 df_clean = df_clean[df_clean['text'].str.len() >= 2] ``` **要点** - 空值、重复、纯符号先按行删掉,再做内容清洗,顺序别反 - 手机号用占位符 `[手机号]` 替换而不是直接删,保留"这里有电话"的语义 - `([!?。,])\1+` 用反向引用把重复标点压成一个 - 清洗完要再检查一次长度,防止洗出空文本
### 中文分词:jieba + 停用词过滤 ```python import jieba # 简单停用词列表(实际项目可加载停用词文件) stop_words = set(['的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这']) def tokenize_text(text): """中文分词:jieba 分词后做三重过滤""" words = jieba.lcut(text) words = [w for w in words if w not in stop_words # 1. 去停用词 and len(w.strip()) > 1 # 2. 去单字 and not re.match(r'[^\w]', w)] # 3. 去标点 return words # 为文本添加分词结果列 df_clean['tokens'] = df_clean['text'].apply(tokenize_text) ``` **要点** - `jieba.lcut` 直接返回列表,比 `jieba.cut` 的生成器更方便 - 三重过滤:停用词、单字、标点,一个列表推导式搞定 - 分词结果存成新列 `tokens`,保留原文方便对照
正则表达式技巧
### 先搞懂 `[^\w\s]`:`\w` 和 `\s` 是什么 正则里 `\` 开头的是**预定义字符类**,`[...]` 是自定义字符类,`[^...]` 是**取反**。 | 写法 | 含义 | Python3 中匹配什么 | |------|------|------------------| | `\w` | 单词字符(word) | 字母 `a-zA-Z`、数字 `0-9`、下划线 `_`,以及**汉字等 Unicode 字符** | | `\W` | `\w` 的取反 | 标点、符号、空格等 | | `\s` | 空白字符(space) | 空格、`\t` 制表符、`\n` 换行、`\r` 回车 | | `\S` | `\s` 的取反 | 任何非空白字符 | | `\d` | 数字 | `[0-9]` | | `\D` | `\d` 的取反 | 非数字 | | `[^...]` | 字符类取反 | 不在括号里的任意字符 | 所以 `[^\w\s]` = **既不是"字"也不是"空白"的字符**,也就是标点符号。回看清理代码: ```python clean = re.sub(r'[^\w\s]', '', text.strip()) # 删掉所有标点符号 return len(clean) >= 2 # 剩下的"有效字符"不足2个 → 纯符号文本 ``` > ⚠️ 注意:Python3 的 `str` 默认是 Unicode 模式,**汉字也算 `\w`**,所以 `[^\w\s]` 不会误删中文——这正是它能用于中文 NLP 的原因。
### 文本清洗:一条正则解决一类脏数据 ```python import re text = "联系我13812345678,或发邮件到 test@qq.com,详见 https://example.com !!!" re.sub(r'https?://[^\s]+', '', text) # 去网址(http/https) re.sub(r'[\w\.-]+@[\w\.-]+\.\w+', '', text) # 去邮箱 re.sub(r'1[3-9]\d{9}', '[手机号]', text) # 手机号脱敏(替换成占位符) re.sub(r'<[^>]+>', '', text) # 去HTML标签 re.sub(r'\s+', ' ', text).strip() # 多个空白压成一个空格 ``` **模式解读** - `https?` — `s?` 表示 s 可有可无,同时匹配 http 和 https - `[^\s]+` — 网址就是"一串非空白字符" - `1[3-9]\d{9}` — 手机号:1 开头,第二位 3-9,共 11 位 - `<[^>]+>` — HTML 标签就是"尖括号包着一串非 `>` 的字符"
### 中文专属:汉字的 Unicode 区间是 `\u4e00-\u9fff` ```python # 提取所有中文(连续汉字段) re.findall(r'[\u4e00-\u9fff]+', text) # ['产品质量有问题', '要求退货'] # 判断文本是否含中文 bool(re.search(r'[\u4e00-\u9fff]', text)) # 删除所有标点:保留汉字/字母/数字/空白,其余全删 re.sub(r'[^\w\s]', '', text) # 重复标点压成一个:\1 是反向引用,表示"和第1个分组相同的内容" re.sub(r'([!?。,?!,.])\1+', r'\1', text) # "太好了!!!" → "太好了!" # 连续重复字压缩(如 "哈哈哈" → "哈") re.sub(r'(.)\1{2,}', r'\1', text) ``` **要点** - 量词决定匹配多长:`+` 至少一个、`{2,}` 至少两个、`?` 零或一个 - `(...)` 分组后可用 `\1` 引用,是处理"重复内容"的利器 - 清洗用 `re.sub`,提取用 `re.findall`,别混用
### 去除 emoji:按 Unicode 区间删 ```python # 简单版:一个区间兜底(本 notebook 的写法) text = re.sub(r'[🀀-🿿]', '', text) # 完整版:覆盖常见 emoji 区间,预编译复用更快 emoji_pattern = re.compile( "[" "\U0001F600-\U0001F64F" # 表情 "\U0001F300-\U0001F5FF" # 符号与象形文字 "\U0001F680-\U0001F6FF" # 交通与地图 "\U0001F1E0-\U0001F1FF" # 旗帜 "\U00002702-\U000027B0" # 杂项符号 "\U0001F900-\U0001F9FF" # 补充符号 "]+", flags=re.UNICODE) text = emoji_pattern.sub('', text) # 省心版:装个库 pip install emoji import emoji text = emoji.replace_emoji(text, replace='') ``` **要点** - emoji 分散在多个 Unicode 区间,用一个 `[区间1区间2...]` 字符类全装下 - 同一正则反复使用时,`re.compile` 预编译能提速 - 生产环境推荐 `emoji` 库,区间表不用自己维护
### 提取信息 + 停用词的正确去处 `re` 家族四个常用函数,按场景选: | 函数 | 作用 | 返回 | |------|------|------| | `re.match` | 只从**开头**匹配 | 第一个匹配对象 | | `re.search` | 全文找**第一个** | 第一个匹配对象 | | `re.findall` | 全文找**所有** | 字符串列表 | | `re.sub` | 查找并**替换** | 替换后的新字符串 | ```python # 提取所有数字 re.findall(r'\d+', "订单号12345,共3件商品") # ['12345', '3'] # 提取微博 #话题# 和 @昵称(加分组后,findall 只返回括号里的内容) re.findall(r'#([^#]+)#', "今天#天气真好#适合出游") # ['天气真好'] re.findall(r'@([\w\u4e00-\u9fff]+)', "@张三 你好") # ['张三'] ``` **去除停用词不是正则的活**:停用词是"按词表过滤",应该在**分词之后**用集合过滤,而不是用正则硬匹配整段文本: ```python stop_words = set(['的', '了', '在', '是', '我', '有', '和', '就']) words = [w for w in jieba.lcut(text) if w not in stop_words] ``` > 正则擅长"按**模式**清洗字符",停用词过滤擅长"按**词表**筛选词语"——先正则清洗、再分词、最后去停用词,顺序别乱。
中文乱码处理技巧
### 乱码从哪来:编码不一致的产物 中文乱码(mojibake)的本质:**字节流用了错误的编码去解码**,产生一堆"看着像字但不是那个字"的字符。 | 乱码样子 | 产生原因 | |---------|---------| | `测试中文` | UTF-8 的字节被按 **Latin-1/GBK** 解码 | | `涓枃涔辩爜` | UTF-8 的字节被按 **GBK** 解码 | | `锟斤拷锟斤拷` | 解码失败产生的 `�`(U+FFFD) 用 UTF-8 编码后又被按 **GBK** 解码 | | `�����` | GBK/GB2312 的字节被按 **UTF-8** 解码失败,变成替换符 `�` | **关键认知**:乱码字符大多是**合法的 Unicode 字母或汉字**(`æ` 是拉丁字母,`锟斤拷` 是真汉字),它们不是"坏字符",只是"被错误翻译的字符"。
### 为什么 `re.sub(r'[^\w\s]', '', text)` 删不掉乱码 正则只查每个字符的 **Unicode 类别**(是不是字母/数字/空白),不关心语义。实测结果: ```python import re re.sub(r'[^\w\s]', '', '测试') # → 'æµèäæ' ❌ 大部分留下(拉丁字母算 \w) re.sub(r'[^\w\s]', '', '锟斤拷锟斤拷abc') # → 原样 ❌ 完全不动(真汉字算 \w) re.sub(r'[^\w\s]', '', '中文�乱码�') # → '中文乱码' ✅ 替换符 U+FFFD 是符号,能删 re.sub(r'[^\w\s]', '', 'abc\x00\x01') # → 'abc' ✅ 控制字符能删 ``` **结论**:`[^\w\s]` 只能删"符号级别"的脏东西(U+FFFD、控制字符);乱码是"编码级别"的问题,得用编码手段解决。一句话:**正则只认户口(Unicode 类别),不认语义(是不是读得通的中文)**。
### 源头治理:用对的编码读(首选方案) ```python # 1. 读文件指定正确编码,错误字符直接丢弃 text = open('data.txt', encoding='utf-8', errors='ignore').read() # 2. 去掉文件头的 BOM(\ufeff,常导致首行多一个隐形字符) text = open('data.txt', encoding='utf-8-sig').read() # 3. Windows 导出的 CSV/Excel 常是 GBK 编码 df = pd.read_csv('data.csv', encoding='gbk') # 4. 已知错误方向时,可以"转回去再修复" text = text.encode('gbk', errors='ignore').decode('utf-8') # GBK误解码的UTF-8 → 修复 # 5. 不知道编码时,逐个尝试(utf-8 → gbk → gb18030 → big5) def safe_read(path): for enc in ['utf-8', 'gbk', 'gb18030', 'big5']: try: return open(path, encoding=enc).read() except UnicodeDecodeError: continue return open(path, encoding='utf-8', errors='ignore').read() # 6. 用库自动检测编码:pip install charset-normalizer from charset_normalizer import from_path result = from_path('data.txt').best() print(result.encoding) # 如 'gbk' text = str(result) ``` **要点** - 治乱码的优先级:**修复编码 > 丢弃错误字符 > 事后清洗**,越靠前损失越小 - `errors` 参数三档:`strict`(报错,默认)/ `ignore`(丢弃)/ `replace`(换成 `�`) - `utf-8-sig` 自动剥 BOM,比手动 `replace('\ufeff', '')` 干净
### 定点清除:已知乱码模式用正则/替换删 ```python # 删除替换符 �(解码失败的产物) text = text.replace('\ufffd', '') # 删除锟斤拷(可连续出现) text = re.sub(r'(锟斤拷)+', '', text) # 删除 BOM 与零宽字符(\ufeff \u200b \u200c 常混在网页抓取文本里) text = re.sub(r'[\ufeff\u200b\u200c\u200d]', '', text) # 全角空格转半角(\u3000 不属于 \s 的一部分,清洗时单独处理) text = text.replace('\u3000', ' ') # 一揽子清理不可见脏字符:保留常规字符,删控制字符和格式字符 text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text) ``` **要点** - `�`(U+FFFD)、BOM(`\ufeff`)、零宽字符(`\u200b`) 都是"隐形杀手":肉眼看不见,却让去重、匹配全部失效 - 网页抓取的数据优先过一遍"隐形字符清理",再做后续分词
### 启发式过滤:整行乱码多的直接丢 无法逐字符修复时,统计"非常规字符占比",超标整行丢弃: ```python def is_garbled(text, threshold=0.3): """判断文本是否乱码:非常规字符占比超过阈值""" if not text.strip(): return True bad = sum( not (c.isalnum() or c.isspace() or '\u4e00' <= c <= '\u9fff') for c in text ) return bad / len(text) > threshold df_clean = df[~df['text'].apply(is_garbled)] # 丢弃乱码行 ``` 更稳的做法:**修复优先、丢弃兜底** ```python def try_fix(text): """能修就修(编码回转),修不了再丢""" try: return text.encode('gbk', errors='strict').decode('utf-8') except (UnicodeEncodeError, UnicodeDecodeError): return None # 修不好 → 返回 None 交给上层丢弃 fixed = df['text'].apply(try_fix) df_clean = df[fixed.notna()].assign(text=fixed.dropna()) ``` **要点** - 判断"好字符"的标准:字母数字 `isalnum()`、空白 `isspace()`、常用汉字 `\u4e00-\u9fff` - 占比阈值 0.2~0.3 较稳:太严误伤含表情/符号的正常文本,太松放过乱码 - 乱码行占比本身就是一项**数据质量指标**,值得在数据探索阶段单独统计
### Unicode 与 UTF-8:一张"码表"和一种"存法" 一句话区分:**Unicode 是字符集(给每个字符编号),UTF-8 是编码方案(编号怎么存成字节)**。 | 对比项 | Unicode | UTF-8 | |--------|---------|-------| | 身份 | **字符集**:一张收录全世界字符的"码表" | **编码方案**:Unicode 码点的一种存储实现 | | 做的事 | 给每个字符分配唯一编号(码点),如 `中 = U+4E2D` | 规定码点怎么拆成 1~4 个字节存到磁盘/网络 | | 层次 | 抽象层:只管"字 ↔ 编号" | 物理层:管"编号 ↔ 字节" | | 同类 | 另一个字符集如 GB2312 只收了部分中文 | 同层的还有 UTF-16、UTF-32、GBK 的存法 | 用"快递"打比方:Unicode 是**收件人身份证号**(全国唯一),UTF-8 是**打包和面单的写法**——同一件货也可以用别的打包方式(UTF-16/UTF-32),但编号不变。 ```python s = '中' hex(ord(s)) # '0x4e2d' → Unicode 码点:U+4E2D(它属于"码表"层) s.encode('utf-8') # b'\xe4\xb8\xad' → 3 个字节(它属于"存法"层) s.encode('utf-16') # b'\xff\xfe-M' → 同一个字,另一种打包(BOM+2字节) '\u4e2d' == '中' # True:\u4e2d 就是码点的写法 ``` **和乱码的关系**:UTF-8 把 `中` 存成 `e4 b8 ad` 三个字节;如果对方按 GBK 每 2 字节一组去解,就解出别的汉字——**字节没错,解码的"码表"用错了**,这就是 mojibake 的全部真相。所以修乱码永远是编码问题,不是字符问题: ```python b'\xe4\xb8\xad'.decode('utf-8') # '中' ✅ 用对的码表解 b'\xe4\xb8\xad'.decode('gbk') # '涓' ❌ 用错的码表解出乱码 '涓'.encode('gbk').decode('utf-8') # '中' ✅ 转回去再解 = 修复原理 ``` **要点** - 看到乱码先想"这是什么编码的字节",而不是"怎么删掉这些怪字符" - UTF-8 变长(ASCII 1 字节、常用汉字 3 字节),是 Web 事实标准;UTF-16 是 Windows/Java 内部常用 - Python3 的 `str` 就是 Unicode 字符串(码点层),`encode`/`decode` 才涉及字节层——乱码只发生在"字节 → 字符"这一步
OCR文字识别技巧
### 现象:`pytesseract` 明明装了,notebook 却报 No module nlp 环境里已安装 pytesseract 0.3.13,notebook 里 `from pytesseract import image_to_string` 仍报 `ModuleNotFoundError`。 **排查思路:先证明"包没问题",再怀疑"kernel 错环境"** | 步骤 | 命令 | 结论 | |------|------|------| | 查环境里的包 | `micromamba list -n nlp \| grep pytesseract` | 0.3.13 在列 | | 用环境 python 直接导入 | `/wks/python/micromamba/envs/nlp/bin/python -c "import pytesseract"` | 导入成功 → 包没问题 | | 看报错特征 | PIL 能导入、pytesseract 不能 | kernel 所在环境有 pillow 没 pytesseract → 是别的环境 | | 查 kernel 指向 | `jupyter kernelspec list` | 只有 python3/cv1/llm_pdf/rag-pdf,没有叫 nlp 的 | | 查 server 进程 | `ps aux \| grep jupyter` | 两个 jupyter-lab:cv1 旧服务(被 Ctrl+Z 挂起)+ nlp 新服务 | **根因**:notebook 里记录的 kernel 名是 `python3`(界面显示 "Python 3 (ipykernel)"),它按**启动 server 的环境**解析——从 cv1 环境启动的 server 上,`python3` 就是 cv1 的 Python;两个环境都是 3.12,界面上无法区分,于是 PIL 能导入而 pytesseract 报错。 > 在 notebook 里跑一行 `import sys; print(sys.executable)`,看输出的 python 路径就知道当前 kernel 到底是哪个环境——这是最快的一锤定音法。
### 多环境 kernel:给每个环境注册"名字" kernel 列表里看不到 nlp,是因为它从未注册过命名 kernel——从 nlp 环境启动的 Jupyter,nlp 只显示为默认的 "Python 3 (ipykernel)";而 "Python (cv1)"、"Python (rag-pdf)" 等是其他环境注册过的用户级 kernel。 **一条命令注册命名 kernel(一劳永逸)** ```bash /wks/python/micromamba/envs/nlp/bin/python -m ipykernel install \ --user --name nlp --display-name "Python (nlp)" ``` - 写入 `~/.local/share/jupyter/kernels/nlp/kernel.json`,`argv` 用**绝对路径**指向该环境的 python - 之后**任何**环境启动的 Jupyter 都会出现 "Python (nlp)",刷新页面后在 Kernel → Change Kernel 里选它 **kernel 搜索顺序**(前者优先):启动 server 的环境 `share/jupyter/kernels` → `~/.local/share/jupyter/kernels` → `/usr/local/share/jupyter/kernels` **习惯清单** - 每个常用环境都注册命名 kernel,别依赖默认 `python3`——它在不同 server 上指向不同环境 - 开新 server 前先 `ps aux | grep jupyter` 看旧服务是否还活着(Ctrl+Z 挂起的 jupyter 不会自己退出,容易连错) - notebook 报"包不存在"时,先 `print(sys.executable)` 确认环境,再查包
### tesseract 引擎安装:conda 包 + 清华镜像加速 pytesseract 只是 Python 壳,真正干活的是 **tesseract 引擎二进制**;没装引擎时 import 不报错、一调用就抛 `TesseractNotFoundError`。 tesseract 是 conda-forge 的 C++ 包(连带 leptonica 等约 12 个依赖、180MB+),**不走 PyPI,pip 镜像派不上用场**,要换清华的 conda 镜像(实测 182MB 秒装完成): ```bash export MAMBA_ROOT_PREFIX=/wks/python/micromamba # 引擎本体 micromamba install -n nlp -y \ -c https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ \ tesseract # 中文语言包(识别中文必装,默认只有 eng) micromamba install -n nlp -y \ -c https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ \ tesseract-data-chi_sim ``` **验证** ```bash /wks/python/micromamba/envs/nlp/bin/tesseract --version # 5.5.3 /wks/python/micromamba/envs/nlp/bin/tesseract --list-langs # eng + chi_sim ``` **要点** - 装进环境的好处:无需 sudo,跟环境走,kernel 启动时 PATH 直接能找到二进制 - 装系统级则用 `sudo apt install tesseract-ocr tesseract-ocr-chi-sim`(WSL 下需要输密码) - Windows 端可装 UB-Mannheim 版,再用 `pytesseract.pytesseract.tesseract_cmd` 指定 exe 路径
### pytesseract 使用:默认 eng,中文要 `lang='chi_sim'` ```python from PIL import Image from pytesseract import image_to_string # 默认 eng:中文图片会输出一串"英文乱码" text = image_to_string(Image.open("data/banjiu_752.png")) print(text) # 实测输出: TERK: LRR-MRSEUKLRM ← 中文被硬识别成英文 # 中文图片必须指定语言包 text = image_to_string(Image.open("data/banjiu_752.png"), lang='chi_sim') # 中英混合:+ 号组合多个语言包 text = image_to_string(img, lang='chi_sim+eng') ``` **常用进阶** ```python import pytesseract from PIL import Image, ImageOps img = Image.open("data/banjiu_752.png") # 带置信度的结构化结果(做质量过滤/统计很有用) data = pytesseract.image_to_data(img, lang='chi_sim', output_type=pytesseract.Output.DICT) for txt, conf in zip(data['text'], data['conf']): if txt.strip() and int(conf) > 60: # conf: 0~100,越大越可信 print(conf, txt) # 预处理提识别率:灰度 + 自动对比度 + 放大(小字图尤效) img2 = ImageOps.autocontrast(img.convert('L')) img2 = img2.resize((img.width * 2, img.height * 2)) print(pytesseract.image_to_string(img2, lang='chi_sim')) # 找不到引擎时,手动指定二进制路径 pytesseract.pytesseract.tesseract_cmd = \ '/wks/python/micromamba/envs/nlp/bin/tesseract' ``` **要点** - 三大报错对号入座:`ModuleNotFoundError` → kernel 错环境;`TesseractNotFoundError` → 没装引擎;输出乱码 → lang 语言包不对 - 中文场景固定写法 `lang='chi_sim'`,混合中英用 `chi_sim+eng` - `image_to_data` 的 conf 可按阈值过滤低质量 OCR 结果 - 识别率低先做预处理:灰度、对比度、放大 2 倍,往往立竿见影
位置编码技巧
### 先纠正一个方向:维度越大,频率越**低** 原论文的公式(`d_model` 是 embedding 维度,`i` 是维度对的下标): ```text PE(pos, 2i) = sin( pos / 10000^(2i/d_model) ) PE(pos, 2i+1) = cos( pos / 10000^(2i/d_model) ) ``` 维度下标 `i` 越大,分母 `10000^(2i/d)` 越大,角频率越小,波长从 `2π` 一路拉长到约 `10000·2π`: | 维度对 i | 频率 | 波长 | 类比 | |---------|------|------|------| | i = 0(最低维对) | 1 | 2π ≈ 6.28 | 秒针:位置每走一步都在变 | | i 逐渐增大 | 1/10000^(2i/d) | 指数级拉长 | 分针 → 时针 | | i = d/2-1(最高维对) | 1/10000 | ≈ 62832 | 几乎不动,像"现在是第几天" | 这正是**二进制计数器的连续版**:低位翻转快、高位翻转慢。同一个 pos 的各维取值里,低维已经振荡了很多轮,高维还几乎是常数——低维记细节、高维记大局。 **特点** - 纯公式、零参数、不用训练;任意长度都能现算,理论上可外推 - 每个位置的编码唯一;且 `PE(pos+k)` 可写成 `PE(pos)` 的线性变换(旋转性质),相对关系有数学结构 - **解决的问题**:self-attention 是置换不变的——把输入顺序打乱,注意力输出不变,不注入位置信息就退化成"词袋模型" - **使用场景**:原始 Transformer 的 encoder/decoder、早期 NMT 模型;后来常被当作免训练的默认选择 **存在的问题** - 位置信息靠"加到 token embedding 上"注入,会被词义信号稀释,越到深层越弱 - 实测外推并不好:输入超过训练长度后注意力混乱、性能明显下降 - 编码的是绝对位置,token 间的相对距离要靠模型自己从加法结果里"悟" ```python import torch, math def sinusoidal_pe(max_len=200, d=16): pe = torch.zeros(max_len, d) pos = torch.arange(max_len).unsqueeze(1).float() div = torch.exp(torch.arange(0, d, 2).float() * (-math.log(10000.0) / d)) pe[:, 0::2] = torch.sin(pos * div) # 偶数维 sin pe[:, 1::2] = torch.cos(pos * div) # 奇数维 cos return pe pe = sinusoidal_pe() print(pe[:10, 0]) # 维度0:10 步内已振荡一圈多(周期≈6.28) print(pe[:10, 14]) # 维度14:周期≈2万,前10步几乎全在 0 附近 ```
### 学习式位置编码:把"位置"当成一张词表来查 做法:建一个 `nn.Embedding(max_len, d_model)`,第 0 ~ max_len-1 号位置各配一个**可训练**向量,前向时按位置下标查表,加到 token embedding 上。 **代表模型**:BERT(512 个位置)、GPT-2(1024)、GPT-3、原始 ViT(patch 位置编码)。 **特点** - 位置向量和词向量一视同仁,端到端从数据学出来,实现最简单 - 原论文 Table 3 (E) 的结论:与 sinusoidal 结果几乎无差别——"能不能学出来"并不是瓶颈 - **解决的问题**:免去手工设计;当位置先验特殊时(固定句式、ViT 的 2D 网格邻接关系)能学出更贴合任务的结构 - **使用场景**:输入长度可控的分类/匹配/NER(BERT 系微调)、固定窗口预训练(GPT-2) **存在的问题** - 长度焊死:BERT 只有 512 个位置向量,长文档只能截断或滑窗 - 完全不能外推:位置 5000 的向量从未被训练过,等于随机噪声 - 位置之间无结构约束:不保证"位置 5 与 6 的向量"比"5 与 500"更相近,泛化全靠数据量 - 多出 `max_len × d` 参数(BERT:512×768 ≈ 39 万,不算大但纯属多余) ```python import torch.nn as nn class LearnedPE(nn.Module): def __init__(self, d_model=768, max_len=512): super().__init__() self.pe = nn.Embedding(max_len, d_model) # 可训练的位置表 self.max_len = max_len def forward(self, x): # x: (B, L, d) assert x.size(1) <= self.max_len # 超长直接报错:无法外推 pos = torch.arange(x.size(1), device=x.device) return x + self.pe(pos).unsqueeze(0) ```
### 相对位置编码:注意力真正关心的是"离多远" **动机**:一个 token 的作用主要取决于它和邻居的**距离**,而不是"它在句子里的绝对编号"——绝对位置在变长、滑窗、padding 场景下反而不稳。 **思路**:不在输入端加位置向量,而是在注意力打分时加一个只依赖相对距离的偏置: ```text score(i, j) = q_i · k_j / √d + b(|i - j|) ``` **代表实现** - Transformer-XL:把 QK 打分做分解,显式引入相对距离项与两个全局向量 u、v - T5 相对偏置:把距离分桶(近处一对一、远处对数合并),每个"桶×注意力头"配一个可学习标量,直接加到 logits 上,**所有层共享**——简单稳定 - XLNet、DeBERTa(解耦注意力)同属这一路线 **特点** - 平移不变:整句平移后,token 间的打分不变 - 距离信息直达每一层的注意力,不用靠"输入端相加 + 网络自己悟" - **解决的问题**:变长输入更稳;片段级循环/滑窗训练(Transformer-XL)必需;T5 靠它在翻译/摘要上更稳 - **使用场景**:encoder-decoder 序列到序列(T5 系)、需要显式距离先验的任务、早期长文建模 **存在的问题** - 要改注意力内部打分逻辑,不能直接用现成的 `Q·K^T` 实现,KV cache、FlashAttention 的适配都要自己来 - 分桶是分段函数:远距离分辨率下降,桶数与边界是超参 - 偏置参数与头数绑定,共享与否需要实验调
### RoPE:用"绝对形式"实现"相对语义" **思路**:把 q、k 向量**两两一组**看成二维平面上的点,按 token 所在位置 pos 旋转角度 `pos · θ_i`。两个旋转后的向量做内积,出现的角度差是 `(pos_k - pos_q) · θ_i`——**结果只依赖相对位置**;但实现上每个 token 只拿自己的绝对位置去旋转,注意力结构一点没改。 `θ_i = 10000^(-2i/d)`,与 sinusoidal 同源的频率设计:低维转得快、高维转得慢。 **代表模型**:LLaMA 全系、Qwen、ChatGLM、GPT-NeoX、PaLM——当前开源 LLM 的事实标准。 **特点** - 相对语义 + 绝对实现:不改 `Q·K^T`,KV cache 逻辑与普通注意力一致,与 FlashAttention 等高效实现兼容 - 零新增参数;注意力随相对距离增大自然衰减,符合语言直觉 - **解决的问题**:让长度外推变成"可操作的旋转变换"——位置插值(PI)、NTK-aware scaling、YaRN 都是对旋转角度做缩放,把 4K 上下文扩到 32K/128K 的标准做法 - **使用场景**:现代 decoder-only LLM 的默认选择;一切要做长上下文扩展的模型 **存在的问题** - 依然有训练长度上限,裸外推照样崩,必须配缩放 + 继续训练 - 实现复杂度高于 learned PE(分组旋转、复数运算) - 频率表全层固定共享时,超远距离的分辨能力会饱和 ```python def rope_rotate(x, pos, base=10000.0): """x: (..., d),pos: 该 token 的位置;两两一组旋转""" d = x.shape[-1] theta = 1.0 / (base ** (torch.arange(0, d, 2).float() / d)) ang = pos * theta # 每对维度一个旋转角 x1, x2 = x[..., 0::2], x[..., 1::2] cos, sin = torch.cos(ang), torch.sin(ang) out = torch.empty_like(x) out[..., 0::2] = x1 * cos - x2 * sin out[..., 1::2] = x1 * sin + x2 * cos return out # q 在位置 m 旋转 m·θ,k 在位置 n 旋转 n·θ # 内积 <q_m, k_n> 只含 (m-n)·θ:绝对旋转、相对生效 ```
### ALiBi:不给位置向量,直接"罚"太远的注意力 Attention with Linear Biases:不注入任何位置编码,只在注意力 logits 上加线性惩罚 `score(i, j) = q_i·k_j/√d - m_h·|i - j|`,其中 `m_h` 是第 h 个头的斜率(按头几何分布,如 `2^(-8h/H)`)——离得越远,注意力被压得越低。 - **特点**:零参数、一行实现;不同头不同斜率 = 天然多尺度(有的头管近处、有的头管远处) - **解决的问题**:**训练短、推理长**的硬外推——训 1K 推 4K+,比 sinusoidal/RoPE 裸外推稳得多 - **使用场景**:BLOOM、MPT - **存在的问题**:强加"越远越不重要"的先验,长文检索类任务(要精确召回远处段落)可能吃亏;外推是硬撑,精度上限不如"插值 + 微调"的 RoPE 系 顺带一提 **NoPE**:decoder-only 的因果掩码本身携带位置信息,去掉位置编码也能学到相当的顺序感——位置编码从来不是"有没有"的问题,而是"够不够好用"。 ### 一张表选型 | 方式 | 注入位置 | 代表模型 | 外推能力 | 新增参数 | |------|---------|---------|---------|---------| | Sinusoidal | 输入端相加 | 原始 Transformer | 理论可、实测差 | 0 | | Learned | 输入端相加 | BERT / GPT-2 / ViT | 无,长度焊死 | max_len×d | | T5 相对偏置 | 注意力 logits | T5 | 分桶范围内尚可 | 桶数×头数 | | RoPE | 旋转 q/k | LLaMA / Qwen | 需缩放+微调,生态最好 | 0 | | ALiBi | 注意力 logits | BLOOM / MPT | 训短推长 | 每头一个斜率 | **选型建议** - 起新 LLM / 做长上下文:RoPE + 插值系(NTK/YaRN),工具链最全 - BERT 式分类、匹配、NER:learned 或 sinusoidal 都够,不必纠结 - 想不微调直接外推:ALiBi 值得一试 - 复现原始 Transformer 教学:sinusoidal,零参数最省事 **一条主线**:固定(sin)→ 可学(learned)→ 显式相对(T5)→ 绝对形式实现相对(RoPE)→ 干脆不加、只罚距离(ALiBi)——每一步都在补上一步"外推能力 / 相对性"的短板。
注意力机制技巧
### 打分函数三兄弟:相关性怎么算 注意力 = "按相关性加权求和",**打分函数**决定 q 和 k 的相关性怎么算,经典三种: | 打分方式 | 公式 | 出处 | |---------|------|------| | 加性注意力 | `v^T · tanh(W_q·q + W_k·k)` | Bahdanau 2014 | | 点积注意力 | `q · k`(Transformer 加了缩放) | Luong 2015 (dot) | | 双线性注意力 | `q^T · W · k` | Luong 2015 (general) | - **加性**:用一个小 MLP 打分。q、k 维度可以不同,非线性最灵活;场景:RNN seq2seq 的软对齐(经典机器翻译);解决的问题:把"硬对齐"变成"软对齐";存在的问题:逐对过 tanh 和两次矩阵乘,拼不成一次大矩阵乘,GPU 不友好、慢 - **点积**:一次 `Q·K^T` 算完所有 pair,GPU 上最快;d 大时要缩放(见下一节) - **双线性**:中间加一个可学投影 W,比裸点积多表达力、比加性快 - Transformer 选择**缩放点积**:纯矩阵乘实现,d_k 小时与加性效果相当,大 d_k 时只要记得缩放 ```python import torch import torch.nn.functional as F q = torch.randn(8, 32) # 8 个 query k = torch.randn(16, 32) # 16 个 key # 1) 点积:一次矩阵乘,最快(Transformer 的选择) score_dot = q @ k.transpose(-2, -1) # (8, 16) # 2) 双线性:加一个可学习投影 W W = torch.randn(32, 32) score_bi = q @ W @ k.transpose(-2, -1) # (8, 16) # 3) 加性:小 MLP 逐对打分——注意中间物化了 (8, 16, 32) 的三维张量 Wq, Wk, v = torch.randn(32, 32), torch.randn(32, 32), torch.randn(32) qi = (q @ Wq).unsqueeze(1) # (8, 1, 32) kj = (k @ Wk).unsqueeze(0) # (1, 16, 32) score_add = torch.tanh(qi + kj) @ v # (8, 16) w = F.softmax(score_dot / 32 ** 0.5, dim=-1) # 归一化成注意力权重 ```
### 一个函数写完 Transformer 的核心 ```text Attention(Q, K, V) = softmax( Q·K^T / √d_k ) · V ``` ```python import math, torch import torch.nn.functional as F def attention(Q, K, V, mask=None): """缩放点积注意力:Transformer 的核心,四步一个函数写完""" score = Q @ K.transpose(-2, -1) / math.sqrt(Q.size(-1)) # ① 打分并缩放 if mask is not None: score = score.masked_fill(mask == 0, float('-inf')) # ② 掩码屏蔽 w = F.softmax(score, dim=-1) # ③ 归一化成权重 return w @ V, w # ④ 加权求和 L = 5 causal = torch.tril(torch.ones(L, L, dtype=torch.bool)) # causal[i, j] = True 表示位置 i 允许看位置 j(j ≤ i): # 解码第 i 个 token 时,未来的内容必须不可见 ``` **为什么要除以 √d_k** - 假设 q、k 各分量是均值 0、方差 1 的独立随机变量,点积 `q·k` 的方差就是 `d_k` - `d_k = 64` 时 logits 标准差约 8:softmax 被推入饱和区——最大值吃掉几乎全部权重、梯度趋近 0 - 除以 `√d_k` 把方差拉回 1,训练才稳 **解决的问题** - 相比 RNN 逐词递推:全序列并行训练,任意两位置直连,长距离依赖不再靠梯度传 N 步 - 因果掩码让"并行训练"和"自回归解码"和平共处 **存在的问题** - 计算与显存都是 O(L²):序列翻倍、成本翻四倍 - softmax 要整行归一化,流式解码必须缓存历史 K/V(KV cache) - 注意力本身对顺序无感——这正是上一节位置编码存在的原因
### 多头注意力:几个视角各看各的 做法:把 `d_model` 切成 h 份(`d_k = d_model / h`),每头**独立**做投影 + 缩放点积注意力,结果拼接后再过一层线性投影。 ```python import torch.nn as nn class MHA(nn.Module): def __init__(self, d_model=512, h=8): super().__init__() assert d_model % h == 0 self.h, self.d_k = h, d_model // h self.wq = nn.Linear(d_model, d_model) self.wk = nn.Linear(d_model, d_model) self.wv = nn.Linear(d_model, d_model) self.wo = nn.Linear(d_model, d_model) def forward(self, x, mask=None): # x: (B, L, d_model) B, L, _ = x.shape def split(t): # (B,L,d) → (B,h,L,d_k) return t.view(B, L, self.h, self.d_k).transpose(1, 2) Q, K, V = split(self.wq(x)), split(self.wk(x)), split(self.wv(x)) out, _ = attention(Q, K, V, mask) # 复用上一节的函数 out = out.transpose(1, 2).reshape(B, L, -1) return self.wo(out) # 拼接所有头后投影 ``` **解决的问题** - 单头注意力输出是 V 的加权平均,一次只能关注一种模式;多头让不同子空间各学各的——有的头盯句法依存、有的盯共指、有的只看相邻位置(BERT 可视化研究的经典发现) - 切成 h 份并行后,总计算量与单头全宽基本相当,属于"免费午餐" **使用场景**:Transformer 全家的标配;多头也是 ViT 学多尺度、多通道纹理的来源。 **存在的问题** - 头之间没有多样性约束,实测不少头冗余可剪(结构压缩的常见入手点) - KV cache 随头数线性增长,成为自回归解码的显存与带宽大头(引出下一节 MQA/GQA)
### MQA / GQA:推理瓶颈从算力挪到 KV cache 自回归解码每生成一个 token,都要把**全部历史 K/V** 读一遍。MHA 每个头都有独立 K/V,缓存大小 = `2 × 层数 × L × d_model`: ```text LLaMA-7B(32 层,d=4096,fp16): 每 token 的 KV cache = 2 × 32 × 4096 × 2B = 512 KB 4K 上下文 → 约 2 GB,全花在"反复读"上 ``` - **MQA**(Multi-Query):所有 Q 头**共享一组** K/V → cache 直接缩小 h 倍;代表:PaLM。存在的问题:共享太狠,质量略降 - **GQA**(Grouped-Query):折衷——Q 头分组,每组共享一组 K/V(如 32 个 Q 头分 8 组,cache 缩 4 倍);代表:LLaMA-2 70B、LLaMA-3、Qwen2/3 系列。质量几乎追平 MHA,是当前 LLM 的主流配置 **解决的问题**:解码时的显存占用与访存带宽——生成速度的真正瓶颈。 **存在的问题**:分组数是新的超参;极端共享(MQA)在长上下文/细节任务上有损失;旧推理框架需要适配。
### O(L²) 吃不消长文档:四条出路 | 方法 | 复杂度 | 改的是什么 | 代表 | |------|--------|-----------|------| | 滑窗注意力 | O(L·w) | 注意力连接的结构(只看邻域+全局token) | Longformer / Swin | | 稀疏注意力 | O(L·(w+r+g)) | 边的分布:局部+随机+全局 | BigBird | | 线性注意力 | O(L·d²) | 打分函数:softmax 换成核函数 φ | Performer / RetNet | | FlashAttention | 仍是 O(L²),常数小 | 访存方式:分块计算,不物化 L×L 矩阵 | FA2/FA3,事实标配 | - **滑窗**(Longformer/Swin):每个 token 只看左右 ±w 个邻居,再留少量全局 token(如 BERT 的 [CLS] 位置);解决的问题:长文档 O(L²);存在的问题:窗口外信息要靠层层传递,隔多跳的依赖仍难 - **BigBird**:局部 + 随机 + 全局三种边混合,理论上证明保留了全连接的表达力(图上任意两点有短路径);存在的问题:模式是手工先验,随机边不可解释 - **线性注意力**:用核技巧改写 `softmax(QK^T)·V ≈ φ(Q)·(φ(K)^T·V)`——括号先算,复杂度从 O(L²) 降到 O(L·d²);存在的问题:softmax 的核近似有损,语言建模效果普遍略差 - **FlashAttention**:**不改数学,只改访存**——分块搬进 SRAM 计算、softmax 在线归一化,从不物化 L×L 中间矩阵:显存 O(L)、速度快 2~4 倍;它不是"另一种注意力",而是同一注意力的快速实现,已是训练与推理的默认底座 **选型建议**(长上下文一套组合拳):FlashAttention(快)+ GQA(省 cache)+ RoPE + 长度插值(扩窗口)——现代 LLM 的标准配方;学术尝鲜线性注意力时,盯住它"训练并行 + 推理常数"的双重优势与效果折损。 **一条主线**:打分函数从 MLP(加性)到矩阵乘(点积)→ 缩放救稳训练 → 多头换视角 → 共享 K/V 省带宽 → 改结构/改实现对付长序列——注意力这条线,一半在算"怎么算分",一半在算"怎么算得快、存得下"。
参考