|
### 数据探索:先看统计,再看分布
拿到文本数据后,先用 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` 才涉及字节层——乱码只发生在"字节 → 字符"这一步
|
|
### 现象:`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 省带宽 → 改结构/改实现对付长序列——注意力这条线,一半在算"怎么算分",一半在算"怎么算得快、存得下"。
|