# 文本分类实战 - 课堂讲义

> 本项目用**纯NumPy**实现文本分类，帮助学生理解文本向量化和神经网络的基本原理。
> 
> 类比：MNIST（图像）→ 全连接网络 → 数字分类，本项目是文本版。

---

## 目录

1. [实验概述](#1-实验概述)
2. [数据预处理：如何让计算机"读懂"文本](#2-数据预处理如何让计算机读懂文本)
3. [向量化方法：BoW 与 TF-IDF](#3-向量化方法bow-与-tf-idf)
4. [模型一：逻辑回归（Logistic Regression）](#4-模型一逻辑回归logistic-regression)
5. [模型二：多层感知机（MLP）](#5-模型二多层感知机mlp)
6. [训练过程：梯度下降与反向传播](#6-训练过程梯度下降与反向传播)
7. [数据不平衡问题与解决](#7-数据不平衡问题与解决)
8. [实验操作指南](#8-实验操作指南)
9. [预测新文本](#9-预测新文本)

---

## 1. 实验概述

### 1.1 任务

对中文酒店评论进行**情感分类**：
- 正面评论（好评）
- 负面评论（差评）

### 1.2 数据集

**ChnSentiCorp**（中文酒店评论数据集）
- 总评论数：7765条
- 正面评论：5322条（68.5%）
- 负面评论：2443条（31.5%）

数据集已内置，程序会自动下载。

### 1.3 整体流程

```
原始文本 → 分词 → 向量化 → 模型训练 → 预测
   "酒店很好"  → ["酒店", "很好"] → [0.3, 0.8, ...] → 正面
```

### 1.4 代码文件

| 文件 | 作用 |
|-----|------|
| `config.py` | 所有超参数配置（改这里来调整实验） |
| `dataset.py` | 数据加载、分词、向量化 |
| `model_numpy.py` | 逻辑回归和MLP模型实现 |
| `train.py` | 训练和对比实验 |
| `predict.py` | 加载模型预测新文本 |

---

## 2. 数据预处理：如何让计算机"读懂"文本

### 2.1 为什么文本不能直接用于计算？

计算机只能处理数字，不能直接处理文字。我们需要把文本转换成数字向量。

### 2.2 分词

**原理**：把连续的中文文本切成离散的词。

```python
# 示例
文本: "酒店服务很好"
分词: ["酒店", "服务", "很好"]
```

本项目使用 `jieba` 库进行分词：

```python
import jieba

text = "酒店服务很好"
words = jieba.lcut(text)
print(words)  # ['酒店', '服务', '很好']
```

**注意**：过滤掉单字（如"的"、"了"），因为信息量太少。

```python
words = [w for w in words if len(w) > 1]  # 过滤单字
```

### 2.3 构建词表

**原理**：把所有评论中的词收集起来，编上序号。

```python
# 词表示例
{
    "酒店": 0,
    "服务": 1,
    "很好": 2,
    "房间": 3,
    ...
}
```

词表大小由 `MAX_FEATURES` 控制（本项目设为3000），只保留出现频率最高的3000个词。

---

## 3. 向量化方法：BoW 与 TF-IDF

把分词后的文本转换成数字向量。

### 3.1 BoW（词袋模型）

**原理**：统计每个词出现的次数。

```
文本: "酒店 服务 很好 服务"
分词: ["酒店", "服务", "很好", "服务"]
词表: {"酒店":0, "服务":1, "很好":2, "不错":3, ...}

向量: [1, 2, 1, 0, ...]  # 酒店出现1次，服务出现2次，很好出现1次
```

**代码位置**：`dataset.py` 中的 `BoWVectorizer` 类

```python
class BoWVectorizer:
    def transform(self, text):
        words = tokenize(text)
        vec = [0] * MAX_SEQ_LEN
        for i, word in enumerate(words[:MAX_SEQ_LEN]):
            if word in self.vocab:
                vec[i] = 1  # 也可以用词频 tf[word]
        return vec
```

**问题**：所有词权重相同，导致常见词（如"的"、"是"）主导。

### 3.2 TF-IDF（词频-逆文档频率）

**原理**：给每个词赋予重要程度权重。

```
TF(词频) = 词在本文中出现的次数
IDF(逆文档频率) = log(总文档数 / 包含该词的文档数)

TF-IDF = TF × IDF
```

**直观理解**：
- 一个词在本文中出现越多 → TF越高 → 越重要
- 一个词在所有文档中越常见 → IDF越低 → 越不重要

```
例子：
- "酒店"：在100篇评论中出现80篇 → IDF = log(100/80) ≈ 0.22
- "惊喜"：在100篇评论中出现5篇 → IDF = log(100/5) ≈ 3.0

"惊喜"虽然少见，但信息量大，IDF更高
```

**代码位置**：`dataset.py` 中的 `TFIDFVectorizer` 类

```python
class TFIDFVectorizer:
    def transform(self, text):
        words = tokenize(text)
        tf = Counter(words)  # 词频
        tf_sum = len(words)
        
        vec = [0.0] * MAX_SEQ_LEN
        for i, word in enumerate(words[:MAX_SEQ_LEN]):
            if word in self.vocab:
                # TF × IDF
                vec[i] = (tf[word] / tf_sum) * self.idf.get(word, 0)
        return vec
```

### 3.3 两种方法对比

| 特性 | BoW | TF-IDF |
|-----|-----|--------|
| 公式 | 词频 | TF × IDF |
| 常见词权重 | 相同（偏高） | 降低 |
| 罕见词权重 | 相同（偏低） | 提升 |
| 计算复杂度 | 低 | 稍高 |
| 效果 | 一般 | 通常更好 |

---

## 4. 模型一：逻辑回归（Logistic Regression）

### 4.1 模型结构

最简单的线性分类器：

```
输入 [batch, features]
    │
    ▼
线性变换: Z = X @ W + b
    │
    ▼
Softmax → 概率
    │
    ▼
输出 [batch, 2]  # [负面概率, 正面概率]
```

### 4.2 线性变换

```python
Z = X @ W + b

# 例子：
# X: [1, 3000] (一个样本，3000维特征)
# W: [3000, 2] (权重矩阵)
# b: [2] (偏置)
# Z: [1, 2] (输出 logits)
```

### 4.3 Softmax

把 logits 转换成概率（和为1）：

```python
def softmax(x):
    exp_x = np.exp(x - np.max(x))  # 减最大值防溢出
    return exp_x / np.sum(exp_x, axis=1, keepdims=True)

# 示例
logits = [2.0, 1.0]
probs = softmax(logits)
# probs = [0.731, 0.269]
# 解释：正面概率73.1%，负面概率26.9%
```

### 4.4 代码实现

```python
class LogisticRegression:
    def __init__(self, input_size, num_classes=2):
        self.W = np.random.randn(input_size, num_classes) * 0.01
        self.b = np.zeros(num_classes)
    
    def forward(self, X):
        z = X @ self.W + self.b
        return softmax(z)
    
    def backward(self, X, y):
        # 梯度计算和参数更新
        ...
```

### 4.5 参数量

```
W: input_size × num_classes = 3000 × 2 = 6000
b: num_classes = 2
总计: 6002
```

---

## 5. 模型二：多层感知机（MLP）

### 5.1 模型结构

比逻辑回归多了一层隐藏层和非线性激活：

```
输入 [batch, features]
    │
    ▼
线性变换: Z1 = X @ W1 + b1
    │
    ▼
ReLU激活: A1 = max(0, Z1)
    │
    ▼
线性变换: Z2 = A1 @ W2 + b2
    │
    ▼
Softmax → 概率
    │
    ▼
输出 [batch, 2]
```

### 5.2 ReLU激活函数

```python
def relu(x):
    return np.maximum(0, x)

# 示例
relu([1, -2, 3, -1]) = [1, 0, 3, 0]
```

**作用**：引入非线性，让模型能学习复杂模式。

### 5.3 参数量

```
W1: input_size × hidden = 3000 × 64 = 192000
b1: hidden = 64
W2: hidden × num_classes = 64 × 2 = 128
b2: num_classes = 2
总计: 192194
```

### 5.4 与视觉CNN的类比

| 视觉（全连接） | 文本 |
|--------------|------|
| 输入: 784维像素 | 输入: 3000维词向量 |
| 隐藏层: 128神经元 | 隐藏层: 64神经元 |
| 输出: 10类数字 | 输出: 2类情感 |
| ReLU + Softmax | ReLU + Softmax |

---

## 6. 训练过程：梯度下降与反向传播

### 6.1 训练流程

```
for epoch in 轮数:
    for batch in 数据:
        1. 前向传播: 计算输出概率
        2. 计算损失: CrossEntropy(probs, labels)
        3. 反向传播: 计算梯度
        4. 更新参数: W = W - lr × 梯度
```

### 6.2 损失函数：交叉熵

```python
def cross_entropy_loss(probs, y):
    # probs: 预测概率
    # y: 真实标签
    loss = -np.log(probs[y])  # 正确类的概率越大，损失越小
    return loss
```

### 6.3 梯度下降

```python
# 简单示例：单参数
loss = f(w)  # 损失是参数的函数
gradient = (loss(w + epsilon) - loss(w)) / epsilon  # 数值梯度

# 解析梯度
w = w - learning_rate * gradient
```

### 6.4 反向传播（BP）

链式法则，从后往前计算梯度：

```
损失 → Softmax → 线性变换 → ReLU → 线性变换 → 输入
  ↓
链式求导
  ↓
各层梯度 = 损失对各层参数的偏导
```

### 6.5 训练日志解读

```
Epoch  20/100 | Loss: 0.5844 | 训练准确率: 0.6851 | 测试准确率: 0.6864
           │           │                │                │
           │           │                │                └─ 测试集上的表现
           │           │                └─ 训练集上的表现
           │           └─ 损失值（越小越好）
           └─ 当前轮数/总轮数
```

---

## 7. 数据不平衡问题与解决

### 7.1 问题

本数据集正负比例约 7:3，模型可能"偷懒"：

| 策略 | 结果 | 准确率 |
|-----|------|--------|
| 不使用技巧，总是预测正面 | 简单但无效 | 68.5%（假高分） |
| 使用类别权重，认真学习 | 难但有效 | 46%（真学习） |

### 7.2 类别权重

**原理**：给少数类更高的权重，让模型更"怕"漏判少数类。

```python
# 计算权重
n_samples = 7765  # 总样本数
n_pos = 5322       # 正面样本数
n_neg = 2443       # 负面样本数

weight_pos = n_samples / (2 * n_pos) = 0.73  # 正面权重（样本多，权重小）
weight_neg = n_samples / (2 * n_neg) = 1.59  # 负面权重（样本少，权重大）

# 梯度更新时
d_z[y] -= class_weight[y]  # 负面样本的梯度更大
```

### 7.3 开关配置

在 `config.py` 中：

```python
USE_CLASS_WEIGHT = True   # 开启类别权重
USE_CLASS_WEIGHT = False  # 关闭（总是预测正面）
```

### 7.4 实验对比

| 配置 | 测试准确率 | 预测分布 | 说明 |
|-----|----------|---------|------|
| 关闭权重 | 68.6% | 全预测正面 | 模型偷懒 |
| 开启权重 | 46.4% | 有正有负 | 模型在学习 |

**结论**：68%准确率是"假"高分，46%是"真"学习。数据不平衡问题没有银弹。

---

## 8. 实验操作指南

### 8.1 安装依赖

```bash
pip install numpy jieba
```

### 8.2 训练模型

```bash
python main.py
```

### 8.3 修改配置

编辑 `config.py`：

```python
# 选择模型
MODEL_TYPE = 'mlp'       # 'lr' 或 'mlp'
VECTORIZER_TYPE = 'tfidf'  # 'bow' 或 'tfidf'

# 开关类别权重
USE_CLASS_WEIGHT = True   # 或 False

# 调整超参数
NUM_EPOCHS = 100          # 训练轮数
LEARNING_RATE = 0.05       # 学习率
HIDDEN_SIZE = 64           # MLP隐藏层大小
```

### 8.4 运行对比实验

```python
RUN_COMPARISON = True  # 开启
```

会自动进行：
1. BoW vs TF-IDF 对比
2. LR vs MLP 对比
3. 学习率对比
4. 隐藏层大小对比

### 8.5 训练输出示例

```
============================================================
训练配置:
  模型: MLP
  向量: TF-IDF
  学习率: 0.05
  隐藏层大小: 64
  训练轮数: 100
============================================================
  类别权重: 正面=0.73, 负面=1.59
MLP: 100 -> 64 -> 2, 参数量: 6594
Epoch  20/100 | Loss: 0.6694 | 训练准确率: 0.4598 | 测试准确率: 0.4662
...
最终结果:
  训练准确率: 0.4596
  测试准确率: 0.4668
  训练时间: 2.95秒
模型已保存: model_mlp_tfidf_weighted_0427_212802_*.npy
```

---

## 9. 预测新文本

### 9.1 使用方法

```bash
python predict.py
```

### 9.2 操作流程

```
1. 程序列出已保存的模型
2. 输入编号选择模型
3. 输入评论文本
4. 查看预测结果
```

### 9.3 示例

```
请选择模型编号 (1-1): 1

请输入评论文本: 酒店服务很好，环境也不错
预测结果: 正面
置信度: 99.7%
详细: 正面概率=99.7%, 负面概率=0.3%

请输入评论文本: 房间太小，卫生很差
预测结果: 负面
置信度: 85.2%
详细: 正面概率=14.8%, 负面概率=85.2%
```

### 9.4 权重文件命名

每次训练生成唯一的文件名：

```
model_mlp_tfidf_weighted_0427_212802_W1.npy
model_mlp_tfidf_weighted_0427_212802_b1.npy
model_mlp_tfidf_weighted_0427_212802_W2.npy
model_mlp_tfidf_weighted_0427_212802_b2.npy
```

文件名包含：模型类型、向量类型、权重开关、时间戳

---

## 10. 思考题

1. **向量化**：为什么TF-IDF通常比BoW效果好？
2. **模型复杂度**：MLP比LR多了一层，带来的优势是什么？
3. **数据不平衡**：68%准确率一定好吗？有什么陷阱？
4. **类别权重**：开启后准确率反而下降，这说明什么？
5. **调参实践**：学习率过大会怎样？隐藏层太小会怎样？

---

## 附录：完整代码流程图

```
                    ┌─────────────┐
                    │   config.py │
                    │  (超参数)    │
                    └──────┬──────┘
                           │
                           ▼
┌─────────────────────────────────────────┐
│              dataset.py                  │
│  ┌───────────┐    ┌──────────────────┐ │
│  │ 下载数据  │───▶│ TF-IDF/BoW向量化  │ │
│  └───────────┘    └────────┬─────────┘ │
│                            │            │
└────────────────────────────┼────────────┘
                             ▼
                    ┌────────────────┐
                    │   特征向量 X    │
                    │   标签 y        │
                    └────────┬───────┘
                             │
                             ▼
┌─────────────────────────────────────────┐
│            model_numpy.py                │
│  ┌───────────────────────────────────┐ │
│  │  LogisticRegression / MLP         │ │
│  │  - forward(): 前向传播            │ │
│  │  - backward(): 反向传播           │ │
│  │  - fit(): 训练循环                │ │
│  └───────────────────────────────────┘ │
└────────────────────────────┬────────────┘
                             │
                             ▼
                    ┌────────────────┐
                    │   保存权重     │
                    │  model_*.npy   │
                    └────────┬───────┘
                             │
                             ▼
                    ┌────────────────┐
                    │  predict.py   │
                    │  (加载预测)    │
                    └───────────────┘
```
