夜雨聆风学习资料网

ARTICLE · 1034764

AI + 量化开发工程师学习笔记 Day 11:完整训练流程——亲手训练第一个 NLP 情感分类模型

AI + 量化开发工程师学习笔记 Day 11:完整训练流程——亲手训练第一个 NLP 情感分类模型

前 10 天,我们已经从神经网络一路走到了 GPT 的 Loss:

文本

Tokenizer

Token IDs

Embedding

Transformer

Logits

Cross Entropy

Loss

Day10 最后还留下了半条链:

Loss

???

模型参数改变

预测越来越准确

今天就把这个 ??? 完整解决:

Forward

Loss

zero_grad()

backward()

optimizer.step()

重复训练

而且今天不是只看公式。

我们会真正训练一个 NLP 情感分类小模型。

今天结束以后,你应该第一次能完整回答:

“一个 PyTorch 模型从随机初始化,到学会分类,代码到底经历了什么?”


一、先明确今天要训练什么

我们先不训练 GPT。

GPT 正式训练留到 Day18。

今天做一个小得多、但训练流程完全真实的任务:

金融文本情感分类

输入:

公司净利润同比大幅增长

输出:

正面

输入:

公司预计全年出现重大亏损

输出:

负面

也就是:

金融文本

Tokenizer

Embedding

Pooling

Linear

Logits

Positive / Negative

这实际上已经是未来:

  • 新闻情绪因子
  • 公告情绪因子

的最小雏形。


二、为什么 Day11 不直接训练 Transformer?

因为今天真正要学习的是:

Training Loop。

如果同时加入:

  • Transformer
  • Multi-Head Attention
  • Mask
  • Position
  • DataLoader
  • Scheduler
  • Dropout

反而容易把训练本身淹没。

所以今天模型故意保持简单:

Token IDs

Embedding

Mean Pooling

Linear

2个 Logits

等训练机制彻底吃透以后:

  • Day16 → 组装 MiniGPT
  • Day18 → 真正训练 MiniGPT

三、第一步:准备数据

今天先人为准备一个很小的数据集。

正面:

  • 公司业绩大幅增长
  • 净利润同比增长
  • 公司获得重大订单
  • 营业收入创新高
  • 公司上调全年业绩预期

负面:

  • 公司业绩大幅下降
  • 净利润同比下滑
  • 公司出现重大亏损
  • 营业收入明显下降
  • 公司下调全年业绩预期

标签:

Positive = 1
Negative = 0

所以训练数据本质:

Text                         Label

公司业绩大幅增长                1
公司出现重大亏损                0
净利润同比增长                  1
净利润同比下滑                  0
...

四、什么是 Label?

Day10 GPT 的 Target 是:

正确的下一个 Token ID

今天分类模型的 Target 是:

正确类别 ID

例如:

Negative = 0
Positive = 1

模型输出:

[negative_logit, positive_logit]

例如:

[-1.2, 3.7]

意味着模型更倾向:

Positive

然后:

Logits
+
Label

Cross Entropy

Loss

五、先做一个最简单 Tokenizer

今天暂时不用 Hugging Face。

因为我们的目标是看清完整训练流程。

使用:

字符级 Tokenizer

例如:

公司业绩增长

拆成:







然后建立 Vocabulary:

<PAD> → 0
<UNK> → 1
公 → 2
司 → 3
业 → 4
绩 → 5
增 → 6
长 → 7
...

最终:

公司业绩增长



[2, 3, 4, 5, 6, 7]

六、为什么需要 PAD?

不同文本长度不同:

公司盈利

可能:

4 Token

而:

公司预计全年净利润大幅增长

可能:

13 Token

神经网络 Batch 通常希望统一长度。

假设:

max_len = 16

短句:

[2, 3, 4, 5]

补成:

[2, 3, 4, 5, 0, 0, 0, 0, ...]

其中:

0 = PAD

Day4 学过的 Padding 今天真正开始进入训练。


七、完整数据准备代码

创建:

day11_sentiment.py

先写数据:

import torch
import torch.nn as nn
import torch.nn.functional as F


torch.manual_seed(42)


samples = [
    ("公司业绩大幅增长"1),
    ("净利润同比增长"1),
    ("公司获得重大订单"1),
    ("营业收入创新高"1),
    ("公司上调全年业绩预期"1),
    ("主营业务持续增长"1),
    ("公司盈利能力明显改善"1),
    ("产品销售收入快速增长"1),

    ("公司业绩大幅下降"0),
    ("净利润同比下滑"0),
    ("公司出现重大亏损"0),
    ("营业收入明显下降"0),
    ("公司下调全年业绩预期"0),
    ("主营业务持续恶化"0),
    ("公司盈利能力明显下降"0),
    ("产品销售收入大幅下滑"0),
]

八、建立 Vocabulary

PAD_TOKEN = "<PAD>"
UNK_TOKEN = "<UNK>"


vocab = {
    PAD_TOKEN: 0,
    UNK_TOKEN: 1
}


for text, _ in samples:

for char in text:

if char notin vocab:

            vocab[char] = len(vocab)


print(
"Vocabulary Size:",
    len(vocab)
)

现在:

文字

字符

Vocabulary

Token ID

九、实现 encode()

max_len = 16


defencode(text):

    ids = []

for char in text:

        token_id = vocab.get(
            char,
            vocab[UNK_TOKEN]
        )

        ids.append(token_id)


# 截断

    ids = ids[:max_len]


# Padding

while len(ids) < max_len:

        ids.append(
            vocab[PAD_TOKEN]
        )


return ids

测试:

print(
    encode(
"公司业绩增长"
    )
)

应该得到:

[若干Token ID, 0, 0, 0, ...]

十、把训练数据变成 Tensor

X = torch.tensor([
    encode(text)
for text, label
in samples
])


y = torch.tensor([
    label
for text, label
in samples
])


print(
"X Shape:",
    X.shape
)

print(
"y Shape:",
    y.shape
)

应该:

X Shape:
[16, 16]

y Shape:
[16]

解释:

16 条文本 × 每条 16 个 Token


十一、今天的模型结构

模型非常简单:

Token IDs

[B, T]



Embedding

[B, T, C]



Mean Pooling

[B, C]



Linear

[B, 2]



Logits

为什么要 Pooling?

因为一句话有:

T 个 Token

但最终只需要:

一个情绪分类

所以需要把:

T 个 Token 表示

压成:

一个句子表示

今天使用最简单的:

Mean Pooling


十二、什么是 Mean Pooling?

假设三个 Token:

Token1 = [1, 2, 3]
Token2 = [2, 3, 4]
Token3 = [3, 4, 5]

平均:

Sentence Vector
=
[2, 3, 4]

也就是:

把所有 Token Embedding 求平均。

不过这里有一个问题:

PAD

不能算进去。

否则 Padding 会污染句子表示。

所以我们要做:

Masked Mean Pooling


十三、定义情感分类模型

classSentimentModel(nn.Module):

def__init__(
        self,
        vocab_size,
        embed_dim=32
    )
:

        super().__init__()

        self.embedding = nn.Embedding(
            vocab_size,
            embed_dim,
            padding_idx=0
        )

        self.classifier = nn.Linear(
            embed_dim,
2
        )


defforward(self, x):

# x:
# [B, T]

        embeddings = self.embedding(
            x
        )

# [B, T, C]


# PAD Mask
        mask = (
            x != 0
        ).unsqueeze(-1)

# [B, T, 1]


        embeddings = (
            embeddings
            *
            mask
        )


# 所有有效 Token 相加

        summed = embeddings.sum(
            dim=1
        )

# [B, C]


# 有效 Token 数量

        lengths = mask.sum(
            dim=1
        ).clamp(
            min=1
        )

# [B, 1]


# Mean Pooling

        pooled = (
            summed
            /
            lengths
        )

# [B, C]


        logits = self.classifier(
            pooled
        )

# [B, 2]

return logits

十四、一定要看懂 Shape

输入:

X
[16, 16]

Embedding:

[16, 16, 32]

Pooling:

[16, 32]

Linear:

[16, 2]

所以:

[B, T]

[B, T, C]

[B, C]

[B, 2]

最后:

2

代表:

Negative
Positive

十五、创建模型

model = SentimentModel(
    vocab_size=len(vocab),
    embed_dim=32
)

看看模型:

print(model)

大概:

SentimentModel(
    embedding
    classifier
)

这已经是一个真正可以训练的神经网络。


十六、Loss

今天是:

二分类

但模型输出两个 Logits:

Negative
Positive

所以使用:

criterion = nn.CrossEntropyLoss()

和 Day10 完全一致。


十七、Optimizer

今天暂时使用:

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=0.01
)

为什么 Adam 好?

今天先把它当成:

负责根据梯度修改参数的优化器。

Day13 会专门拆开:

  • SGD
  • Momentum
  • Adam
  • AdamW

今天不要抢 Day13 内容。


十八、终于到了今天最核心的 Training Loop

epochs = 300


for epoch in range(epochs):

# =====================
# 1. Forward
# =====================

    logits = model(X)


# =====================
# 2. Loss
# =====================

    loss = criterion(
        logits,
        y
    )


# =====================
# 3. 清空旧梯度
# =====================

    optimizer.zero_grad()


# =====================
# 4. Backward
# =====================

    loss.backward()


# =====================
# 5. 更新参数
# =====================

    optimizer.step()


if epoch % 30 == 0:

        print(
f"Epoch {epoch:3d} "
f"Loss={loss.item():.6f}"
        )

这就是今天最重要的代码。


十九、逐行理解 Training Loop

1. Forward

logits = model(X)

模型:

输入

Embedding

Pooling

Linear

Logits

2. Loss

loss = criterion(
    logits,
    y
)

告诉模型:

你预测得有多差

3. zero_grad()

optimizer.zero_grad()

为什么?

因为 PyTorch 默认:

梯度会累加。

如果上一次:

grad = 0.3

这次:

grad = 0.2

不清空可能变成:

0.5

但普通训练每个 step 通常希望使用当前 batch 的梯度。

所以先:

Gradient → 0

二十、为什么 PyTorch 默认累加梯度?

因为有时候我们真的需要:

Gradient Accumulation

例如 GPU 一次只能放:

Batch = 8

但我们希望等效:

Batch = 32

可以:

Batch1

Backward
不更新

Batch2

Backward
不更新

Batch3

Backward
不更新

Batch4

Backward



optimizer.step()

把四次梯度累积起来。

以后训练大模型时这个非常有用。

但今天普通训练:

optimizer.zero_grad()

每轮清空。


二十一、backward()

loss.backward()

计算:

Loss

对所有参数的:

Gradient

例如:

  • Embedding Weight
  • Classifier Weight
  • Classifier Bias

都会获得 .grad


二十二、optimizer.step()

optimizer.step()

这才真正:

修改模型参数。

所以再次强调:

loss.backward()
=
算梯度

optimizer.step()
=
改参数

这是非常常见的面试题,也是真正训练模型时必须形成肌肉记忆的区别。


二十三、一个 Epoch 到底是什么?

今天:

X

包含全部:

16 条训练数据

我们一次全部输入。

所以:

完整看一遍全部训练数据
=
1 Epoch

训练:

epochs = 300

意味着模型把整个数据集看了:

300 遍

注意:

以后使用 DataLoader 后:

一个 Epoch

会包含很多:

Batch / Step

Day12 专门讲。


二十四、Epoch、Batch、Step 的关系

先提前建立概念:

假设:

Dataset = 1000
Batch Size = 100

那么:

1 Epoch
=
10 Batch
=
10 Step

训练:

20 Epoch

就是:

200 Step

所以:

  • Epoch= 完整遍历数据集一次
  • Batch= 一次送进模型的数据
  • Step= 一次参数更新

Day12 会详细实践。


二十五、运行训练

运行:

python day11_sentiment.py

你应该看到类似趋势:

Epoch   0 Loss=0.72

Epoch  30 Loss=0.30

Epoch  60 Loss=0.08

Epoch  90 Loss=0.02

...

Epoch 270 Loss=0.001

具体数值可能不同。

关键不是数字,而是:

Loss

不断下降

这意味着:

模型正在越来越适应训练数据。

注意我特意说的是:

适应训练数据

而不是:

模型一定越来越聪明。

这个区别 Day14 讲 Overfitting 时非常重要。


二十六、训练完成后怎么预测?

写:

defpredict(text):

    model.eval()

    ids = torch.tensor([
        encode(text)
    ])

with torch.no_grad():

        logits = model(
            ids
        )

        probs = F.softmax(
            logits,
            dim=-1
        )

        pred = torch.argmax(
            probs,
            dim=-1
        ).item()


    label = (
"正面"
if pred == 1
else"负面"
    )

return (
        label,
        probs[0].tolist()
    )

二十七、测试模型

test_texts = [
"公司净利润大幅增长",
"公司出现严重亏损",
"营业收入持续增长",
"公司盈利明显下降",
]


for text in test_texts:

    label, probs = predict(
        text
    )

    print(
        text,
"->",
        label,
        probs
    )

你可能看到:

公司净利润大幅增长
→ 正面

公司出现严重亏损
→ 负面

营业收入持续增长
→ 正面

公司盈利明显下降
→ 负面

但是由于数据只有 16 条:

不要把结果好看理解成模型已经拥有真正的金融语义理解能力。

这是一个教学模型。


二十八、model.train() 和 model.eval() 有什么区别?

PyTorch 模型有两种工作模式:

model.train()

表示:

训练模式

而:

model.eval()

表示:

评估 / 推理模式

现在我们的模型只有:

  • Embedding
  • Pooling
  • Linear

所以两者暂时看不出明显差异。

但是以后加入:

  • Dropout
  • BatchNorm

以后,两种模式行为会不同。

例如 Dropout:

model.train()

神经元随机丢弃

而:

model.eval()

关闭 Dropout

所以推荐养成习惯:

# 训练
model.train()

# 推理
model.eval()

注意:

model.eval()不会关闭梯度计算。

所以推理时我们通常还写:

with torch.no_grad():

两者作用不同:

model.eval()
→ 改变某些层的运行行为

torch.no_grad()
→ 不构建梯度计算图

二十九、把 Training Loop 写得更规范

刚才我们的代码:

for epoch in range(epochs):

    logits = model(X)

    loss = criterion(
        logits,
        y
    )

    optimizer.zero_grad()

    loss.backward()

    optimizer.step()

更规范一点:

for epoch in range(epochs):

# 进入训练模式
    model.train()

# 清梯度
    optimizer.zero_grad()

# Forward
    logits = model(X)

# Loss
    loss = criterion(
        logits,
        y
    )

# Backward
    loss.backward()

# Update
    optimizer.step()

你可能发现:

optimizer.zero_grad()

被我移到了 Forward 前面。

两种写法在这里都可以。

我更建议以后形成固定习惯:

zero_grad

forward

loss

backward

step

三十、一个 Step 到底发生了什么?

我们把这一轮彻底拆开。

初始参数:

W₀

Step 1:Forward

X

Model(W₀)

Logits

Step 2:计算 Loss

Logits
+
Target

CrossEntropy

Loss = 0.73

Step 3:Backward

计算:

∂Loss / ∂W

得到:

Gradient

它告诉优化器:

参数往哪个方向变化,可以让 Loss 下降。

Step 4:Optimizer

W₁
=
W₀
-
更新量

参数从:

W₀

变成:

W₁

Step 5:再次 Forward

X

Model(W₁)

新的 Logits

新的 Loss

如果训练正常:

Loss
0.73

0.68

0.59

0.42

...

不断下降。

所以:

训练不是模型突然“懂了”,而是参数经过大量微小更新,逐渐形成能够降低 Loss 的映射关系。


三十一、今日实践升级版:亲眼看参数改变

这个实验我非常建议你运行。

创建:

day11_parameter_update.py

代码:

import torch
import torch.nn as nn


torch.manual_seed(42)


model = nn.Linear(
2,
2
)


criterion = nn.CrossEntropyLoss()


optimizer = torch.optim.SGD(
    model.parameters(),
    lr=0.1
)


X = torch.tensor([
    [1.02.0]
])


y = torch.tensor([
1
])


print("训练前 Weight:")

print(
    model.weight.data.clone()
)


# ==========================
# 一个完整 Training Step
# ==========================

model.train()

optimizer.zero_grad()


logits = model(
    X
)


loss = criterion(
    logits,
    y
)


loss.backward()


print(
"\nGradient:"
)

print(
    model.weight.grad
)


optimizer.step()


print(
"\n训练后 Weight:"
)

print(
    model.weight.data
)


print(
"\nLoss:"
)

print(
    loss.item()
)

你会真正看到:

训练前:

Weight A

执行:

loss.backward()

产生:

Gradient

执行:

optimizer.step()

以后:

训练后:

Weight B

并且:

A ≠ B

这就是:

学习

从计算机角度说,没有任何神秘之处:

参数

Gradient

修改参数

三十二、再做一个更漂亮的实验:记录 Loss Curve

修改情感模型训练代码:

loss_history = []


for epoch in range(epochs):

    model.train()

    optimizer.zero_grad()

    logits = model(
        X
    )

    loss = criterion(
        logits,
        y
    )

    loss.backward()

    optimizer.step()


    loss_history.append(
        loss.item()
    )

训练完成:

import matplotlib.pyplot as plt


plt.plot(
    loss_history
)

plt.xlabel(
"Epoch"
)

plt.ylabel(
"Loss"
)

plt.title(
"Training Loss"
)

plt.show()

你应该看到大致:

Loss
│\
│ \
│  \
│   \
│    \____

└──────────── Epoch

这张图以后非常重要。

真正训练模型的时候,我们不会只看:

最后 Loss = 0.01

而是看:

Loss Curve

因为曲线可以告诉我们很多问题:

  • Loss 不下降 → 模型没学会
  • Loss 剧烈震荡 → Learning Rate 可能过大
  • Train Loss 下降、Validation Loss 上升 → 可能 Overfitting

这些 Day14、Day15 会继续研究。


三十三、现在有一个严重的问题

我们的训练:

model(X)

一次把全部:

16 条数据

送进模型。

真实数据可能:

100 万条新闻

显然不能:

model(
100万条数据
)

一次全部送 GPU。

所以需要:

Dataset

DataLoader

Batch

例如:

100万条数据

Batch Size = 64

每次训练 64 条

训练完一个 Batch

再拿下一个 Batch

这就是:

Mini-Batch Training

而这正是 Day12 的主题。


三十四、今天这个模型与未来量化有什么关系?

现在我们的模型:

金融文本

Embedding

Pooling

Classifier

Positive / Negative

未来真正做新闻因子:

财经新闻

LLM / Transformer

情绪分析

Sentiment Score

例如:

新闻A
→ +0.82

新闻B
→ -0.71

公告C
→ +0.35

然后:

股票

一天所有新闻

聚合

News Sentiment Factor

最终:

PE
PB
ROE
Momentum
News Sentiment
Announcement Event
        ↓
   Quant Model
        ↓
Future Return

所以今天虽然只是一个 16 条数据的小模型,它已经第一次把:

NLP

和:

Quant

真正连起来了。


三十五、一个非常重要的提醒:训练集预测准不代表模型好

我们的 16 条数据:

训练

Loss ≈ 0

然后测试:

公司净利润大幅增长
→ 正面

看起来非常厉害。

但可能只是:

Memorization

也就是:

把训练数据记住了。

真正需要的是:

Generalization

例如模型训练从没见过:

公司前三季度盈利水平显著改善

它仍然判断:

正面

这才叫泛化。

所以真正机器学习必须:

Dataset

├── Training Set

├── Validation Set

└── Test Set

不能:

训练数据
=
测试数据

否则你根本不知道模型是否真的学会规律。

这个问题 Day14 的 Overfitting 会重点讲。


三十六、Day11 今日作业

理论题

  1. 一个完整 Training Step 包含哪几个核心步骤?
  2. optimizer.zero_grad()为什么需要?
  3. loss.backward()和 optimizer.step()有什么区别?
  4. Epoch、Batch、Step 分别是什么?
  5. 为什么训练时使用 model.train(),推理时使用 model.eval()
  6. model.eval()和 torch.no_grad()是一回事吗?
  7. 为什么训练 Loss 持续下降不代表模型一定很好?
  8. 什么叫 Generalization?
  9. 为什么真实训练通常使用 Mini-Batch,而不是一次把所有数据送进模型?
  10. 今天情感分类模型为什么需要 Pooling?

三十七、作业参考答案

1. Training Step

核心:

zero_grad()

Forward

Loss

Backward

optimizer.step()

对应:

optimizer.zero_grad()

logits = model(X)

loss = criterion(
    logits,
    y
)

loss.backward()

optimizer.step()

2. 为什么 zero_grad?

因为 PyTorch 默认会:

累加 Gradient

普通训练通常希望当前 Step 使用当前 Batch 的梯度,因此每次训练前清空旧梯度。

3. backward 和 step

loss.backward()

负责:

计算梯度。

结果进入:

parameter.grad

而:

optimizer.step()

负责:

根据梯度真正修改参数。

所以:

Backward
=
算怎么改

Step
=
真的改

4. Epoch / Batch / Step

假设:

Dataset = 1000

Batch Size = 100

那么:

Batch
=
每次 100 条

Step
=
一次参数更新

1 Epoch
=
完整看完 1000 条
=
10 Step

5. train / eval

model.train()

告诉模型:

现在处于训练阶段

某些层按照训练方式工作。

model.eval()

告诉模型:

现在处于评估/推理阶段

例如 Dropout 会关闭随机丢弃行为。

6. eval 和 no_grad 一样吗?

不一样。

model.eval()

改变模型中某些层的行为。

而:

torch.no_grad()

关闭梯度计算。

所以推理通常:

model.eval()

with torch.no_grad():
    logits = model(X)

两个一起使用。

7. 为什么 Loss 下降不代表模型一定好?

因为可能出现:

Overfitting

模型只是记住:

Training Data

却不能处理没见过的数据。

所以必须观察:

Training Performance
+
Validation Performance
+
Test Performance

8. Generalization

泛化能力就是:

模型对训练过程中没有见过的新数据,仍然能够做出合理预测的能力。

这比“记住训练集”重要得多。

9. 为什么 Mini-Batch?

真实数据可能非常大,无法一次全部放进内存/GPU。

因此:

Dataset

Batch1

Step

Batch2

Step

Batch3

Step
...

既节省内存,也形成现代神经网络最常见的训练方式。

10. 为什么需要 Pooling?

Embedding 输出:

[B, T, C]

即一句话有:

T 个 Token 表示

但情感分类需要:

一个句子

一个类别

所以需要把 Token 表示聚合成:

[B, C]

今天使用:

Masked Mean Pooling

然后:

[B, C]

Linear

[B, 2]

完成句子分类。


三十八、今天建议真正完成的三个实验

目录:

day11/
├── day11_sentiment.py
├── day11_parameter_update.py
└── day11_loss_curve.py

如果今天只有一个小时,优先级:

  1. day11_sentiment.py
  2. day11_parameter_update.py
  3. loss curve

尤其第一个一定亲手跑。

因为今天是课程第一次真正完成:

文本

数据处理

模型

Loss

Backward

Optimizer

训练

预测新文本

这个闭环。


Day 11 最重要的一句话

训练神经网络,就是不断执行 Forward → Loss → Backward → Update,让参数沿着降低 Loss 的方向反复调整。

到这里,我们已经从:

Day2

“知道 Training Loop 长什么样”

走到了:

Day11

“真正训练一个 NLP 模型”

而今天我们还有一个明显不专业的地方:

X = torch.tensor(...)
y = torch.tensor(...)

model(X)

我们自己把全部数据一次塞进模型。

真正工程训练应该是:

原始数据

Dataset

DataLoader

Shuffle

Batch

Training Loop

所以 Day11 到这里完整结束。

下一课严格按照正式计划进入:

Day 12:PyTorch Dataset / DataLoader + Mini-Batch Training

我们会把今天的金融情感分类数据重新工程化,自己实现 Dataset,使用 DataLoader 按 Batch 训练,并真正看清:

Dataset
Epoch
Batch
Step
Shuffle

之间到底是什么关系。


附录:三个程序怎么分工

先把文中三个程序的定位说清楚:

文件
作用
是不是完整情感分类程序
day11_sentiment.py
金融文本情感分类:数据 → 训练 → 预测
是,今天的核心程序
day11_parameter_update.py
观察 backward()和 step()如何改变参数
教学实验
day11_loss_curve.py
训练情感模型并画 Loss 曲线
基于情感分类,但重点是训练过程可视化

所以今天真正应该重点研究和运行的是:

day11_sentiment.py

它的实际使用方式就是:

输入:

"公司净利润大幅增长"

↓ 模型

输出:

正面
正面概率:xx%
负面概率:xx%

下面三个程序都是可以独立运行的完整版本。

附录 A:核心程序 day11_sentiment.py

这个才是今天真正的:

金融文本情感分类模型

完整流程:

训练文本
    ↓
建立 Vocabulary
    ↓
Tokenizer / Encode
    ↓
Token IDs
    ↓
Embedding
    ↓
Masked Mean Pooling
    ↓
Linear
    ↓
Positive / Negative Logits
    ↓
CrossEntropyLoss
    ↓
Backward
    ↓
Adam
    ↓
训练完成
    ↓
输入新文本
    ↓
情感分类
import torch
import torch.nn as nn
import torch.nn.functional as F


# ============================================================
# 1. 固定随机种子
# ============================================================
#
# 神经网络初始化包含随机过程。
#
# 固定随机种子以后,多次运行程序时,
# 初始参数尽量保持一致,方便学习和实验对比。
#

torch.manual_seed(42)


# ============================================================
# 2. 准备训练数据
# ============================================================
#
# 每条数据:
#
# (
#     文本,
#     标签
# )
#
# 标签定义:
#
# 0 = Negative 负面
# 1 = Positive 正面
#

samples = [

# -------------------------
# Positive
# -------------------------

    ("公司业绩大幅增长"1),
    ("净利润同比增长"1),
    ("公司获得重大订单"1),
    ("营业收入创新高"1),
    ("公司上调全年业绩预期"1),
    ("主营业务持续增长"1),
    ("公司盈利能力明显改善"1),
    ("产品销售收入快速增长"1),

# -------------------------
# Negative
# -------------------------

    ("公司业绩大幅下降"0),
    ("净利润同比下滑"0),
    ("公司出现重大亏损"0),
    ("营业收入明显下降"0),
    ("公司下调全年业绩预期"0),
    ("主营业务持续恶化"0),
    ("公司盈利能力明显下降"0),
    ("产品销售收入大幅下滑"0),
]


# ============================================================
# 3. 定义特殊 Token
# ============================================================

PAD_TOKEN = "<PAD>"
UNK_TOKEN = "<UNK>"


# ============================================================
# 4. 建立 Vocabulary
# ============================================================
#
# 今天为了让整个过程足够透明,
# 使用最简单的“字符级 Tokenizer”。
#
# 例如:
#
# 公司业绩增长
#
# 会被拆成:
#
# 公
# 司
# 业
# 绩
# 增
# 长
#
# 然后每个字符获得一个整数 ID。
#

vocab = {

# Padding
    PAD_TOKEN: 0,

# Unknown
    UNK_TOKEN: 1
}


# 遍历全部训练文本

for text, label in samples:

# 遍历每个汉字

for char in text:

# 如果这个字符还没有进入词表

if char notin vocab:

# 给它分配一个新的 Token ID

            vocab[char] = len(vocab)


print(
"Vocabulary Size:",
    len(vocab)
)


print(
"\nVocabulary:"
)

print(vocab)


# ============================================================
# 5. 文本最大长度
# ============================================================
#
# 为了组成 Batch,
# 每句话最终都变成固定长度。
#

MAX_LEN = 16


# ============================================================
# 6. 编写 Tokenizer / Encoder
# ============================================================

defencode(text):

"""
    把中文字符串转换成固定长度的 Token ID 数组。

    例如:

    公司增长

    ↓

    [2, 3, 10, 11, 0, 0, ...]

    其中:

    0 = PAD
    """


    ids = []


# -------------------------
# 文本 -> Token ID
# -------------------------

for char in text:

        token_id = vocab.get(

            char,

# 没见过的字符使用 UNK

            vocab[UNK_TOKEN]
        )

        ids.append(
            token_id
        )


# -------------------------
# Truncation
# -------------------------
#
# 如果文本太长,只保留前 MAX_LEN 个 Token。
#

    ids = ids[:MAX_LEN]


# -------------------------
# Padding
# -------------------------
#
# 如果长度不足 MAX_LEN,
# 后面补 PAD。
#

while len(ids) < MAX_LEN:

        ids.append(
            vocab[PAD_TOKEN]
        )


return ids


# ============================================================
# 7. 看看 encode() 的效果
# ============================================================

example = "公司业绩增长"

print(
"\nExample Text:",
    example
)

print(
"Encoded:",
    encode(example)
)


# ============================================================
# 8. 把训练数据转换成 Tensor
# ============================================================

X = torch.tensor(

    [
        encode(text)

for text, label
in samples
    ],

    dtype=torch.long
)


y = torch.tensor(

    [
        label

for text, label
in samples
    ],

    dtype=torch.long
)


print(
"\nX Shape:",
    X.shape
)

print(
"y Shape:",
    y.shape
)


# ============================================================
# 9. 定义神经网络
# ============================================================

classSentimentModel(nn.Module):

def__init__(
        self,
        vocab_size,
        embed_dim=32
    )
:


        super().__init__()


# ====================================================
# Embedding
# ====================================================
#
# Token ID
#
# ↓
#
# 32维向量
#
# padding_idx=0:
#
# 告诉 PyTorch:
#
# Token 0 是 PAD。
#

        self.embedding = nn.Embedding(

            num_embeddings=vocab_size,

            embedding_dim=embed_dim,

            padding_idx=0
        )


# ====================================================
# 分类器
# ====================================================
#
# 输入:
#
# 32维句子向量
#
# 输出:
#
# 2个 Logits
#
# [
#     Negative Logit,
#     Positive Logit
# ]
#

        self.classifier = nn.Linear(
            embed_dim,
2
        )


defforward(self, x):

# ====================================================
# 输入 Shape
# ====================================================
#
# x:
#
# [Batch, Sequence]
#
# 例如:
#
# [16, 16]
#


# ====================================================
# Step 1:Embedding
# ====================================================

        embeddings = self.embedding(
            x
        )

# Shape:
#
# [B, T, C]
#
# 例如:
#
# [16, 16, 32]


# ====================================================
# Step 2:创建 PAD Mask
# ====================================================
#
# x != 0
#
# 真正 Token:
#
# True
#
# PAD:
#
# False
#

        mask = (
            x != 0
        ).unsqueeze(-1)

# Shape:
#
# [B, T, 1]


# ====================================================
# Step 3:把 PAD Embedding 清零
# ====================================================

        masked_embeddings = (
            embeddings
            *
            mask
        )


# ====================================================
# Step 4:Token 向量求和
# ====================================================

        summed = masked_embeddings.sum(
            dim=1
        )

# Shape:
#
# [B, C]


# ====================================================
# Step 5:计算真正 Token 数量
# ====================================================

        lengths = mask.sum(
            dim=1
        )

# 防止除 0

        lengths = lengths.clamp(
            min=1
        )


# ====================================================
# Step 6:Mean Pooling
# ====================================================
#
# 把一句话所有 Token 的 Embedding 求平均。
#
# [B, T, C]
#
# ↓
#
# [B, C]
#

        pooled = (
            summed
            /
            lengths
        )


# ====================================================
# Step 7:分类
# ====================================================

        logits = self.classifier(
            pooled
        )

# Shape:
#
# [B, 2]


return logits


# ============================================================
# 10. 创建模型
# ============================================================

model = SentimentModel(

    vocab_size=len(vocab),

    embed_dim=32
)


print(
"\nModel:"
)

print(model)


# ============================================================
# 11. Loss
# ============================================================
#
# 两分类问题。
#
# 输入:
#
# [Negative Logit, Positive Logit]
#
# Target:
#
# 0 或 1
#

criterion = nn.CrossEntropyLoss()


# ============================================================
# 12. Optimizer
# ============================================================

optimizer = torch.optim.Adam(

    model.parameters(),

    lr=0.01
)


# ============================================================
# 13. Training
# ============================================================

EPOCHS = 300


print(
"\n开始训练..."
)


for epoch in range(EPOCHS):


# --------------------------------------------------------
# 训练模式
# --------------------------------------------------------

    model.train()


# --------------------------------------------------------
# 清空上一轮梯度
# --------------------------------------------------------

    optimizer.zero_grad()


# --------------------------------------------------------
# Forward
# --------------------------------------------------------

    logits = model(
        X
    )


# --------------------------------------------------------
# Loss
# --------------------------------------------------------

    loss = criterion(
        logits,
        y
    )


# --------------------------------------------------------
# Backward
# --------------------------------------------------------

    loss.backward()


# --------------------------------------------------------
# 更新模型参数
# --------------------------------------------------------

    optimizer.step()


# --------------------------------------------------------
# 输出训练信息
# --------------------------------------------------------

if epoch % 30 == 0:

# 当前预测类别

        predictions = torch.argmax(
            logits,
            dim=1
        )


# Training Accuracy

        accuracy = (

            predictions == y

        ).float().mean()


        print(

f"Epoch={epoch:3d} "

f"Loss={loss.item():.6f} "

f"Accuracy={accuracy.item():.2%}"
        )


# ============================================================
# 14. 定义预测函数
# ============================================================

defpredict(text):

"""
    输入一段金融文本。

    返回:

    预测标签
    Negative 概率
    Positive 概率
    """



# --------------------------------------------------------
# 推理模式
# --------------------------------------------------------

    model.eval()


# --------------------------------------------------------
# Text -> Token IDs
# --------------------------------------------------------

    ids = encode(
        text
    )


# --------------------------------------------------------
# 增加 Batch 维度
# --------------------------------------------------------
#
# 原来:
#
# [T]
#
# 变成:
#
# [1, T]
#

    input_tensor = torch.tensor(

        [ids],

        dtype=torch.long
    )


# --------------------------------------------------------
# 推理不需要梯度
# --------------------------------------------------------

with torch.no_grad():


# Forward

        logits = model(
            input_tensor
        )


# Logits -> Probability

        probabilities = F.softmax(

            logits,

            dim=-1
        )


# 最大概率类别

        prediction = torch.argmax(

            probabilities,

            dim=-1

        ).item()


# --------------------------------------------------------
# 分类结果
# --------------------------------------------------------

if prediction == 1:

        label = "正面"

else:

        label = "负面"


    negative_probability = (
        probabilities[0][0].item()
    )

    positive_probability = (
        probabilities[0][1].item()
    )


return (
        label,
        negative_probability,
        positive_probability
    )


# ============================================================
# 15. 测试模型
# ============================================================

print(
"\n============================"
)

print(
"开始情感分类测试"
)

print(
"============================\n"
)


test_texts = [

"公司净利润大幅增长",

"公司出现严重亏损",

"营业收入持续增长",

"公司盈利明显下降",

"公司获得新的订单",

"主营业务明显恶化",
]


for text in test_texts:

    (
        label,
        negative_prob,
        positive_prob

    ) = predict(text)


    print(
f"文本:{text}"
    )

    print(
f"预测:{label}"
    )

    print(
f"负面概率:{negative_prob:.2%}"
    )

    print(
f"正面概率:{positive_prob:.2%}"
    )

    print(
"-" * 40
    )

到底怎么使用这个情感分类程序?

运行:

python day11_sentiment.py

程序实际上分成两个阶段。

第一阶段:训练

程序首先使用:

samples = [...]

这 16 条数据训练。

你会看到:

Epoch=  0 Loss=0.7... Accuracy=...

Epoch= 30 Loss=...

Epoch= 60 Loss=...

...

Epoch=270 Loss=...

这部分就是:

模型训练

第二阶段:真正进行情感分类

训练结束以后:

predict("公司净利润大幅增长")

这里才是:

使用模型进行情感分类

整个过程:

"公司净利润大幅增长"

        ↓

encode()

        ↓

Token IDs

        ↓

Embedding

        ↓

Mean Pooling

        ↓

Linear

        ↓

[-2.1, 3.8]

        ↓

Softmax

        ↓

[0.003, 0.997]

        ↓

argmax

        ↓

Positive

        ↓

"正面"

所以以后你想分析一句新文本,只需要:

result = predict(
"公司获得重大合同"
)

print(result)

但这里有一个非常重要的问题:UNK

比如你输入:

公司遭到监管处罚

其中:

遭 / 到 / 监 / 管 / 处 / 罚

有些字可能没有出现在训练集 Vocabulary 中。

那么:

vocab.get(
    char,
    vocab["<UNK>"]
)

会变成:

<UNK>

所以这个教学模型泛化能力非常有限。

以后真正金融 NLP:

不会自己用 16 条数据造字符词表

而会使用成熟:

Tokenizer

预训练 Transformer / LLM

但今天故意这么写,是为了让你看清楚所有过程。

附录 B:day11_parameter_update.py

这个程序:

不是情感分类模型。

它只解决一个问题:

loss.backward()和 optimizer.step()到底做了什么?

import torch
import torch.nn as nn


# ============================================================
# 固定随机种子
# ============================================================

torch.manual_seed(42)


# ============================================================
# 创建一个极简单模型
# ============================================================
#
# 输入:
#
# 2维
#
# 输出:
#
# 2分类
#

model = nn.Linear(
2,
2
)


# ============================================================
# Loss
# ============================================================

criterion = nn.CrossEntropyLoss()


# ============================================================
# Optimizer
# ============================================================
#
# 今天故意使用 SGD,
# 因为更新过程比较容易理解。
#

optimizer = torch.optim.SGD(

    model.parameters(),

    lr=0.1
)


# ============================================================
# 一个训练样本
# ============================================================

X = torch.tensor([
    [1.02.0]
])


# 正确类别:
#
# Class 1

y = torch.tensor([
1
])


# ============================================================
# 保存训练前参数
# ============================================================

weight_before = (
    model.weight.data.clone()
)

bias_before = (
    model.bias.data.clone()
)


print(
"训练前 Weight:"
)

print(
    weight_before
)


print(
"\n训练前 Bias:"
)

print(
    bias_before
)


# ============================================================
# Training Mode
# ============================================================

model.train()


# ============================================================
# 1. 清梯度
# ============================================================

optimizer.zero_grad()


# ============================================================
# 2. Forward
# ============================================================

logits = model(
    X
)


print(
"\nLogits:"
)

print(
    logits
)


# ============================================================
# 3. Loss
# ============================================================

loss = criterion(
    logits,
    y
)


print(
"\nLoss:"
)

print(
    loss.item()
)


# ============================================================
# 4. Backward
# ============================================================
#
# 根据 Loss 进行反向传播。
#
# 注意:
#
# backward() 只负责计算梯度,
# 这时候模型参数本身还没有发生变化。
#

loss.backward()


# ============================================================
# 5. 查看 Gradient
# ============================================================

print(
"\nWeight Gradient:"
)

print(
    model.weight.grad
)


print(
"\nBias Gradient:"
)

print(
    model.bias.grad
)


# ============================================================
# 6. 特别验证:
#
# backward() 以后,
# 参数有没有改变?
# ============================================================

print(
"\nBackward之后、Step之前 Weight:"
)

print(
    model.weight.data
)


# ============================================================
# 7. optimizer.step()
# ============================================================
#
# 这一步才是真正修改参数。
#

optimizer.step()


# ============================================================
# 8. 保存更新后的参数
# ============================================================

weight_after = (
    model.weight.data.clone()
)

bias_after = (
    model.bias.data.clone()
)


print(
"\n训练后 Weight:"
)

print(
    weight_after
)


print(
"\n训练后 Bias:"
)

print(
    bias_after
)


# ============================================================
# 9. 看看参数到底改变了多少
# ============================================================

print(
"\nWeight变化量:"
)

print(
    weight_after
    -
    weight_before
)


print(
"\nBias变化量:"
)

print(
    bias_after
    -
    bias_before
)


# ============================================================
# 10. 再 Forward 一次
# ============================================================
#
# 使用更新后的参数再次预测,
# 看看 Loss 是否发生变化。
#

with torch.no_grad():

    new_logits = model(
        X
    )

    new_loss = criterion(
        new_logits,
        y
    )


print(
"\n更新前 Loss:"
)

print(
    loss.item()
)


print(
"\n更新后 Loss:"
)

print(
    new_loss.item()
)

这个实验到底要观察什么?

运行:

python day11_parameter_update.py

重点不是最终数字,而是顺序:

初始 Weight
      ↓
Forward
      ↓
Logits
      ↓
Loss
      ↓
loss.backward()
      ↓
出现 Gradient
      ↓
但 Weight 还没改变
      ↓
optimizer.step()
      ↓
Weight 真正改变
      ↓
再次 Forward
      ↓
Loss 通常下降

所以这个程序就是为了证明:

loss.backward()

修改参数

而是:

loss.backward()
=
计算梯度

真正修改参数的是:

optimizer.step()

这两个以后一定不能混。

附录 C:day11_loss_curve.py

这个程序仍然是:

金融文本情感分类模型

但是它的学习重点和第一段不一样。

第一段重点:

怎么训练并使用一个情感分类模型?

第三段重点:

训练过程中 Loss 到底是怎么变化的?

它会画出训练曲线。

import torch
import torch.nn as nn
import torch.nn.functional as F

import matplotlib.pyplot as plt


# ============================================================
# 1. 固定随机种子
# ============================================================

torch.manual_seed(42)


# ============================================================
# 2. 准备金融情感训练数据
# ============================================================
#
# Label:
#
# 0 = Negative
# 1 = Positive
#

samples = [

# Positive

    ("公司业绩大幅增长"1),
    ("净利润同比增长"1),
    ("公司获得重大订单"1),
    ("营业收入创新高"1),
    ("公司上调全年业绩预期"1),
    ("主营业务持续增长"1),
    ("公司盈利能力明显改善"1),
    ("产品销售收入快速增长"1),

# Negative

    ("公司业绩大幅下降"0),
    ("净利润同比下滑"0),
    ("公司出现重大亏损"0),
    ("营业收入明显下降"0),
    ("公司下调全年业绩预期"0),
    ("主营业务持续恶化"0),
    ("公司盈利能力明显下降"0),
    ("产品销售收入大幅下滑"0),
]


# ============================================================
# 3. 特殊 Token
# ============================================================

PAD_TOKEN = "<PAD>"
UNK_TOKEN = "<UNK>"


# ============================================================
# 4. Vocabulary
# ============================================================

vocab = {

    PAD_TOKEN: 0,

    UNK_TOKEN: 1
}


for text, label in samples:

for char in text:

if char notin vocab:

            vocab[char] = len(vocab)


print(
"Vocabulary Size:",
    len(vocab)
)


# ============================================================
# 5. 最大文本长度
# ============================================================

MAX_LEN = 16


# ============================================================
# 6. Encode
# ============================================================

defencode(text):

    ids = []


# Text -> Token IDs

for char in text:

        token_id = vocab.get(

            char,

            vocab[UNK_TOKEN]
        )

        ids.append(
            token_id
        )


# Truncation

    ids = ids[:MAX_LEN]


# Padding

while len(ids) < MAX_LEN:

        ids.append(
            vocab[PAD_TOKEN]
        )


return ids


# ============================================================
# 7. 构造训练 Tensor
# ============================================================

X = torch.tensor(

    [
        encode(text)

for text, label
in samples
    ],

    dtype=torch.long
)


y = torch.tensor(

    [
        label

for text, label
in samples
    ],

    dtype=torch.long
)


# ============================================================
# 8. 定义模型
# ============================================================

classSentimentModel(nn.Module):

def__init__(
        self,
        vocab_size,
        embed_dim=32
    )
:


        super().__init__()


# Token Embedding

        self.embedding = nn.Embedding(

            vocab_size,

            embed_dim,

            padding_idx=0
        )


# 二分类

        self.classifier = nn.Linear(

            embed_dim,

2
        )


defforward(self, x):

# ----------------------------------------------------
# Embedding
# ----------------------------------------------------

        embeddings = self.embedding(
            x
        )

# Shape:
#
# [B, T, C]


# ----------------------------------------------------
# Padding Mask
# ----------------------------------------------------

        mask = (
            x != 0
        ).unsqueeze(-1)


# ----------------------------------------------------
# 清除 PAD
# ----------------------------------------------------

        embeddings = (
            embeddings
            *
            mask
        )


# ----------------------------------------------------
# Token Embedding 求和
# ----------------------------------------------------

        summed = embeddings.sum(
            dim=1
        )


# ----------------------------------------------------
# 有效 Token 数量
# ----------------------------------------------------

        lengths = mask.sum(
            dim=1
        ).clamp(
            min=1
        )


# ----------------------------------------------------
# Mean Pooling
# ----------------------------------------------------

        pooled = (
            summed
            /
            lengths
        )


# ----------------------------------------------------
# Classification
# ----------------------------------------------------

        logits = self.classifier(
            pooled
        )


return logits


# ============================================================
# 9. 创建模型
# ============================================================

model = SentimentModel(

    vocab_size=len(vocab),

    embed_dim=32
)


# ============================================================
# 10. Loss
# ============================================================

criterion = nn.CrossEntropyLoss()


# ============================================================
# 11. Optimizer
# ============================================================

optimizer = torch.optim.Adam(

    model.parameters(),

    lr=0.01
)


# ============================================================
# 12. Training
# ============================================================

EPOCHS = 300


# 用来记录每个 Epoch 的 Loss

loss_history = []


# 用来记录 Accuracy

accuracy_history = []


for epoch in range(EPOCHS):


# --------------------------------------------------------
# Train Mode
# --------------------------------------------------------

    model.train()


# --------------------------------------------------------
# Clear Gradient
# --------------------------------------------------------

    optimizer.zero_grad()


# --------------------------------------------------------
# Forward
# --------------------------------------------------------

    logits = model(
        X
    )


# --------------------------------------------------------
# Loss
# --------------------------------------------------------

    loss = criterion(
        logits,
        y
    )


# --------------------------------------------------------
# Backward
# --------------------------------------------------------

    loss.backward()


# --------------------------------------------------------
# Update Parameters
# --------------------------------------------------------

    optimizer.step()


# ========================================================
# 保存 Loss
# ========================================================

    loss_history.append(
        loss.item()
    )


# ========================================================
# 计算 Training Accuracy
# ========================================================

    predictions = torch.argmax(

        logits,

        dim=1
    )


    accuracy = (

        predictions == y

    ).float().mean()


    accuracy_history.append(
        accuracy.item()
    )


# ========================================================
# 输出训练状态
# ========================================================

if epoch % 30 == 0:

        print(

f"Epoch={epoch:3d} "

f"Loss={loss.item():.6f} "

f"Accuracy={accuracy.item():.2%}"
        )


# ============================================================
# 13. 绘制 Loss Curve
# ============================================================

plt.figure(
    figsize=(85)
)


plt.plot(
    loss_history
)


plt.xlabel(
"Epoch"
)


plt.ylabel(
"Loss"
)


plt.title(
"Training Loss"
)


plt.grid(
True,
    alpha=0.3
)


plt.tight_layout()


plt.show()


# ============================================================
# 14. 绘制 Accuracy Curve
# ============================================================

plt.figure(
    figsize=(85)
)


plt.plot(
    accuracy_history
)


plt.xlabel(
"Epoch"
)


plt.ylabel(
"Accuracy"
)


plt.title(
"Training Accuracy"
)


plt.grid(
True,
    alpha=0.3
)


plt.tight_layout()


plt.show()


# ============================================================
# 15. 定义预测函数
# ============================================================

defpredict(text):

    model.eval()


    input_tensor = torch.tensor(

        [
            encode(text)
        ],

        dtype=torch.long
    )


with torch.no_grad():

        logits = model(
            input_tensor
        )


        probabilities = F.softmax(

            logits,

            dim=-1
        )


        prediction = torch.argmax(

            probabilities,

            dim=-1

        ).item()


    label = (

"正面"

if prediction == 1

else"负面"
    )


return (

        label,

        probabilities[0][0].item(),

        probabilities[0][1].item()
    )


# ============================================================
# 16. 测试
# ============================================================

print(
"\n训练完成,开始测试:\n"
)


test_texts = [

"公司净利润大幅增长",

"公司出现严重亏损",

"营业收入持续增长",

"公司盈利能力下降"
]


for text in test_texts:

    (
        label,
        negative_prob,
        positive_prob

    ) = predict(text)


    print(
f"{text}"
    )

    print(
f"预测:{label}"
    )

    print(
f"Negative:{negative_prob:.2%}"
    )

    print(
f"Positive:{positive_prob:.2%}"
    )

    print(
"-" * 40
    )

三个程序现在彻底区分开

到底哪段代码是进行情感分类的?到底怎么用?

现在可以非常明确地回答。

程序
主要学习什么
情感分类
day11_sentiment.py
完整训练 + 使用模型预测文本情绪
核心
day11_parameter_update.py
看梯度和参数怎么改变
不是
day11_loss_curve.py
观察情感模型训练过程
是,重点是过程

所以你首先运行:

python day11_sentiment.py

这个才是 Day11 的主项目。


day11_sentiment.py到底由哪两部分组成?

这点一定区分。

第一部分:训练模型

samples
   ↓
Vocabulary
   ↓
encode()
   ↓
X / y
   ↓
SentimentModel
   ↓
CrossEntropyLoss
   ↓
Adam
   ↓
Training Loop

核心:

for epoch in range(EPOCHS):

    model.train()

    optimizer.zero_grad()

    logits = model(X)

    loss = criterion(
        logits,
        y
    )

    loss.backward()

    optimizer.step()

这叫:

Training

第二部分:使用模型

真正使用模型的是:

predict(text)

例如:

label, negative, positive = predict(
"公司净利润大幅增长"
)

这叫:

Inference

内部:

"公司净利润大幅增长"
          ↓
       encode()
          ↓
      Token IDs
          ↓
       model()
          ↓
        Logits
          ↓
       Softmax
          ↓
     Probability
          ↓
       argmax
          ↓
        正面

训练和推理最关键的区别

训练:

model.train()

Forward

Loss

Backward

Optimizer

修改参数

推理:

model.eval()

torch.no_grad()

Forward

Logits

Softmax

Prediction

推理阶段:

没有 Loss

没有 backward

没有 optimizer.step

因为:

模型已经训练完了,现在只使用参数,不再修改参数。


你可以自己输入一句话测试

最简单就是修改:

test_texts = [
"公司净利润大幅增长",
"公司出现严重亏损",
]

加入:

公司订单快速增长

或者:

公司全年出现巨额亏损

再运行整个程序。

也可以直接:

text = input(
"请输入一段金融文本:"
)

label, negative, positive = predict(
    text
)

print(
"预测结果:",
    label
)

print(
f"负面概率:{negative:.2%}"
)

print(
f"正面概率:{positive:.2%}"
)

这样运行以后:

请输入一段金融文本:

你输入:

公司净利润大幅增长

程序:

预测结果:正面
负面概率:1.23%
正面概率:98.77%

具体概率会因训练而不同。

这时候它就已经像一个非常简陋的:

金融情感分析小工具

了。


但是不要误解这个模型的能力

这是今天最需要强调的一点。

它只有:

  • 16 条训练数据
  • 字符级 Tokenizer
  • Embedding
  • Mean Pooling
  • Linear

所以它很可能实际上学到的是:

增长 → 正面
改善 → 正面
订单 → 正面

下降 → 负面
亏损 → 负面
恶化 → 负面

而不是像真正 LLM 一样理解:

公司收入虽然增长 30%,但归母净利润下降 60%,主要原因是成本大幅上升。

这种复杂语义。

甚至 Mean Pooling 会大幅弱化词序:

利润增长

和:

利润没有增长

它可能处理得很差。

这恰恰也是为什么我们前面花那么多时间学习:

  • Position
  • Attention
  • Transformer

后面真正的模型会逐渐解决这些问题。


建议你今天按照这个顺序跑

不要三个一起看。

先:

① day11_sentiment.py

重点理解:

怎么训练一个模型、怎么用模型。

然后:

② day11_parameter_update.py

重点观察:

Weight Before

Gradient

optimizer.step()

Weight After

最后:

③ day11_loss_curve.py

观察:

Epoch ↑
Loss ↓
Accuracy ↑

这样 Day11 的实践逻辑就特别清楚:

第一步
我会训练模型了
       ↓
第二步
我知道参数为什么会变
       ↓
第三步
我会观察模型训练过程了

这三段代码合起来,才是 Day11 真正想让你掌握的 PyTorch Training Loop。


预告:Day 12

下一课:

Day 12:PyTorch Dataset / DataLoader + Mini-Batch Training

我们会把今天的金融情感分类数据重新工程化,自己实现 Dataset,使用 DataLoader 按 Batch 训练,并真正看清 Dataset、Epoch、Batch、Step、Shuffle 之间到底是什么关系。

相关学习资料