乐于分享
好东西不私藏

NLP的进化历史,我们怎么让电脑“读懂”我们

NLP的进化历史,我们怎么让电脑“读懂”我们

由于电控原件的限制,电脑最初只认识0和1,而我们想让电脑“读懂”自然语言,本质上是  在解决一个映射问题:如何将人类离散、模糊、高维的符号系统,转换为机器可以计算 的连续、稠密、低维的数值表示。从这个角度,一场持续几十年的NLP(Nautral Langurage Processing)发展拉开帷幕


wordvector

最开始人们做的数据化尝试是词向量(wordvector)——让词语拥有“语义空间”中的坐标

最朴素的做法是独热编码,这种方式把每个词当作孤立的点,只在一个维度

(0,0,0,……0,1,0,……,0)

上展示出每个词“特有”的属性,词与词之间没有任何相似性可言,独热编码相当于字典,能同时精准表示每一个词的意思,但是也同时陷入了内存占用过大的问题

当人们尝试着优化独热向量时,Word2Vec诞生了,Word2Vec的贡献在于提出了一种基于分布的假设:一个词的含义由它的上下文决定。如果我们把一个词的向量表示记作,把它的上下文词的向量记作,那么我们希望点积能反映出共现强度。把这个点积放进Softmax里,就得到了条件概率的形式:

P(o∣c)=exp⁡(uoTvc)∑w∈Vexp⁡(uwTvc)P(o∣c)=∑w∈Vexp(uwTvc)exp(uoTvc)

当我们对求导时,结果恰好是“模型预测的分布减去真实分布”,在代码层面,一个简单的跳字模型训练循环大致如下:遍历文本中的每个中心词,在其窗口内采样上下文词,然后对每一对(center, context)计算损失并反向传播。负采样版本的改动在于把多分类退化为二分类,正样本是真实的上下文对,负样本则是随机抽取的无关词对——这种做法极大地降低了Softmax的分母计算开销

下面是一种实现w2v的方法

from gensim.models import Word2Vec
from gensim.models.word2vec import LineSentence

# 训练Word2Vec模型
sentences = [['我''喜欢''编程'], ['Python''很''强大'], ['自然语言''处理''有趣']]
model = Word2Vec(sentences, vector_size=100, window=5, min_count=1, workers=4)

# 获取词向量
vector = model.wv['编程']

# 查找相似词
similar_words = model.wv.most_similar('编程', topn=5)

# 保存和加载模型
model.save("word2vec.model")
model = Word2Vec.load("word2vec.model")

词向量的进步极大地推动了NLP的发展,像相似度比较之类的技术如雨后春笋般出现

然而,词向量是静态的,但语言是动态的。一个词在不同的句子里可能有完全不同的含义,因此我们需要一个能“看到上下文”的模型。

RNN

最简单的想法是固定窗口神经网络:把当前词前后若干个词的向量拼成一个长向量,再送入一个全连接层进行分类或预测。这种方法的优点是简单、可并行,但窗口大小限制了它能看到的范围,而且不同位置的参数是完全独立的,没有共享。

RNN的出现解决了这两个问题。它的核心是一个循环单元:隐藏状态。这个公式意味着当前时刻的状态不仅依赖当前输入,还依赖上一时刻的状态。从反向传播的角度看,损失对的梯度会经过次矩阵的连乘。如果的特征值大于1,梯度就会爆炸;如果小于1,梯度就会消失。这是RNN最核心的技术难点。

在PyTorch代码中,我们可以手动实现一个简单的RNN cell:在forward里写一个循环,依次更新隐藏状态,然后在每个时间步输出。训练时通常会使用torch.nn.utils.clip_grad_norm_来裁剪梯度,防止爆炸。

我们可以看到RNN的处理和CNN很像,都是采用了窗口遍历的方式

import torch
import torch.nn as nn

classRNNModel(nn.Module):
def__init__(self, input_size, hidden_size, output_size, num_layers=1):
        super(RNNModel, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers

        self.rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_size, output_size)

defforward(self, x):
# 初始化隐藏状态
        h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)

# 前向传播
        out, _ = self.rnn(x, h0)

# 取最后一个时间步的输出
        out = self.fc(out[:, -1, :])
return out

# 使用示例
model = RNNModel(input_size=10, hidden_size=20, output_size=2)
x = torch.randn(32510)  # (batch_size, sequence_length, input_size)
output = model(x)

LSTM(长短期记忆模型)

LSTM的解决方案很漂亮:它引入了一条叫做“细胞状态”的独立通路,更新规则是。这里的是遗忘门,是输入门,是候选记忆。关键的技术洞察在于:当接近1且接近0时,,这意味着梯度可以几乎无损地沿着细胞状态传播很远的距离。在代码层面,一个标准的LSTM单元的实现包含四个仿射变换,分别对应遗忘门、输入门、输出门和候选记忆,然后通过Sigmoid和Tanh的组合来控制信息流动。双向LSTM则是在序列的正反两个方向上各跑一遍LSTM,然后把两个方向的最终隐藏状态拼接起来——这在命名实体识别等需要利用“未来上下文”的任务中效果显著。

Seq2Seq架构把整个源序列压缩成一个固定长度的上下文向量,这显然不适合长句子。注意力机制的解决方案是:解码器在每一步生成时,不再依赖单一的上下文向量,而是去“查阅”编码器的所有隐藏状态,并计算一个加权和。具体来说,对于解码器的当前状态和编码器的每个隐藏状态,我们先计算一个得分(这是最简单的点积形式),然后用Softmax归一化成权重,最后加权求和得到上下文向量。这个再和拼接,送入输出层。下面是代码实现

import torch
import torch.nn as nn

classLSTMModel(nn.Module):
def__init__(self, input_size, hidden_size, output_size, num_layers=1, dropout=0.1):
        super(LSTMModel, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers

        self.lstm = nn.LSTM(input_size, hidden_size, num_layers, 
                           batch_first=True, dropout=dropout)
        self.fc = nn.Linear(hidden_size, output_size)

defforward(self, x):
# 初始化隐藏状态和细胞状态
        h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)
        c0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)

# LSTM前向传播
        out, (hn, cn) = self.lstm(x, (h0, c0))

# 取最后一个时间步的输出
        out = self.fc(out[:, -1, :])
return out

# 双向LSTM示例
classBiLSTMModel(nn.Module):
def__init__(self, input_size, hidden_size, output_size):
        super(BiLSTMModel, self).__init__()
        self.lstm = nn.LSTM(input_size, hidden_size, 
                           bidirectional=True, batch_first=True)
        self.fc = nn.Linear(hidden_size * 2, output_size)

defforward(self, x):
        out, _ = self.lstm(x)
        out = self.fc(out[:, -1, :])
return out

# 使用示例
model = LSTMModel(input_size=10, hidden_size=20, output_size=2)
x = torch.randn(32510)
output = model(x)

但是有直觉的开发者一看就能知道这种架构的问题——我们要在一个区域内遍历我们的视觉框架,这种算法的复杂度是O(n^2)

所以,我们亟待一场在复杂度上的革命,于是乎,2016年,一篇跨时代的论文《Attention is All You Need》带来了一套全新的架构——Transformer

Transformer

在代码层面,实现一个点积注意力只需要几行PyTorch:

import torch
import torch.nn as nn
import math

classTransformerModel(nn.Module):
def__init__(self, vocab_size, d_model=512, nhead=8, num_layers=6, dim_feedforward=2048, dropout=0.1):
        super(TransformerModel, self).__init__()

        self.embedding = nn.Embedding(vocab_size, d_model)
        self.pos_encoder = PositionalEncoding(d_model, dropout)

        encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward, dropout, batch_first=True)
        self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers)

        self.fc = nn.Linear(d_model, vocab_size)
        self.d_model = d_model

defforward(self, src):
# 嵌入 + 位置编码
        src = self.embedding(src) * math.sqrt(self.d_model)
        src = self.pos_encoder(src)

# Transformer编码
        output = self.transformer_encoder(src)

# 输出层
        output = self.fc(output)
return output

classPositionalEncoding(nn.Module):
def__init__(self, d_model, dropout=0.1, max_len=5000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)

        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))

        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)

        self.register_buffer('pe', pe)

defforward(self, x):
        x = x + self.pe[:, :x.size(1)]
return self.dropout(x)

# 使用Hugging Face的预训练Transformer
from transformers import AutoTokenizer, AutoModel

tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
model = AutoModel.from_pretrained("bert-base-chinese")

text = "这是一个测试句子"
inputs = tokenizer(text, return_tensors="pt")
outputs = model(**inputs)

这里除以的缩放操作是为了防止点积过大把Softmax推入梯度饱和区。这段代码看起来简单,但它背后的信息流发生了根本性的变化:梯度可以直接从解码器流向编码器的任意位置,不受距离限制。

Transformer的核心创新在于:它用自注意力替代了循环连接。对于一个序列的每个位置,我们把它映射成三个向量:查询、键、值。然后计算得到注意力矩阵,再与相乘。这个操作可以在一步内完成所有位置之间的信息交互。位置编码的加入解决了“词袋”问题:因为自注意力本身对位置不敏感,我们需要在输入嵌入上叠加一个正弦/余弦编码,或者使用可学习的位置嵌入。

在PyTorch中,一个简化版的多头注意力实现大致是:把输入的嵌入分别线性投影到组不同的空间,每组独立计算缩放点积注意力,然后把个头的输出拼接起来,再通过一个线性层。相比RNN的,Transformer的复杂度是,虽然项在长序列上显得吓人,但因为整个矩阵乘法可以被高度并行化,实际在GPU上的吞吐量远超RNN。这也是为什么现代大模型几乎全部采用Transformer架构。

多模态

让电脑“读懂”我们,不限于文本。图像、语音、视频都属于“我们的表达方式”。多模态模型的核心思想是:把不同模态的数据映射到同一个向量空间里。早期的VSE模型用一个线性变换把图像特征投影到词向量空间,通过排序损失让匹配的图文对距离更近。后来的CLIP采用了双塔结构,各自用Transformer编码图文,然后用对比学习拉近匹配对、推远不匹配对。在代码层面,这种对比损失的计算很简单:对于batch内的个图文对,我们得到图像特征矩阵和文本特征矩阵,计算相似度矩阵,然后让对角线上的个正样本的Softmax概率最大化。

一种图像识别的架构是YOLO(you only look once)

# 使用Ultralytics YOLOv8
from ultralytics import YOLO

# 加载预训练模型
model = YOLO('yolov8n.pt')  # 可选: yolov8n, yolov8s, yolov8m, yolov8l, yolov8x

# 训练模型
model.train(data='coco128.yaml', epochs=100, imgsz=640)

# 推理检测
results = model('image.jpg')

# 显示结果
for result in results:
    boxes = result.boxes  # 边界框
    result.show()  # 显示带标注的图像
    result.save('output.jpg')  # 保存结果

# 视频检测
results = model('video.mp4', stream=True)
for result in results:
    result.show()

# 实时摄像头检测
model.predict(source=0, show=True)  # 0表示默认摄像头

# 自定义YOLO检测函数
import cv2

defdetect_objects(image_path, model_path='yolov8n.pt'):
    model = YOLO(model_path)
    results = model(image_path)

for r in results:
        im_array = r.plot()  # 绘制检测结果
        cv2.imshow('Detection', im_array)
        cv2.waitKey(0)
        cv2.destroyAllWindows()

return results

# YOLO模型配置示例
from ultralytics import YOLO

# 自定义训练配置
model = YOLO('yolov8n.yaml')  # 从配置文件构建新模型

# 训练参数设置
results = model.train(
    data='custom_dataset.yaml',
    epochs=100,
    imgsz=640,
    batch=16,
    device='cuda',  # 使用GPU
    workers=8,
    lr0=0.01,
    augment=True
)

# 模型评估
metrics = model.val()

# 导出模型到不同格式
model.export(format='onnx')  # 导出ONNX格式
model.export(format='torchscript')  # 导出TorchScript格式

YOLO的核心创新在于将目标检测重构为单一的回归问题,通过一次前向传播直接从图像像素预测边界框和类别概率,彻底摒弃了传统方法中耗时的“区域提议+分类”两阶段流水线。这种端到端的设计使其检测速度达到实时级别(45-155 FPS),同时通过全局推理有效降低了背景误检率。

更精细的控制来自FiLM:它用文本特征生成一组缩放和平移参数,然后对图像特征做逐通道的仿射变换。这个操作的代码不过一两行,但它赋予了文本“指导”图像特征提取过程的强大能力。


回顾整个NLP的进化史,一个有趣的视角浮现出来:所谓“读懂”,本质上是在训练一个越来越好的预测器。Word2Vec在预测上下文,RNN在预测下一个词,Seq2Seq在预测翻译后的句子,而Transformer的自注意力机制则是在预测每个位置与其他位置的相关性。每一次架构的迭代,都在让这个预测器变得更精准、更高效、更能捕捉语言的深层结构。

当我们最终能训练出一个模型,它能根据一段文字生成逼真的图像,或者根据一幅图像写出准确的描述时,虽然那只是重复计算后的最优解问题,但是当神经网络不断加深,联系不断加强,也许我们可以说:电脑真的“读懂”了我们。

制作不易,欢迎关注
树先生的自习室