☰
爬虫与LSTM预测:一套完整的数据采集-存储-分析实战源码解析
2026/10/3 2:50:34 网站建设 项目流程

简介:这是一份面向高校计算机相关专业学生的爬虫与数据分析综合实践项目包,融合网络信息爬取、LSTM时序预测与机器学习分析三大模块,可直接用于课程设计、毕业设计或项目初期展示。包内共472个文件,以pkl数据文件、Python源码、ipynb分析笔记、SQL/CSV数据文件及Docker部署配置为主,并附带README与项目说明文档,便于快速理解与复现。压缩包整体约174MB,内置训练日志与可视化结果文件,可直观对比模型效果。项目代码经过严格测试,功能完整,适合人工智能、电子信息、自动化、物联网等专业的学生学习进阶。目前已有63人学习,对基础薄弱者还提供远程运行指导,遇到配置问题可交流求助。下载后可获得完整源码、项目说明、数据文件及可视化报告,方便在此基础上二次开发。

1. 爬虫与数据分析实践:一份能完整跑通「采集-预测-分析」的源码包

需要爬虫、数据库、LSTM 预测、机器学习分析和说明文档全部配套的项目,这份值得拆开看。它不是单独一段爬虫脚本,也不是只给训练代码的 LSTM notebook,而是把信息抓取、MySQL 落库、时序预测、特征分析和可视化报告串成了一条完整链路:爬虫拿到的数据先写进数据库,清洗后导出特征表,再滑窗喂给 LSTM 做预测,预测结果落成 vresult.csv,最后用 sklearn 算指标、用 matplotlib 出对比图。

比起单点教程,它更像一个能直接交作业的工程样板。适合正在做课程设计、毕业设计或比赛初版的人,也适合想完整跑一遍「采集-存储-预测-分析」全流程的学习者。新手照着改 URL 和字段就能运行,熟手可以拿它当地基去换数据源、换预测目标。下面我按数据流向,把爬虫、入库、LSTM 训练到分析报告每个环节怎么改、参数怎么设、坑在哪逐一拆开。

2. 项目结构与数据链路:MySQL 中转、CSV 输出、TensorBoard 日志各司其职

拿到压缩包别急着跑,先按文件清单过一遍。这个包除了源码和项目说明,还能看到几类特殊文件:events.out.tfevents.*、vresult.csv、*_loss_log.csv,以及binlog.000012、general_log、slow_log这类 MySQL 相关文件。第一次拆这类项目的人容易懵,其实它们各有各的用途,有的必须看懂,有的可以直接忽略。

2.1 包里的文件在项目里分别干什么

文件/文件类型在项目里的角色处理建议
events.out.tfevents.*TensorBoard 训练日志,记录 loss 曲线和指标训练时自动生成,可用tensorboard --logdir查看
*_loss_log.csv每轮训练损失,CSVLogger 回调输出分析收敛情况时优先读它
vresult.csv预测结果表,一般含真实值、预测值、时间戳机器学习分析和画图的主入口
binlog.000012/general_log.*/slow_log.*MySQL 运行日志和系统文件打包时残留的运行痕迹,不用管,建议忽略
源码 + 项目说明 + 可视化报告项目主体按说明文档里的顺序阅读

看到events.out.tfevents.*基本能判断技术栈是 TensorFlow 那套,配合*_loss_log.csv说明训练时同时开了 TensorBoard 和 CSVLogger 两种记录方式。vresult.csv是整个项目的出口,后面机器学习分析、可视化报告都从这张表取数。至于binlog、general_log、slow_log,是开发环境 MySQL 数据目录里的日志文件被一起打进了压缩包,跑代码用不到它们。有同学下载后直接拿这些目录当自己的数据文件用,容易踩各种玄学问题,建议一律忽略,自己新建库。

2.2 数据链路为什么这样设计:MySQL 当中间层,CSV 当接口

整个项目的核心不是某个单个模型,而是这条数据管道。数据流按顺序走四个节点:requests 抓取页面并解析字段,SQLAlchemy 写入 MySQL 做去重和增量,查询结果导出成特征 CSV,再滑窗构造成 LSTM 输入。预测完成写出vresult.csv,最后用 sklearn 评估、matplotlib 出报告。

很多人图省事直接 requests → pandas → CSV → LSTM,小 demo 没问题,但一旦要断点续爬、按日期增量,CSV 的痛点就出来了:去重要自己维护、并发写容易冲突、字段类型不严格。MySQL 做中转层,唯一键去重由数据库保证,爬虫崩了重跑不会重复入库。输出端再统一查成 CSV 给训练脚本,这样训练代码不依赖数据库环境,换台机器只要 CSV 就能复现实验。

我一般会把这个链路想成「数据管道」而不是「爬虫加模型」:管道里每一段的输出都要能被下一段直接消费,管道里的任一环节崩了,只重跑这一段就行。项目里vresult.csv单独存而不是直接写回 MySQL,也是这个道理——分析脚本只读文件,不碰数据库,环境依赖降到最低。如果只在一台机器上跑,把 MySQL 换成 SQLite 也可以,代码层面只需要改 SQLAlchemy 的连接串。

2.3 运行前环境准备与最小配置

跑通这个项目需要 Python 3.8+、MySQL 5.7+,以及 requests、sqlalchemy、pymysql、pandas、numpy、tensorflow、scikit-learn、matplotlib 这几个库。tensorflow 装 CPU 版就够,序列量级不大时 CPU 和 GPU 的差距体现不出来。建库时记住两点:字符集用utf8mb4,排序规则选utf8mb4_unicode_ci,连接串里带上charset=utf8mb4,否则中文入库很容易乱码。

CREATE DATABASE IF NOT EXISTS spider DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS articles ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, url VARCHAR(500) NOT NULL, publish_time DATETIME NULL, pv INT DEFAULT 0, category VARCHAR(64) DEFAULT 'unknown', UNIQUE KEY uq_article_url (url) ) ENGINE=InnoDB CHARSET=utf8mb4;

这张表和后面 SQLAlchemy 的 ORM 模型是一一对应的,URL 上的唯一键uq_article_url是去重的关键。建表时没有加唯一键的话,后面爬虫重复请求会把同一篇文章插好几遍,清洗阶段还得再花力气去重。DDL 里的publish_time允许为空,是为了应对上游数据缺失时间字段的情况,宁可留空也别让整条记录报废。

3. 信息爬取落地:requests 请求、字段清洗与 SQLAlchemy 入库参数

爬虫部分是整条链路的入口,这里的代码质量直接决定后面数据库和模型能不能省心。很多项目死在爬虫上不是因为反爬多厉害,而是请求层和入库层写得不够稳。下面这三段代码是项目里最核心的部分,按请求、解析、入库三个层次拆开。

3.1 请求层:超时、重试、UA 伪装一个都不能少

import requests import time from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept": "application/json, text/html, */*", } def fetch(url, params=None, max_retries=3, timeout=8): session = requests.Session() retry = Retry(total=max_retries, backoff_factor=0.8, status_forcelist=[500, 502, 503, 504]) session.mount("https://", HTTPAdapter(max_retries=retry)) session.mount("http://", HTTPAdapter(max_retries=retry)) for attempt in range(max_retries): try: resp = session.get(url, params=params, headers=HEADERS, timeout=timeout) if resp.status_code == 200: return resp elif resp.status_code in (403, 429): print(f"[blocked] status={resp.status_code}, sleep {2 * (attempt + 1)}s") time.sleep(2 * (attempt + 1)) except requests.RequestException as exc: print(f"[retry] {attempt + 1}/{max_retries}: {exc}") time.sleep(1.5 * (attempt + 1)) return None

这段代码把请求层该处理的三个问题都堵上了。Retry里的status_forcelist指定哪些状态码触发重试,500、502、503、504 是服务端临时故障,重试合理;backoff_factor=0.8表示重试间隔按 0.8 秒、1.6 秒、3.2 秒递增,避免刚恢复的服务又被一波请求压垮。timeout=8是给每个请求设置硬超时,没有它,某个连接卡住会让整个爬虫挂在那不动。

403 和 429 是反爬信号,这里没有走重试逻辑,而是指数退避后继续下一次循环。我跑采集任务时见过不少人一遇到 403 就换代理换 IP,其实先降频率、把 UA 和必要的 Headers 补齐,大部分静态站点就能放行。requests 爬虫最容易被封的就是缺 UA 和固定频率,这两个血泪教训一定要记住。

3.2 字段解析与清洗:把杂乱响应变成规整记录

import pandas as pd def parse_item(raw, source="api"): if source == "api": data = raw["data"]["list"] else: data = raw # 表格页面时,raw 是 pandas 读出的 DataFrame records = [] for item in data: rows = { "title": str(item.get("title", "")).strip(), "url": item.get("url", ""), "publish_time": pd.to_datetime(item.get("time"), unit="s", errors="coerce"), "pv": int(item.get("pv", 0) or 0), "category": item.get("category", "unknown"), } if rows["title"] and rows["url"]: records.append(rows) return pd.DataFrame(records)

解析函数的核心是「每个字段都带默认值兜底」。item.get("title", "")防止 KeyError,.strip()去掉前后空格,pd.to_datetime(..., errors="coerce")会把无法解析的时间转成 NaT,int(item.get("pv", 0) or 0)这个写法是同时处理None和空字符串的情况,避免类型转换报错。最后强制要求 title 和 url 非空才保留,缺关键字段的记录直接丢弃。

清洗的原则是:上游字段永远可能是脏的,解析阶段宁可丢数据,也不要让脏数据进库。实际跑的时候,这个函数返回的 DataFrame 列名要和数据库表字段严格一致,SQLAlchemy 这边才能直接用**row.to_dict()插入,否则会出现字段对不上导致的插入失败。如果爬的是 HTML 页面而不是 JSON API,把source换成"html"分支,用 pandas 的read_html读表格也很省事。

3.3 SQLAlchemy 入库:ORM 模型、唯一键去重与增量更新

from sqlalchemy import create_engine, Column, String, Integer, DateTime, UniqueConstraint from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Article(Base): __tablename__ = "articles" __table_args__ = (UniqueConstraint("url", name="uq_article_url"),) id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(255), nullable=False) url = Column(String(500), nullable=False) publish_time = Column(DateTime, nullable=True) pv = Column(Integer, default=0) category = Column(String(64), default="unknown") engine = create_engine( "mysql+pymysql://root:password@localhost:3306/spider?charset=utf8mb4", echo=False ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() def insert_ignore_duplicate(df): added = skipped = 0 for _, row in df.iterrows(): exists = session.query(Article).filter_by(url=row["url"]).first() if exists: skipped += 1 continue session.add(Article(**row.to_dict())) added += 1 if added % 50 == 0: session.commit() session.commit() print(f"inserted={added}, skipped={skipped}")

ORM 模型里的UniqueConstraint("url")和 2.3 节 DDL 里的唯一键是同一件事,保证同一 URL 只入库一次。insert_ignore_duplicate用先查再插的方式做增量写入,每次批量跑完打印 inserted 和 skipped 计数,方便确认爬虫到底新增了多少数据。

注意这里没有用INSERT ... ON DUPLICATE KEY UPDATE做 upsert,原因是这个场景里重复数据直接跳过就行,不需要更新已有记录。如果业务上要求重复时刷新pv字段,那再考虑用 MySQL dialect 的 upsert 写法。另一个细节是每 50 条 commit 一次:爬虫脚本跑几个小时是常态,如果最后一次性 commit,中途断电会全部回滚,分批提交能把失败代价控制在最近 50 条以内。

4. LSTM 预测实战:滑窗构建、归一化、训练回调与损失解读

LSTM 部分是这个项目里最容易被玄学化的一段。其实时序预测的代码套路很固定:先构造监督学习样本,再归一化,再搭网络训练。踩坑的地方往往不在模型结构,而在数据切分和归一化这两个环节。下面按项目实际顺序走一遍。

4.1 从 CSV 构造监督样本:look_back 与步长的取舍

import numpy as np import pandas as pd def build_dataset(series, look_back=12, step=1): X, y = [], [] for i in range(0, len(series) - look_back - 1, step): X.append(series[i : i + look_back]) y.append(series[i + look_back]) return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) df = pd.read_csv("input_series.csv", parse_dates=["date"]).sort_values("date") values = df["value"].values.reshape(-1, 1) split_idx = int(len(values) * 0.8) train_raw, test_raw = values[:split_idx], values[split_idx:] X_train, y_train = build_dataset(train_raw, look_back=12) X_test, y_test = build_dataset(test_raw, look_back=12) print(f"X_train.shape={X_train.shape}, X_test.shape={X_test.shape}")

look_back=12表示用过去 12 个时间点预测下一个点,这个值要根据数据节奏调:日粒度数据可以试 7、14、30,小时粒度数据可以试 24、48。我的习惯是先看数据的周期性,没有一个万能值。step参数在数据量大时用来隔点采样,比如step=2表示每两个样本取一个,能减少训练量,但会丢失部分信息,默认 1 就好。

这段代码最关键的是split_idx的位置:时序切分必须按时间顺序,从时间轴第 80% 处硬切,前面做训练集、后面做测试集。很多新手在这里用了train_test_split的默认随机切分,导致训练集里混进测试时间段的数据,指标虚高得离谱,这个问题在 5.2 节专门讲。

4.2 MinMaxScaler 归一化与 Keras LSTM 模型搭建

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler.fit_transform(train_raw.reshape(-1, 1)) test_scaled = scaler.transform(test_raw.reshape(-1, 1)) X_train, y_train = build_dataset(train_scaled.flatten(), look_back=12) X_test, y_test = build_dataset(test_scaled.flatten(), look_back=12) X_train = X_train.reshape((X_train.shape[0], X_train.shape[1], 1)) X_test = X_test.reshape((X_test.shape[0], X_test.shape[1], 1)) import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense model = Sequential([ LSTM(32, activation="tanh", return_sequences=True, input_shape=(12, 1)), LSTM(16, activation="tanh"), Dense(1) ]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss="mae", metrics=["mae"] ) model.summary()

归一化这里有个容易出错的细节:fit_transform只用在训练集上,测试集必须用同一个scaler.transform,绝不能让测试数据参与 scaler 的拟合,否则就是数据泄漏。你把测试集的信息提前告诉了模型,验证结果就没有意义了。这段代码先整体归一化再切窗,顺序是反的也没事?不,顺序很重要——先归一化整段原始数据再按索引切分,和先切分再分别归一化,效果有细微差别。这里写的是先归一化再切窗,前提是 scaler 只 fit 训练部分。稳妥做法是切分后再对训练段 fit,再 transform 测试段,上面代码里train_raw和test_raw已经切好,所以fit_transform作用在训练段,逻辑是对的。

网络结构用两层 LSTM 加一层 Dense:第一层return_sequences=True是为了把完整序列传给第二层,第二层不返回序列只输出最后一个隐藏状态,最后接一个线性层输出预测值。units 从 32 到 16 递减是常见做法,序列简单时 16 个单元就够,复杂序列可以加大到 64。loss="mae"不是默认选项,我偏好它是因为对异常值比 MSE 稳健,预测结果做反归一化后量纲也更直观。

4.3 训练回调:CSVLogger、TensorBoard、EarlyStopping 与损失解读

from tensorflow.keras.callbacks import EarlyStopping, CSVLogger, TensorBoard callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), CSVLogger("train_loss_log.csv", append=False), TensorBoard(log_dir="logs/fit", histogram_freq=1) ] history = model.fit( X_train, y_train, validation_data=(X_test, y_test), epochs=100, batch_size=32, callbacks=callbacks, verbose=1 )

这三个回调是项目里*_loss_log.csv和events.out.tfevents.*的直接来源。CSVLogger把每轮训练和验证的 loss 写进 CSV,训练结束后不用重新跑模型就能分析收敛情况。TensorBoard记录更详细的训练过程,命令行执行tensorboard --logdir logs/fit就能在浏览器里看曲线。EarlyStopping的patience=10表示 val_loss 连续 10 轮不改善就自动停,restore_best_weights=True会把模型权重回滚到最优的那一轮,相当于免费防过拟合。

训练完最该看的是 loss_log CSV 里最后几行的训练 loss 和验证 loss 差值。如果训练 loss 一路降、验证 loss 却反弹,说明模型开始记训练集了;如果两者都高,先怀疑归一化或数据切分出了问题。实际项目里我通常把 epochs 设成 100 到 200,配合早停,真正跑多少轮由数据自己决定。

5. 避坑与排查:从爬虫到 LSTM 的五个典型翻车点

这部分是血泪经验的集合。项目里遇到的坑其实非常集中:反爬、乱码、数据泄漏、Loss 不收敛、预测滞后。下面五条是从采集端到预测端最常见的问题,按「现象 → 原因 → 解决」拆开讲。先看现象总览,再逐条过。

5.1 先看现象类型,再定位原因

现象大概率原因解决方向
爬虫跑一会就返回验证码或 403请求频率太高,或 UA 缺失随机延时加慢请求,补全请求头
数据库里中文全是问号建库字符集不是 utf8mb4,连接串漏 charset重建库,连接串补charset=utf8mb4
LSTM 训练 loss 变成 nan数据未归一化,或学习率过大检查 inf 值,学习率降到 0.001
预测曲线比真实值滞后一拍单步滑窗预测的均值回归现象改多步预测,评估趋势而非点值
测试集指标好得不真实切分数据时打乱了时间顺序按时间截断,禁止随机 shuffle

5.2 五个典型坑:现象、原因、解决

坑 1:爬虫请求超时或返回验证码页

现象是脚本跑几十条后突然拿不到数据,返回的 HTML 里全是验证码或者「访问异常」。原因是请求频率太高,同一个 IP 和 UA 被服务端识别。解决方法是请求层加上随机延时,1 到 3 秒之间的随机 sleep,比固定 sleep 更有用;UA 做成列表轮换,每次请求换一个。把 403 和 429 状态码当作信号而不是报错,主动退避重试。我见过不少人一被限流就去换 IP 池,其实对小型爬虫来说降频率比换 IP 更靠得住,先把频率和伪装做好再谈其他。

坑 2:MySQL 写入中文乱码

现象是数据库表里中文标题全是问号。原因几乎都是字符集问题:建库时没有指定utf8mb4,或者连接串里漏了charset=utf8mb4。解决方法是删库重建,或者新建库时执行 2.3 节那段 DDL,确保库、表、连接三层字符集一致。已经在表里的乱码数据靠 ALTER 修复基本没戏,直接 drop 掉按正确字符集重建,再重跑一遍入库脚本,十分钟的事。这个坑踩一次就够了,所以我在 2.3 节特意强调 utf8mb4。

坑 3:LSTM 训练 loss 变成 nan

现象是训练到第几轮 loss 突然变成 nan,后面所有 epoch 全废。原因最常见的是输入序列里有 inf 值,或者数据没归一化;其次是学习率太大导致梯度爆炸。解决方法是按顺序排查:先检查输入数据有没有 inf 或超大值,再确认 MinMaxScaler 已经 fit 过,最后把 Adam 的学习率从 0.01 降到 0.001,必要时加梯度裁剪。这个坑 90% 的情况出在前两步,模型结构本身很少是 nan 的元凶。

坑 4:预测曲线比真实值「滞后一拍」

现象是预测曲线整体向右平移了一段,看起来像昨晚的真实值被原样搬到了今天。原因是单步滑窗预测在序列相关性强的数据上会退化成「复制最近值」的模型,这是 LSTM 时序预测的经典现象,不是模型坏了。解决方法是别拿预测值和真实值逐点比大小,改看趋势方向和拐点位置;或者改成多步预测,一次预测未来 N 个点,强迫模型学习外推而不是复制。评估指标也要跟着换,用趋势命中率比 MAE 更贴近真实业务。

坑 5:切分数据时打乱时间顺序,指标虚高

现象是测试集 MAE 好得不真实,R² 高到不敢信,但画图发现预测值完美贴合真实值。原因是用train_test_split默认的随机切分,把时间顺序打乱了,训练集里混进了测试段的数据。解决方法是时序切分必须按时间索引硬截断,train_test_split在这里要么传shuffle=False,要么干脆手动切片,就像 4.1 节里values[:split_idx]和values[split_idx:]那样。这是我做第一个单变量预测时踩过的坑,当时 R² 高达 0.98,一查原因就是泄漏,从那以后我每次切分都先打印两端的时间戳确认顺序。

5.3 跑长任务前留好「后悔药」:五分钟检查清单

训练跑十几个小时之前,先花五分钟过一遍这个清单,能省下大量返工时间。第一,爬虫字段和数据库表结构是否严格对应,列名、类型都要核。第二,时间序列是否严格按时间升序排列,排序前先确认有没有重复时间戳。第三,归一化是否只 fit 了训练集,测试集没有参与。第四,loss_log.csv的第一行数据是不是正常初始值,而不是空文件或乱码。第五,MySQL 连接串是否带charset=utf8mb4。

6. 机器学习分析与可视化验证:用基线对比和指标报告证明 LSTM 效果

预测跑完不算完事,第 5 章的坑都可能埋在这里。Kaggle 项目里 LSTM 不是竞品,是团队方案的差异化亮点。项目说明里写的「机器学习分析」不只是画图,而是用 sklearn 的基线模型和指标把 LSTM 的效果量化出来,让报告里的每个数字都能被复查。

6.1 先跑一个线性回归基线

from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score result = pd.read_csv("vresult.csv") y_true = result["true_value"].values y_pred = result["pred_value"].values X_flat = X_test.reshape(len(X_test), -1) lr = LinearRegression() lr.fit(X_train.reshape(len(X_train), -1), y_train) lr_pred = lr.predict(X_flat) print("LinearRegression MAE:", mean_absolute_error(y_true, lr_pred)) print("LSTM MAE:", mean_absolute_error(y_true, y_pred)) print("R2:", r2_score(y_true, y_pred))

机器学习分析的第一步永远是对照组。线性回归在同样的滑窗特征上训练,如果它的 MAE 和 LSTM 差不多,说明序列规律很简单,LSTM 是杀鸡用牛刀;只有 LSTM 明显优于线性回归时,时序记忆模块的价值才成立。这一步放进课设和毕设报告里,是有分量的一笔。

6.2 可视化报告:损失曲线加预测对比图

import matplotlib.pyplot as plt plt.style.use("ggplot") fig, axes = plt.subplots(1, 2, figsize=(12, 4)) loss_df = pd.read_csv("train_loss_log.csv") axes[0].plot(loss_df["epoch"], loss_df["loss"], label="train") axes[0].plot(loss_df["epoch"], loss_df["val_loss"], label="val") axes[0].set_title("Loss Curve") axes[0].legend() axes[1].plot(y_true, label="true") axes[1].plot(y_pred, label="pred") axes[1].set_title("Prediction vs Truth") axes[1].legend() fig.tight_layout() plt.savefig("report.png", dpi=150)

两张图构成可视化报告的核心:左边是训练损失曲线,右边是预测对比。如果右边两条线整体贴近但不重合,后退一步看是不是 5.2 节坑 4 说的滞后问题。dpi=150出图够清晰,直接插进毕设报告不会糊。项目包里的资料是按这个思路组织的,看到报告基本能反推每个图表对应的 CSV 来源。

6.3 核对产物再收工

收尾时把 vresult.csv 和 loss_log.csv 打开核对一遍:预测结果表的行数等于测试样本数,时间列严格递增;训练日志最后一轮的损失低于初始状态。确认这两点后,报告里的每一个数字都能查到出处,不会出现图和数据对不上的尴尬。

我从第一次跑类似项目到现在,养成一个强迫症:训练完不看模型打印的 loss,而是先把 vresult.csv 加载进来,逐列核对行数、时间和数值区间,确认没有 NaN 再画图。包里那几个 CSV 就是我每次都要手动留底的产物,虽然多花五分钟,但能让报告里的每个结论都查得到来源。希望这个习惯和这套流程对你有帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询