ML-For-Beginners:从 Pickle 到 Flask —— 用 UFO 目击预测案例把 Scikit-learn 模型部署为 Web 应用
本文以 ML-For-Beginners 课程第 3 单元《Build a Web App to use a ML Model》(本课同时提供完整的阿拉伯语翻译)为主体,完整复刻其全部实操流程:使用约 8 万条 NUFORC 不明飞行物(UFO)目击数据训练一个 Scikit-learn 逻辑回归模型,用 Python 内置的 Pickle 模块把训练好的模型序列化为 .pkl 文件,再搭建一个 Flask 应用读取该模型文件,通过网页表单接收"秒数、纬度、Longitude 经度"三个输入并返回预测的目击国家。读完本文,你将掌握"训练 → 序列化 → Web 服务化"这条把模型送上网页的完整链路,并理解部署前需要做的架构决策。
课程背景与学习目标
本课属于 ML-For-Beginners(12 周、26 课、52 测验)中"经典机器学习"路线的第 3 单元。此前几个单元已用 Jupyter Notebook 完成了南瓜数据的回归、分类、聚类等练习,本课把流程往前推进一步:让模型走出 Notebook,进入"真实世界"(in the wild)——一个可以用浏览器访问的 Web 应用。
课程明确的两项学习目标是:
- 如何用 Pickle 把一个训练好的模型"打包"(pickle)保存下来;
- 如何在 Flask 应用中加载并使用这个已打包的模型。
数据来源是 NUFORC(National UFO Reporting Center,美国不明飞行物报告中心)数据库,包含过去一个世纪约 8 万条 UFO 目击记录。数据文件 ufos.csv 约 8 万行,字段包括目击发生的城市(city)、州(state)、国家(country)、物体形状(shape)、纬度(latitude)与经度(longitude)等。数据中不乏有趣的目击描述,课程给出的两个例子:
- 长描述示例:"一名男子从一个照在夜间草地上的光柱中走出,朝 Texas Instruments 的停车场跑去";
- 短描述示例:"那些光追着我们"。
部署架构决策:在动手前先问清 5 个问题
课程强调,构建"消费 ML 模型的 Web 应用"有多种方式,而且 Web 架构本身会反过来影响模型该如何训练。课程让你代入一个典型企业场景:数据科学团队已经训练好一个模型,现在要求你在应用里消费它。在动手前,需要先回答以下问题:
| 决策问题 | 课程给出的思考要点 |
|---|---|
| 是 Web 应用还是移动应用? | 若是移动端或 IoT 场景,可以用 TensorFlow Lite 把模型放到 Android / iOS 应用中运行 |
| 模型驻留在哪里? | 云端还是本地?这决定了推理发生在服务器端还是客户端 |
| 是否需要离线支持? | 应用是否必须在断网状态下工作,直接影响模型部署位置 |
| 模型用什么技术训练的? | 训练框架决定了可用的导出与消费工具链,见下方三种技术路线 |
| 能否直接在浏览器中训练? | Flask 应用甚至可以在 Web 浏览器内训练模型,用 TensorFlow.js 也能在 JavaScript 上下文中做到 |
针对"训练技术栈"这一条,课程列举了三条常见路线:
- TensorFlow 路线:用 TensorFlow 训练的模型可借助 TensorFlow.js 转换后直接在 Web 应用中消费;
- PyTorch 路线:PyTorch 模型可导出为 ONNX(Open Neural Network Exchange)格式,供能使用 ONNX Runtime 的 JavaScript Web 应用加载(课程提到这一选项将在后续一节的 Scikit-learn 模型练习中展开);
- ML SaaS 路线:使用 Lobe.ai 或 Azure Custom Vision 这类"机器学习即服务"平台训练时,这类软件天然提供把模型导出到多种平台的方式,包括构建一个自定义 API,让你的在线应用从云端查询它。
而本课因为前面一直使用 Python Notebook,所以聚焦最贴近读者的一条路径:如何把一个在 Notebook 中训练好的模型导出成 Python Web 应用可读的格式。
工具链:Flask 与 Pickle
课程明确本任务只需要两个 Python 侧工具:
Flask:由创建者自称为"微框架"(micro-framework),提供 Python Web 框架的基础能力(路由、请求处理、静态资源)以及用于构建网页的模板引擎。它足够轻量,可以在几十个代码行内跑起一个完整的预测服务。
Pickle:Python 标准库中负责序列化(serialize)与反序列化(de-serialize)Python 对象结构的模块。当你"pickle"一个模型时,实际上是把它的对象结构扁平化,以便在 Web 环境中加载使用。课程特别给出了安全警告:Pickle 并非天生安全——.pkl 文件本质上是可执行的对象状态,不要随意反序列化来源不可信的 pickle 文件。
实操一:清洗 UFO 数据
课程附带的 notebook.ipynb 是一份空白笔记本,要求学生按以下步骤自己填写代码;完整填写后的版本见 solution/notebook.ipynb,其中保留了每一步的真实运行输出,可用来核对结果。
第 1 步:导入依赖并读取数据
import pandas as pd
import numpy as np
ufos = pd.read_csv('./data/ufos.csv')
ufos.head()
ufos.head() 会展示前 5 条记录,可以看到 datetime、city、state、country、shape、duration (seconds)、duration (hours/min)、comments、date posted、latitude、longitude 等列,以及像 NaN 这样的缺失值。
第 2 步:裁剪为四列的精简数据框
只保留建模需要的 4 列,并重命名字段;同时检查 Country 字段的唯一值:
ufos = pd.DataFrame({'Seconds': ufos['duration (seconds)'], 'Country': ufos['country'],'Latitude': ufos['latitude'],'Longitude': ufos['longitude']})
ufos.Country.unique()
从解题笔记本的实际输出看,该字段唯一值为 array(['us', nan, 'gb', 'ca', 'au', 'de'], dtype=object)——即美国、英国、加拿大、澳大利亚、德国五个国家代码,外加缺失值。笔记本中还留有一行关键注释,给出了国家码与后续标签的对应关系:# 0 au, 1 ca, 2 de, 3 gb, 4 us。
第 3 步:去空值并过滤持续时间
删除空值,只保留目击持续时间为 1–60 秒的记录:
ufos.dropna(inplace=True)
ufos = ufos[(ufos['Seconds'] >= 1) & (ufos['Seconds'] <= 60)]
ufos.info()
ufos.info() 的真实输出显示,过滤后数据框剩余 25,863 条记录、4 列(Seconds、Country、Latitude、Longitude),其中 Country 在去空值后无缺失。
第 4 步:用 LabelEncoder 把国家编码为数字
Scikit-learn 的模型只能处理数值特征,因此用 LabelEncoder 把文本国家码转换为整数。注意课程提示的编码规则:LabelEncoder 按字母顺序(alphabetically)编码,即 au→0, ca→1, de→2, gb→3, us→4——这一点在后面的 Web 应用里还要用到,因为 Flask 端必须用同一张映射表把预测出的数字翻译回国家名。
from sklearn.preprocessing import LabelEncoder
ufos['Country'] = LabelEncoder().fit_transform(ufos['Country'])
ufos.head()
课程给出的预期数据形态(与解题笔记本输出一致):
Seconds Country Latitude Longitude
2 20.0 3 53.200000 -2.916667
3 20.0 4 28.978333 -96.645833
14 30.0 4 35.823889 -80.253611
23 60.0 4 45.582778 -122.352222
24 3.0 3 51.783333 -0.783333
实操二:训练逻辑回归模型
划分训练集与测试集
以 Seconds、Latitude、Longitude 为特征 X,Country 为目标 y。目标能力是:输入秒数、纬度、经度,返回国家标签。
from sklearn.model_selection import train_test_split
Selected_features = ['Seconds','Latitude','Longitude']
X = ufos[Selected_features]
y = ufos['Country']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
参数说明:test_size=0.2 表示 20% 数据留作测试(25,863 条中约 5,173 条);random_state=0 固定随机种子,保证划分结果可复现。
用 LogisticRegression 训练并评估
from sklearn.metrics import accuracy_score, classification_report
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train, y_train)
predictions = model.predict(X_test)
print(classification_report(y_test, predictions))
print('Predicted labels: ', predictions)
print('Accuracy: ', accuracy_score(y_test, predictions))
课程对结果的定性判断是:精度不低(约 95%),这并不意外,因为国家与经纬度天然强相关。解题笔记本保留的真实输出更细致:
Accuracy: 0.9512855209742895,测试集共 5,173 条;- 分类报告中,标签 4(US,4,743 条)与标签 3(gb,131 条)的 precision/recall 都接近满分,而少数类(如 de 只有 8 条、ca 250 条)几乎没被预测到,
macro avg只有 0.78。
这正是一个很好的教学点:总体 accuracy 很高不代表每个类别都被学到了,类别严重不平衡时 macro 指标会暴露问题。课程也坦言,这个模型"并不革命性"——从经纬度推断国家本应如此——但它的价值在于完整走通"清洗原始数据 → 训练 → 导出 → 上 Web"这一流程。
实操三:Pickle 序列化模型
训练完成后,用几行代码把模型"pickle"并立即回载验证:
import pickle
model_filename = 'ufo-model.pkl'
pickle.dump(model, open(model_filename,'wb'))
model = pickle.load(open('ufo-model.pkl','rb'))
print(model.predict([[50,44,-12]]))
pickle.dump(model, open(model_filename,'wb')):以二进制写模式(wb)把模型对象写入ufo-model.pkl;pickle.load(open('ufo-model.pkl','rb')):以二进制读模式(rb)把对象还原为内存中的模型;- 验证输入
[50, 44, -12](50 秒、北纬 44 度、西经 12 度)的预测输出为[3],即国家码 3 —— 英国(gb)。
这个"保存 → 回载 → 单点预测"的验证步骤非常重要:它证明序列化没有破坏模型,且确认了输入形状——模型需要一条含 3 个数值特征的样本才能做出预测,后面 Flask 端组装请求数据时正是依据这一点。仓库 solution 目录 中提供了一个现成的 ufo-model.pkl 成品文件,可以直接拿来跑 Web 应用部分。
实操四:搭建 Flask 应用
目录结构
在 notebook.ipynb 和 ufo-model.pkl 所在目录旁创建 web-app 文件夹,并在其中建立 static/css 与 templates 两个子目录,最终结构如下:
web-app/
static/
css/
templates/
notebook.ipynb
ufo-model.pkl
成品应用可对照 solution/web-app 查看。
第 1 个文件:requirements.txt
与 JavaScript 应用中的 package.json 类似,requirements.txt 列出应用依赖。课程要求写入以下四行(与 solution 目录中的 requirements.txt 完全一致):
scikit-learn
pandas
numpy
flask
然后进入目录安装依赖:
cd web-app
pip install -r requirements.txt
第 2 个文件:static/css/styles.css
课程给出的完整样式(深色主题 + CSS Grid 布局):
body {
width: 100%;
height: 100%;
font-family: 'Helvetica';
background: black;
color: #fff;
text-align: center;
letter-spacing: 1.4px;
font-size: 30px;
}
input {
min-width: 150px;
}
.grid {
width: 300px;
border: 1px solid #2d2d2d;
display: grid;
justify-content: center;
margin: 20px auto;
}
.box {
color: #fff;
background: #2d2d2d;
padding: 12px;
display: inline-block;
}
要点:body 设定黑底白字的大字号标题风格;.grid 用 display: grid 将表单容器居中并限制为 300px 宽;.box 是表单所在卡片的背景色。
第 3 个文件:templates/index.html
Flask 模板页面(Jinja2 模板语法):
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>🛸 UFO Appearance Prediction! 👽</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<div class="grid">
<div class="box">
<p>According to the number of seconds, latitude and longitude, which country is likely to have reported seeing a UFO?</p>
<form action="{{ url_for('predict')}}" method="post">
<input type="number" name="seconds" placeholder="Seconds" required="required" min="0" max="60" />
<input type="text" name="latitude" placeholder="Latitude" required="required" />
<input type="text" name="longitude" placeholder="Longitude" required="required" />
<button type="submit" class="btn">Predict country where the UFO is seen</button>
</form>
<p>{{ prediction_text }}</p>
</div>
</div>
</body>
</html>
模板里有两处 Jinja2 "mustache"({{ }})语法值得注意:
{{ url_for('static', filename='css/styles.css') }}:由 Flask 生成静态 CSS 的 URL,url_for是 Flask 内置的路由 URL 反查函数;{{ prediction_text }}:预测结果占位符,由render_template在响应时注入。
表单 action="{{ url_for('predict') }}" method="post" 表明:三个输入框以 POST 方式提交到 /predict 路由;seconds 输入框用 min="0" max="60" 做了前端约束,呼应数据清洗时"只训练 1–60 秒样本"的设定。
第 4 个文件:app.py(核心)
这是驱动"模型消费 + 预测展示"的 Python 文件,课程给出的完整代码:
import numpy as np
from flask import Flask, request, render_template
import pickle
app = Flask(__name__)
model = pickle.load(open("./ufo-model.pkl", "rb"))
@app.route("/")
def home():
return render_template("index.html")
@app.route("/predict", methods=["POST"])
def predict():
int_features = [int(x) for x in request.form.values()]
final_features = [np.array(int_features)]
prediction = model.predict(final_features)
output = prediction[0]
countries = ["Australia", "Canada", "Germany", "UK", "US"]
return render_template(
"index.html", prediction_text="Likely country: {}".format(countries[output])
)
if __name__ == "__main__":
app.run(debug=True)
执行 python app.py(或 python3 app.py)后,本地 Web 服务器启动,即可在浏览器中填写表单获得预测。
app.py 执行流程解析
按课程对代码的拆解,启动与请求阶段共发生以下事情:
启动阶段(模块导入即执行):
- 加载依赖并创建 Flask 实例
app = Flask(__name__); - 用
pickle.load从./ufo-model.pkl读入训练好的模型——模型只加载一次,常驻内存; - 注册两条路由:
/渲染首页,/predict处理 POST 预测请求。
/predict 请求处理阶段(表单提交后):
request.form.values()收集三个表单值,列表推导式逐一int()转成整数,再经np.array包装、外层再套一层列表——得到形如[[s, lat, lon]]的二维数组,这正是LogisticRegression.predict期望的"一批样本、每条 3 个特征"的输入形状(与实操三验证的单点输入[[50,44,-12]]完全一致);model.predict(final_features)返回预测标签数组,取prediction[0]作为国家码output;- 用
countries = ["Australia", "Canada", "Germany", "UK", "US"]这张按字母序硬编码的映射表把数字标签翻译回可读国家名——它与LabelEncoder的编码规则(au→0, ca→1, de→2, gb→3, us→4)严格对应,是"训练侧与消费侧必须共享数据契约"的典型体现; render_template("index.html", prediction_text=...)把结果文本注入模板并返回渲染后的页面。
源码级细节:对照仓库中的 solution/web-app/app.py,解题版本的模型路径写的是 "../ufo-model.pkl"(因为成品 ufo-model.pkl 放在 solution/ 根目录、而 app.py 在其子目录 web-app/ 中),而课程正文的代码写的是 "./ufo-model.pkl"——两者差别纯粹由文件摆放位置决定,说明模型路径是相对 app.py 运行时工作目录解析的,部署时务必保证该相对路径能命中文件。
关于 debug=True:课程给出的提示是——开启 debug 模式后,对应用的改动会即时热重载,无需手动重启服务器;但切勿在生产环境开启该模式(会暴露交互式调试器,存在严重安全风险)。
课程延伸:挑战题、复习与作业
挑战题:在 Flask 应用内部训练模型
课程提出的进阶挑战是:跳过"Notebook 训练 → pickle 导入"的路线,把清洗后数据的训练代码直接搬进 Flask 应用,新增一条名为 train 的路由来触发训练。课程要求学生自行分析这条路线的利弊(例如:省去了模型文件的版本管理与传输环节;但每次冷启动都要重训、训练逻辑与 Web 逻辑耦合、且生产环境不宜在请求处理中执行长耗时训练等)。
复习与自学
课程布置的自研任务:列举使用 JavaScript 或 Python 构建"消费 ML 模型的 Web 应用"的各种方式;思考架构问题——模型应该留在应用内还是住在云端?若住云端,应用如何访问它?最后,画出你所设想的 ML Web 方案的架构图。
作业:换一个模型重做这个 Web 应用
配套作业 assignment.md 的要求是:既然你已经用训练好的回归类模型做出一个 Web 应用,现在改用前面 Regression 课程中的某个模型重做这个应用;可以沿用原有样式,也可以按南瓜(pumpkin)数据主题重新设计,但必须同步修改前端输入以匹配新模型的训练特征。评分标准(rubric)为三档:优秀——应用按预期运行并部署上云;合格——应用有缺陷或出现意外结果;待改进——应用无法正常工作。
相关文件索引
| 文件 | 说明 |
|---|---|
| README.md | 本课英文原版(含全部实操代码) |
| translations/ar/3-Web-App/1-Web-App/README.md | 本课完整阿拉伯语翻译(本文所依据的文档) |
| notebook.ipynb | 供学员填写的空白练习笔记本 |
| solution/notebook.ipynb | 含真实运行输出的解题笔记本 |
| solution/ufo-model.pkl | 现成的 pickle 模型文件 |
| solution/web-app/app.py | 成品 Flask 应用入口 |
| solution/web-app/templates/index.html | 成品预测表单页面 |
| solution/web-app/static/css/styles.css | 成品样式表 |
| solution/web-app/requirements.txt | 依赖清单(scikit-learn、pandas、numpy、flask) |
| data/ufos.csv | 约 8 万条 NUFORC UFO 目击原始数据 |
| assignment.md | 课后作业与评分标准 |
小结
本课用一条极简但完整的技术链路回答了"训练好的 Scikit-learn 模型如何变成 Web 服务":数据清洗(pandas)→ LabelEncoder 数值化 → train_test_split 划分 → LogisticRegression 训练 → pickle 序列化 → Flask 双路由服务(/ 渲染表单、/predict 预测)→ Jinja2 模板回显结果。其中最容易被初学者忽视、却决定了应用能否跑通的两个"契约"是:模型输入形状(3 个特征的二维数组)与标签映射表(国家码 ↔ 国家名),两者必须由训练侧与消费侧严格一致——课程最后一句"在生产环境中,训练模型的人与消费模型的人之间需要良好沟通"正是对这条契约的总结。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
