简介:这是“爬虫与数据分析实践”完整项目包,集成信息爬取、LSTM时间序列预测与机器学习分析三条主线,覆盖从数据采集、清洗、建模到结果可视化的完整流程,适合计算机、人工智能、自动化、物联网等专业高校学生用于毕设、课设、作业或项目初期演示,也适合有Python基础者进阶学习。压缩包共472个文件,大小174MB,核心包含13个Python源码、2个Jupyter Notebook分析文档、多个pickle模型文件及csv结果数据,并附带MySQL数据库文件、yaml配置与Dockerfile,便于重建实验环境。项目资料齐全,除可运行代码外,还提供项目说明、设计文档和可视化报告;训练日志、预测结果csv、模型权重等中间产物均有保留,方便对照检查与复盘。已有63人学习下载,源码经严格测试可正常运行,基础较好的读者也可在其上二次开发,扩展爬虫目标或调整LSTM/机器学习模型。
1. 爬虫与数据分析实践:这份 zip 在解决什么,谁能照着复现
很多人拿到“爬虫与数据分析实践-信息爬取+LSTM预测+机器学习分析(含源码+项目说明+可视化报告).zip”这个包,第一反应是去找 LSTM 源码,第二反应是问能不能直接跑。实际上这类数据分析项目最花时间的从来不是模型,而是“爬下来的数据能不能喂给模型”这一段。它解决的是从零到一的完整链路:用爬虫把数据采下来,清洗成时间序列,用 LSTM 做预测,再用机器学习方法做对比分析,最后输出可视化报告。适合两类人:课程设计或毕设需要端到端项目的学生,以及刚接触数据分析项目、想在一个例子里同时看到采集、建模、结果验证的入门工程师。下文按这条链路拆开讲,重点放在参数怎么设、哪里容易翻车。
2. 信息爬取:用 requests 搭建能扛住断流的最小采集方案
2.1 先定采集目标:URL 规律分析比写代码更省时间
拿到任何爬虫任务,别急着写解析逻辑。先用浏览器开发者工具的 Network 面板观察列表页的请求,确认两件事:数据是 HTML 渲染的还是接口返回的 JSON,翻页参数是怎么传的。常见做法是打开列表页往下拉,看新出现的请求里 page=2 或 offset=20 之类的参数,手动改 URL 能访问到对应页,就说明可以按规律循环采集。
这一步省下的时间非常可观。如果目标页面走 JSON 接口,requests 拿到响应直接解析结构化字段就行;如果走 HTML 页面,还得沉淀一套选择器。对课程设计来说,优先选有 JSON 接口的公开数据源,例如各类公开排行榜、新闻站点列表接口,代码量直接少一半。
选定目标后把 URL 模板钉死:base_url = "https://example.com/api/list?page={page}&page_size=20"。页码从 1 开始循环,每次请求前检查这页返回是否为空,空就 break。用这种“空页中断”而不是 for 硬跑指定范围更稳,因为目标站点实际只有 37 页,你写 range(1, 100) 就会白请求 63 次,还可能因为连续空响应触发风控。requests 在这一层的职责很单纯:按照 URL 规律把数据拿回来,其他逻辑不要混进来。
2.2 请求会话:headers、超时与重试的推荐配置
写爬虫最先要改的是 User-Agent。默认的 python-requests 标识会被很多站点直接拒绝,伪装成浏览器是第一条规则。同时把 Accept、Accept-Language 带上,降低被风控的概率。把请求封装进 Session,好处是自动保持 cookie 和连接复用,翻页时不用每次重新建立 TCP 连接,采集速度快一截。
下面这段是我平时起手的最小配置,覆盖了 headers、超时和重试三个最容易踩坑的点:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", }) retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter)Retry 的 backoff_factor=0.5 表示重试间隔按 0.5、1、2 秒指数递增,避免连续高压请求。status_forcelist 只对 5xx 重试,4xx 是客户端问题,重试没有意义。timeout 统一写成元组 (3, 10):第一个是连接超时,第二个是读取超时。很多爬虫挂在“默认永不超时”上,页面卡住时整个进程就停在那里不走了。
注意:timeout 不设是典型的隐性坑。别人代码里没写 timeout 能跑,是因为那一刻网络好,不代表你可以不写。
采集频率控制也在这层做。常见做法是每页之间 time.sleep(random.uniform(0.5, 1.5)),让请求节奏接近人工浏览。对课程设计这个量级,单线程加随机间隔完全够用;一上来就上并发,反而容易被限流,排查起来还麻烦。
2.3 解析与结构化:JSON 接口优先,HTML 用 CSS 选择器
拿到响应后先看 resp.headers.get("Content-Type"),JSON 的用 resp.json(),HTML 的用 BeautifulSoup 或 lxml。响应体是 JSON 时不要先存文件再读,直接解析落字段;HTML 解析的代码量集中在选择器上。
以新闻列表为例,用 CSS 选择器提取标题、链接、时间,比正则表达式稳得多:
from bs4 import BeautifulSoup resp = session.get(base_url.format(page=1), timeout=(3, 10)) soup = BeautifulSoup(resp.text, "lxml") items = [] for li in soup.select("ul.list li"): title = li.select_one("a.title") time_el = li.select_one("span.time") if title is None: continue items.append({ "title": title.get_text(strip=True), "url": title.get("href"), "publish_time": time_el.get_text(strip=True) if time_el else None, })代码里先判断 title 是否为 None 再取值。列表页面经常混入置顶、推荐位,它们的 DOM 层级不一样,select_one 返回 None 非常常见,不判空整个脚本就会抛 AttributeError。get_text(strip=True) 去掉标题前后的换行和空格;href 有些站点给的是相对路径,存储前要拼接域名,否则后面做 URL 去重时会漏掉同一篇文章。
2.4 落库策略:CSV 先跑通,SQLAlchemy 再补增量与去重
第一版项目不需要上数据库,pandas 直接落 CSV 最快。编码用 utf-8-sig 而不是默认的 utf-8,就是为了让 Excel 打开 CSV 不乱码,这是可视化报告交付时最容易被打回的一个细节。
当采集量上来、脚本要重复跑时,就要考虑去重和增量。先查一遍已采集 URL 集合,再决定要不要插入。用 SQLAlchemy 的好处是换数据库不用改业务代码,课程设计用 SQLite,以后换 MySQL 只改一行连接串。
from sqlalchemy import create_engine import pandas as pd engine = create_engine("sqlite:///items.db") seen = set(pd.read_sql("SELECT url FROM item", engine)["url"]) new_items = [it for it in items if it["url"] not in seen] pd.DataFrame(new_items).to_sql( "item", engine, if_exists="append", index=False )这段代码每次运行前把已存在 URL 加载到 seen,只追加新数据。SQLAlchemy 在这个场景的价值不只是 ORM,而是 create_engine 提供的统一接口:本地用 SQLite,部署到服务器可以换成 PostgreSQL 连接串,业务代码一行不用动。注意 to_sql 默认逐行 insert,几千条没问题,几万条建议分批写,不然事务日志会拖慢入库速度。
3. 数据分析与特征工程:把爬下来的脏数据变成 LSTM 能吃的时间序列
3.1 清洗与索引化:从 DataFrame 到干净的 datetime 序列
爬虫产出的 CSV 打开看,问题永远是那几个:时间字段是字符串甚至带“今天”这种相对描述,数值列混着千分位逗号,某几天直接缺失。先用 pd.to_datetime 统一时间格式,errors="coerce" 让解析失败的行变成 NaT,再按时间列排序。
import pandas as pd df = pd.read_csv("items.csv", parse_dates=["publish_time"]) df = df.dropna(subset=["publish_time"]) df = df.set_index("publish_time").sort_index() series = df["value"].resample("1D").sum() series = series.interpolate(method="time")dropna 只针对 publish_time,不针对 value。因为 value 的缺失可能要单独处理,比如某天站点没更新导致整行不存在,resample("1D") 之后那天会被 NaN 填空。interpolate(method="time") 是按时间间隔做线性插值,比 fillna(0) 合理得多。fillna(0) 会把“缺失”变成“零值”,LSTM 会把这天当作一个真实的低值去学习,预测结果里就会多出莫名其妙的低谷,这是初学者最容易弄错的一步。
3.2 归一化:为什么 MinMaxScaler 是默认选择而不是 StandardScaler
LSTM 内部激活函数是 tanh,输出范围在 -1 到 1 之间,输入特征尺度太大时梯度很容易不稳定。MinMaxScaler 把数据压到 0 到 1 之间,和 tanh 的敏感区间对齐,效果最直接。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_arr = scaler.fit_transform(series_train.to_frame()) test_arr = scaler.transform(series_test.to_frame())这里 fit_transform 和 transform 的区别是整个特征工程的命门:scaler 只允许在训练集上 fit。如果对全量数据 fit,测试期的最大最小值会混进归一化参数,相当于模型提前看到了未来数据的边界,这在时间序列里叫标签泄漏。StandardScaler 不是不能用,但在小样本时间序列场景里,MinMax 保留了原始分布边界,逆变换回去画图更直观,所以我一般默认选 MinMax,只有特征分布极不均匀时才换 RobustScaler。
3.3 滑窗构造:seq_len 的取值逻辑与 X/y 形状
归一化之后要把一维序列变成“窗口 + 标签”的监督学习格式。窗口长度 seq_len 是最关键的超参数。常见做法是看数据的自相关性:日粒度数据先看 7 日和 30 日 lag 的自相关系数,哪个 lag 显著就用哪个做基础,再结合样本量调整。
import numpy as np def make_windows(data, seq_len=30): X, y = [], [] for i in range(len(data) - seq_len): X.append(data[i : i + seq_len]) y.append(data[i + seq_len]) return np.array(X), np.array(y) X_train, y_train = make_windows(train_arr, seq_len=30) X_test, y_test = make_windows(test_arr, seq_len=30)X 的形状是 (样本数, 30, 1),LSTM 需要这种三维输入:第一维是样本,第二维是时间步,第三维是特征数。seq_len 不是越大越好,数据总量 N=1000 时,seq_len=30 得到 970 个样本,seq_len=60 就只剩 940 个,而且多出来的 30 个旧时间步如果和预测目标没有强相关,只会让模型学得更慢。以日周期数据为例,7 和 30 是两个值得先试的值。注意 y 的形状是 (样本数,),后面训练时需要 unsqueeze 成 (样本数, 1)。
3.4 切分不 shuffle:时间序列做训练/验证/测试集划分的边界
很多从图像分类转过来的人会习惯性加上 shuffle,这在时间序列里是灾难。LSTM 学习的是时间步之间的顺序依赖,shuffle 之后训练集里出现“昨天的样本在后、前天的样本在前”,模型学到的就不是时序规律而是噪声。
正确做法是按时间索引切分。常见比例是 8:1:1 或 7:1.5:1.5,先切成三段再分别做滑窗,避免窗口横跨训练集和测试集的边界。这里有个细节:如果先做滑窗再切分,要把窗口的起始索引作为切分依据,否则最后一个训练窗口可能包含了测试期的数据点。我自己习惯用滑动窗口构造的返回索引来切,而不是切完序列再补窗口。
train_end = int(len(series) * 0.8) val_end = int(len(series) * 0.9) train_raw = series.iloc[:train_end] val_raw = series.iloc[train_end:val_end] test_raw = series.iloc[val_end:]三个集合里的样本不能有重叠。数值类项目里随机抽样交叉验证很常见,但时间序列里的随机交叉验证会让同一个时间段既出现在训练又出现在测试,评估出来的指标虚高,放到真实预测场景立刻现原形。
4. LSTM 预测:从网络结构到训练脚本的落地参数
4.1 网络结构:一层 LSTM 加全连接其实够用
LSTM 神经网络的结构不需要一开始就堆得很深。纯数据量几千条的课程设计,一层 LSTM 加一个全连接输出层已经能拟合大部分模式。两层 LSTM 参数量翻倍,训练时间变长,在小数据集上反而更容易过拟合。
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=1): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, _ = self.lstm(x) return self.fc(out[:, -1, :])batch_first=True 让输入形状是 (batch, seq_len, features),这符合 PyTorch 里大多数人的书写习惯。forward 里 out[:, -1, :] 取最后一个时间步的隐状态输出,再接全连接层。input_size=1 表示单变量预测;如果你后面加了星期几、假期标记等多维特征,把 input_size 改成特征数,x 的形状变成 (batch, seq_len, feature_num)。hidden_size=64 是稳妥起点,数据量小于 2000 条时 32 也够,hidden_size 翻倍带来的收益通常不如把 seq_len 调对。
4.2 训练循环:损失函数、优化器与梯度裁剪
训练部分用 MSELoss,这是回归预测的默认选择。优化器用 Adam,学习率 1e-3 起步。LSTM 训到一半 loss 变 nan 是高频事故,梯度裁剪是低成本后悔药,max_norm 设 1.0 基本不会影响正常收敛。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LSTMPredictor().to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) X_train_t = torch.tensor(X_train, dtype=torch.float32).to(device) y_train_t = torch.tensor(y_train, dtype=torch.float32).to(device) for epoch in range(200): model.train() optimizer.zero_grad() pred = model(X_train_t) loss = criterion(pred, y_train_t.unsqueeze(1)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() if (epoch + 1) % 50 == 0: print(f"epoch {epoch+1}, loss {loss.item():.6f}")y_train_t.unsqueeze(1) 把形状从 (样本数,) 变成 (样本数, 1),才能和 pred 对齐做 MSE。clip_grad_norm_ 在 loss.backward() 之后、optimizer.step() 之前调用。200 个 epoch 对几千条样本来说通常是够的,判断收敛不要只看训练 loss,要看验证集 loss 什么时候开始上升,那就是过拟合的信号。CPU 上跑这个规模完全没问题,GPU 的收益在这个数据量下不明显,但 .to(device) 的写法要保留,换机器时不用改逻辑。
4.3 单步预测与递归预测:逆归一化后如何还原成真实数值
预测阶段必须切到 model.eval() 模式,再用 torch.no_grad() 包住推理过程。eval 模式会关闭 dropout 和 batch norm 的训练行为,no_grad 则避免建计算图,省内存也防止误改梯度。
model.eval() with torch.no_grad(): pred_test = model(torch.tensor(X_test, dtype=torch.float32).to(device)) pred_test = pred_test.cpu().numpy() pred_inv = scaler.inverse_transform(pred_test) true_inv = scaler.inverse_transform(y_test.reshape(-1, 1))inverse_transform 必须用训练时保存的 scaler,不能重新 new 一个。reshape(-1, 1) 是因为 sklearn 的 inverse_transform 要求输入是二维列向量。到这里,pred_inv 和 true_inv 都是真实数值,可以直接算 MAE、RMSE,也可以画图对比。
想做多步预测时,有两种路线。单步滚动:把预测值拼回窗口尾部,丢掉窗口第一个值,继续预测下一天,缺点是误差逐步累积;直接多步:修改网络输出维度或加一个序列解码结构,一次预测未来 7 天。递归预测的误差累积问题在下一章展开,这里先记住一个结论:预测步数超过 5 天后,递归方式基本不可用。
4.4 模型持久化:只存 state_dict,不存整个模型
模型训练完,保存方式决定了你下次能否顺利加载。torch.save(model, "lstm.pt") 这种整模型保存方式在 PyTorch 版本升级后经常反序列化失败,只保存 state_dict 最稳:
torch.save(model.state_dict(), "lstm.pt") # 加载时先实例化同结构模型 model_new = LSTMPredictor(input_size=1, hidden_size=64, num_layers=1) model_new.load_state_dict(torch.load("lstm.pt", weights_only=True)) model_new.to(device) model_new.eval()weights_only=True 从 PyTorch 2.x 起是推荐参数,只加载张量不执行 pickle 反序列化,安全性更好。加载的前提是模型结构和保存时完全一致,hidden_size 改过就加载失败。如果还要断点续训,把 optimizer.state_dict() 也一起存进字典,这是设备寿命预测这类长训练任务里的常规做法。
5. 爬虫与 LSTM 实战避坑:5 个高频翻车场景的排查顺序
5.1 爬虫采集一半突然断流:超时重试与断点续采
现象:脚本前 200 页跑得正常,第 201 页开始连续超时,重试三次后整个进程退出,前面的数据白采了。
原因:单进程串行请求,页数越多间隔越不可控,目标服务端压力上来或频率限制生效时,固定重试次数撑不住;更麻烦的是没有断点续采机制,重启后从第 1 页重新来。
解决:外层套一个“采集 + 落库”的循环,每一页的数据先按 URL 去重再追加写入,脚本中断后重启会自动跳过已采集页。重试次数从 3 提到 5,backoff_factor 从 0.5 提到 1.0,给服务端更多喘息空间。另外把目标页数上限写成一个配置文件,不要散落在代码里,跑挂了改参数也方便。
5.2 归一化泄漏:先 fit 训练集,再 transform 测试集
现象:训练 loss 很低,验证 loss 也低,模型在历史数据上预测得很漂亮,但拿到最新一段数据做“未来预测”时,预测值整体偏离真实量级。
原因:对全量序列调用了 scaler.fit_transform。测试期的最大值和最小值参与计算,等于模型在训练时就已经知道未来数据的取值范围。
解决:严格按 3.2 节的方式,训练集 fit_transform,验证集和测试集只 transform。如果未来某天数值确实超出训练范围,那是模型外推能力的问题,不能靠把未来数据混进 scaler 来掩盖。检查方法也很简单:训练结束后把 scaler.data_min_ 和 data_max_ 打印出来,和全量序列的 min/max 对比,不一致就说明泄漏了。
5.3 预测曲线错位:shuffle 把时间顺序打乱了
现象:验证集上预测曲线和真实曲线形状相似,但整体平移了一个窗口长度,像把真实曲线往右挪了一段。
原因:构造滑窗后顺手打了个 shuffle,或者用 sklearn 的 train_test_split 默认 shuffle=True。时间序列经过 shuffle 后,训练集里出现的样本时间顺序打乱,LSTM 学到的依赖关系是错的;因为窗口内数据本身还是连续的,模型只能学到“窗口尾部的值等于标签”这种错位关系。
解决:切分时 shuffle=False,按时间顺序切,或用 3.4 的索引切分法。如果业务上确实需要随机性,那也要保证切分不发生时间穿越:先按时间切出测试区间,再在训练区间内有放回地抽窗口,并且把每个窗口对应的真实时间戳存下来,画图时按时间戳排序而不是按样本顺序。
5.4 训练 loss 变成 nan:学习率、梯度爆炸与数据里的 NaN 来源
现象:训练到第 20 个 epoch 左右,loss 从 0.01 数量级突然变成 nan,后续 epoch 全部是 nan。
原因:三个方向逐个查。第一,数据里有 NaN 没清理,特征工程时 interpolate 只处理了部分缺失,剩余 NaN 进入张量后 loss 计算直接崩;第二,学习率太大,Adam 默认 1e-3 通常没事,有人改成 1e-2 就爆;第三,梯度爆炸,LSTM 在长序列上反向传播时梯度可能指数增长。
解决:先 np.isnan(series).sum() 确认数据干净;再确认梯度裁剪已经加上;最后把学习率降到 3e-4 重新跑。这三个检查按顺序做,90% 的 nan 问题在第一步就能定位。不要一上来就怀疑网络结构,单层 LSTM 很少因为结构原因产生 nan。
5.5 预测曲线总滞后一天:递归误差累积该换多步预测
现象:单步预测评估很准,一旦做多步递归预测,第 3 天之后的预测值几乎变成“上一天的拷贝”,曲线呈现明显的滞后。
原因:递归预测把模型自己的输出当作下一步输入,前一步的误差会逐步放大。模型学到的最小化 MSE 策略就是“下一时刻等于当前时刻”,因为这样在大多数时间步上损失最小,这是所有单步训练模型做多步预测时的通病。
解决:要么固定预测步长,把这个步长作为模型输出维度,一次输出未来 7 天的值,不要一步一步喂回去;要么用 seq2seq 结构,训练时就用 teacher forcing,推理时接受一部分误差。课程设计层面最省力的改法是“一步预测 + 滚动窗口评估”:每次只预测下一天,然后真实值推进窗口,不递归。这样评估出来的是模型真实水平,而不是被误差累积污染后的水平。
6. 机器学习分析与可视化报告:用对比表和残差图验证模型值不值得用
6.1 模型对比:LSTM、XGBoost、线性回归的 MAE/RMSE
LSTM 不是唯一答案,做机器学习分析的目的就是把 LSTM 拉下神坛,用同一个测试集做横向对比。常见做法是准备三组模型:LSTM 用滑窗序列;XGBoost 用滞后特征(滞后 1/7/30 天的值)加滚动均值;线性回归用同样的滞后特征做基线。归一化时 XGBoost 和线性回归不做也行,树模型对尺度不敏感,线性模型做了归一化反而更好解释系数。
指标用 MAE 和 RMSE 两个就够。你跑出来的数值会因为随机种子、数据量、seq_len 不同而有波动,但相对关系通常稳定:如果数据本身有强的线性趋势,线性回归的 MAE 可能和 LSTM 接近;如果数据有复杂的非线性周期,LSTM 或 XGBoost 明显更好。LSTM 在小样本上赢不了 XGBoost 是正常的,不要因此怀疑代码写错了,这背后是“参数多、数据少”的客观约束。
6.2 可视化报告:一张图确认模型学到的规律
可视化报告的核心不是把图堆满,而是让看报告的人一眼看到模型错在哪。我一般用四宫格:真实值 vs 预测值、误差直方图、按星期分组的误差箱线、残差序列。
import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 2, figsize=(12, 8)) axes[0, 0].plot(true_inv, label="true") axes[0, 0].plot(pred_inv, label="pred") axes[0, 0].legend() residual = true_inv.ravel() - pred_inv.ravel() axes[0, 1].hist(residual, bins=30) axes[1, 0].plot(residual) axes[0, 0].set_title("true vs pred") plt.tight_layout() plt.savefig("report.png", dpi=150)这几张图里最有信息量的是残差序列。如果残差在某个区间系统性偏高或偏低,说明模型存在偏差;如果残差还有明显的周期波动,比如周末总是偏高,说明模型没学到星期效应,应该把星期几作为特征加进去,而不是换模型重训。可视化报告的意义就在这:它不是给模型贴金的装饰,而是定位下一步改方向的路标。把 report.png 和指标表拼进项目说明的 README,这份 zip 才算是闭环。
我最早做这类数据分析项目时也喜欢把 LSTM 当主角,后来发现真正花时间的是数据对齐和验证,模型只是最后一个环节。多改几轮特征、多画几次残差图,比换更复杂的网络结构有用得多。希望帮到你。
本文还有配套的精品资源,点击获取