PyTorch 入门实战:从爬虫数据到模型上线的完整闭环

代码里的小宇宙
2025-12-21 21:07
阅读 1612

上周五晚上十点半,我还在百度大厦 B 座调一个搜索排序模型。产品经理突然在钉钉上@我:“能不能用 AI 判断这个网站是不是区块链垃圾站?” 我差点一口老血喷在键盘上——我们是做通用搜索的,不是风控团队啊!但转念一想,这不正好是个练手 PyTorch 的好机会?毕竟在公司三年多,天天和 TF1.x 打交道(别问,问就是历史包袱),最近跳槽面试又总被问 PyTorch,再不学真要被淘汰了。

于是,我决定用周末两天时间,从零搭建一个轻量级深度学习 pipeline:先写个爬虫抓点网页,然后用模型判断是否为“区块链营销号”,最后部署成 API。听起来简单?等你踩完所有坑就知道什么叫“理想很丰满,现实很骨感”了。


起手式:为什么选 PyTorch?

说实话,作为百度老算法狗,我们内部主力框架还是 PaddlePaddle + 自研系统。但外面的世界早已是 PyTorch 的天下。GitHub 上开源项目 80% 都用它,HuggingFace 模型库也优先支持 PyTorch 格式。更重要的是——调试体验真的香

还记得去年双11前,我 debug 一个 TF2 的动态图模型,光是搞清楚 tf.function 的 tracing 机制就熬了两个通宵。而 PyTorch 的 eager mode 就像 Python 本身一样自然:print 中间变量、打断点、单步执行,毫无障碍。对于我这种喜欢抠底层原理的人,PyTorch 的源码也更“干净”——不像某些框架,一层套一层的 decorator 看得人头晕。

所以,这次项目我毫不犹豫选了 PyTorch。


数据从哪来?自己爬!

既然任务是识别区块链垃圾站,首先得有正负样本。正样本好说,随便搜“区块链投资”、“NFT财富自由”就能找到一堆;负样本就有点 tricky——不能随便拿百度首页当负例,那太 trivial 了。

我的方案是:用 Scrapy 写个微型爬虫,定向抓两类页面:

  • 正例:关键词包含 “区块链”、“挖矿”、“代币”、“财富密码” 的站点
  • 负例:主流新闻站(如新华网、人民网)的技术/财经频道
# spider.py 片段
class BlockchainSpider(scrapy.Spider):
    name = 'blockchain_spider'
    
    def parse(self, response):
        # 提取正文文本(简单版,实际需处理 JS 渲染)
        text = ' '.join(response.css('p::text').getall())
        
        # 标签由 URL 关键词决定
        label = 1 if any(kw in response.url for kw in ['blockchain', 'crypto']) else 0
        
        yield {
            'url': response.url,
            'text': clean_text(text),  # 去广告、去脚本
            'label': label
        }

💡 真实场景吐槽:你以为爬虫很简单?实际上光是反爬就折腾半天。有些网站检测 headless 浏览器,有些动态加载内容,还有些直接 IP 封禁……最后我不得不加代理池+随机 UA+延时,搞得像在干黑产。测试同学看到代码后一脸惊恐:“你这不会被法务找上门吧?”

最终,我攒了约 5000 条样本(2500 正 + 2500 负),存成 CSV。虽然少,但入门足够了。


模型设计:小而美就够了

既然是快速验证,没必要上 BERT。我选择了最经典的 TextCNN ——结构简单、训练快、效果还不差。核心思想是用不同尺寸的卷积核捕捉 n-gram 特征。

import torch
import torch.nn as nn

class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, num_classes=2, dropout=0.5):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        
        # 多尺度卷积核:2-gram, 3-gram, 4-gram
        self.convs = nn.ModuleList([
            nn.Conv2d(1, 100, (k, embed_dim)) for k in [2, 3, 4]
        ])
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(300, num_classes)  # 100 * 3
    
    def forward(self, x):
        # x: [batch, seq_len]
        x = self.embedding(x).unsqueeze(1)  # [B, 1, L, E]
        
        # 卷积 + ReLU + MaxPool
        conv_outs = []
        for conv in self.convs:
            c = torch.relu(conv(x))  # [B, 100, L-k+1, 1]
            c = torch.max_pool1d(c.squeeze(-1), c.size(2)).squeeze(-1)  # [B, 100]
            conv_outs.append(c)
        
        x = torch.cat(conv_outs, dim=1)  # [B, 300]
        x = self.dropout(x)
        return self.fc(x)

数据预处理:Tokenizer 是关键

PyTorch 不像 TF 有内置的高级 API,所以得自己搞 tokenization。我用了最朴素的方案:

  1. 构建词汇表(vocab):统计所有单词,保留 top 10000
  2. 文本转 ID 序列,长度统一 pad 到 200
  3. DataLoader 打包成 batch
from torch.utils.data import Dataset, DataLoader

class TextDataset(Dataset):
    def __init__(self, texts, labels, word2idx, max_len=200):
        self.texts = texts
        self.labels = labels
        self.word2idx = word2idx
        self.max_len = max_len
    
    def __getitem__(self, idx):
        words = self.texts[idx].split()
        ids = [self.word2idx.get(w, 0) for w in words[:self.max_len]]
        ids += [0] * (self.max_len - len(ids))  # pad
        return torch.tensor(ids), torch.tensor(self.labels[idx])
    
    def __len__(self):
        return len(self.texts)

🤯 踩坑记录:一开始忘了做 padding,训练时直接报错 tensor size mismatch。还有一次把 word2idx 的 OOV 映射成 -1,结果 embedding 层越界崩溃。当时真的想砸电脑——这不比 TF 的 tf.keras.preprocessing 麻烦多了?


训练与调优:那些年我们追过的 loss

训练脚本写起来倒是很清爽:

model = TextCNN(vocab_size=len(word2idx))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(10):
    for batch_texts, batch_labels in train_loader:
        optimizer.zero_grad()
        outputs = model(batch_texts)
        loss = criterion(outputs, batch_labels)
        loss.backward()
        optimizer.step()

但真正折磨人的是 调参。初始版本准确率只有 65%,完全不可用。我尝试了以下优化:

策略 准确率提升 说明
加 Dropout +5% 过拟合严重,train loss 很低但 val loss 高
调整学习率 (1e-3 → 5e-4) +3% 原先 learning rate 太大,loss 震荡
用预训练词向量 (Word2Vec) +7% 从头训 embedding 效果差
增加负样本多样性 +4% 原先负样本太“干净”,模型学偏了

最终准确率达到 89%,AUC 0.93。虽然比不上大模型,但对一个 weekend project 来说,够用了。


和“区块链”的意外联动

说到这里你可能疑惑:关键词“区块链”到底在哪体现?其实整个项目的业务目标就是识别打着“区块链”旗号的垃圾网站。有趣的是,在分析错误样本时,我发现:

  • 很多正例其实是正规区块链媒体(如 CoinDesk),但因为用词浮夸被误判
  • 一些负例是科技博客讨论 Web3,也被当成垃圾

这说明:单纯看关键词不行,必须结合上下文语义——而这正是深度学习的优势。传统规则引擎可能写一堆正则匹配“区块链.*财富”,但模型能学到“区块链技术赋能实体经济”和“区块链带你一夜暴富”的语义差异。

🧠 算法思考:在搜索场景中,我们也面临类似问题。比如用户搜“比特币”,到底是想了解技术,还是想找交易平台?我们的排序模型就得区分 intent。这次小实验让我更理解了语义建模的价值。


部署上线:别让模型烂在本地

模型训好了,总不能只在 Jupyter Notebook 里跑吧?我用 Flask 包了个简易 API:

from flask import Flask, request, jsonify
import torch

app = Flask(__name__)
model = torch.load('textcnn_model.pth')
model.eval()

@app.route('/predict', methods=['POST'])
def predict():
    data = request.json
    text = data['text']
    # 预处理 + 推理(省略细节)
    with torch.no_grad():
        logits = model(input_ids)
        prob = torch.softmax(logits, dim=-1)[0][1].item()
    return jsonify({'is_blockchain_spam': prob > 0.7, 'score': prob})

启动服务后,用 curl 测试:

curl -X POST http://localhost:5000/predict \
  -H "Content-Type: application/json" \
  -d '{"text": "加入我们的区块链挖矿群,日赚万元不是梦!"}'

返回:

{"is_blockchain_spam": true, "score": 0.92}

搞定!虽然离生产级还差十万八千里(没做 batching、没加监控、没压测),但至少证明了端到端流程可行。


给 fellow 算法工程师的建议

作为一个在大厂待了三年多、正考虑换环境的老兵,我真心觉得:掌握 PyTorch 已经不是“加分项”,而是“生存技能”

  • 如果你在做 NLP/CV,90% 的 SOTA 模型都首发 PyTorch
  • 如果你想读论文复现,PyTorch 社区资源丰富到爆炸
  • 如果你准备跳槽,面试官张口就是 “讲讲你用 PyTorch 解决过什么问题”

这次小项目虽然粗糙,但它帮我打通了 数据 → 模型 → 部署 的全链路。更重要的是,我终于敢在简历上写“熟练使用 PyTorch”了(之前一直心虚)。


最后一点碎碎念

写这篇文章时,我又想起那个周五晚上的钉钉消息。其实产品经理后来也没真用这个模型——他们改需求了,现在要识别“AI 生成内容”。但没关系,技术人的快乐,往往就藏在这些“无用但有趣”的折腾里。

如果你也在大厂被 legacy code 折磨,不妨找个周末,用 PyTorch 做点小东西。不用多 fancy,哪怕只是分类垃圾邮件,也能让你找回 coding 的初心。

毕竟,算法工程师的核心竞争力,从来不是会调哪个框架,而是解决问题的思维和动手能力

共勉。

评论 0

最热最新
暂无评论
代码里的小宇宙Lv.1
0
影响力
0
文章
0
粉丝