Django+ARIMA:构建大气污染时间序列预测与Web服务系统

Django+ARIMA:构建大气污染时间序列预测与Web服务系统 简介一份面向高校计算机专业毕业设计场景的大气污染预测软件设计与实现报告基于Python与Django框架开发以时间序列分析为建模核心覆盖B/S架构、MySQL数据库、HTML前端以及ARCH模型等技术内容。资源为1份docx格式完整文档整体约1.55MB系统记录开发背景、目的意义、相关技术介绍、可行性分析、需求分析、总体设计、数据库设计、功能实现以及系统测试等完整流程可直接作为论文写作框架与项目设计参考。文档展示首页展示、用户登录注册、大气污染预测、预测分析、预测结果管理、个人信息查看和用户管理等典型模块的落地思路并提供测试目的、测试用例与测试总结等实践内容。核心算法围绕时间序列预测模型展开结合数据库与前端页面完成业务闭环。已有187人学习适合正在开展相关毕业设计或课程项目的Python学习者、Web开发初学者以及对时间序列环境预测感兴趣的读者借鉴。1. 大气污染预测这件事真正的门槛不在“预测”而在“落地”如果你把这个标题拆开看会发现它其实是在回答一个问题怎么把一套时间序列预测算法变成一个真实可操作、能看结果、能交付给用户使用的Web软件。这里面的难点往往被人误判很多人以为核心是“调一个更准的ARIMA模型”但当你真正动手做这个题目时最先卡住你的是数据本身。空气质量监测数据来自不同站点、不同时间粒度小时级和日级数据混在一起传感器漂移、网络断传导致的时间戳错位、节假日前后污染浓度突变……这些才是真实场景里的“脏”。而另一层隐蔽的难度在于Django作为MTV框架它对“同步阻塞型”的预测任务并不友好——模型推理三五秒还好但如果前端要下载一份历史数据报表或者按站点批量预测同步请求就会把worker线程拖垮。所以这个标题背后其实是一套完整的技术决策数据清洗用什么策略、时序模型怎么选型与验证、预测逻辑在Django里该放在哪一层、长任务怎么异步化、部署用什么方案。这篇文章就沿着这条链路往下走每一节都给能直接复制的代码和参数。适合正在做课设、毕设或者要把一个“模型demo”变成“可交付Web系统”的同学。2. 数据是第一道关卡污染时序数据的清洗与对齐策略2.1 两套数据源的对齐思路监测站数据与气象数据大气污染预测不能只靠污染物浓度历史值。PM2.5的扩散受风速、湿度、温度、边界层高度影响所以常规做法是把环境监测站点的污染物浓度数据和气象站的逐小时气象数据做时间戳对齐再合并成一张宽表。常见的做法是拿到两类CSV污染物数据station_code, time, pm25, pm10, no2, so2, o3, co气象数据time, temperature, humidity, wind_speed, wind_direction, pressure这两份数据的时间粒度可能不一致比如污染数据是小时级气象数据是每10分钟一条。对齐的第一步是统一时间基准以污染物数据的整点时间戳为主键把气象数据按小时求均值后“对”上去。import pandas as pd df_poll pd.read_csv(pollution_hourly.csv, parse_dates[time]) df_met pd.read_csv(meteo_hourly.csv, parse_dates[time]) # 以整点小时为重采样基准去掉分钟信息 df_poll[hour] df_poll[time].dt.floor(h) df_met[hour] df_met[time].dt.floor(h) # 气象数据按小时聚合风速取均值、风向取圆形均值这里简化处理 df_met_hour df_met.groupby(hour).agg({ temperature: mean, humidity: mean, wind_speed: mean, pressure: mean }).reset_index() df pd.merge(df_poll, df_met_hour, left_onhour, right_onhour, howleft)这里有一个容易忽略的问题风向是角度量直接算算术均值会把0度和359度平均成179.5度这在风向上是严重错误的。实际项目中要么把风向拆成u/v分量u wind_speed * cos(theta)v wind_speed * sin(theta)再做均值要么干脆不做因子化处理。我在做这类项目时一般只保留风速因子因为污染物扩散模型对风速的敏感度远高于风向这也是减少特征噪声的一种方式。2.2 时间戳缺失与时间戳重复的判别逻辑合并之后最常见的问题是时间索引不连续。某站点在凌晨2点到4点之间断传数据表里就直接少行。pd.merge默认的howleft会给缺失行填充NaN但缺失行根本没有出现在表里这种情况下只能重新生成完整的时间范围再外连接。full_hours pd.date_range(startdf[hour].min(), enddf[hour].max(), freq1h) df_full df.set_index(hour).reindex(full_hours).reset_index() df_full.rename(columns{index: hour}, inplaceTrue)reindex会为缺失时段自动生成NaN行。处理完缺失行后还要处理时间戳重复一个hour出现了两行通常是数据上报机制重复导致的。用drop_duplicates按照“站点小时”去重保留最后一条记录即可。2.3 用插值与滚动窗口修补缺失值对于单点缺失前后数据都存在线性插值是最稳妥的选择对于连续3小时以上的长时间缺失线性插值会明显失真这时建议用“同期均值替代法”——用前7天同一时刻的均值填充。# 单点缺失线性插值限制最大连续插值跨度5小时 df_full[pm25] df_full[pm25].interpolate( methodlinear, limit5, limit_areainside ) # 长时间缺失用前7天同小时均值填充 df_full[pm25] df_full[pm25].fillna( df_full[pm25].shift(24 * 7).rolling(7 * 24, min_periods1).mean() )参数说明limit5表示最多连续填充5个缺失值超过这个跨度就放弃避免“一根直线拉平”的情况。limit_areainside保证不填充序列两端的数据两端缺失不做推断。前半部分如果缺失太多插值和回填都不够可靠更合理的做法是直接截断这一段只保留质量达标的时间窗口进入模型训练。2.4 异常值的识别3σ法则与IQR法污染物传感器受雾霾、扬尘等物理干扰会出现瞬时尖峰。识别异常值不建议直接用3σ法则因为污染浓度本身高度右偏正太假设不成立。更稳妥的做法是对数变换后做IQR检测。import numpy as np log_pm25 np.log1p(df_full[pm25]) q1, q3 log_pm25.quantile(0.25), log_pm25.quantile(0.75) iqr q3 - q1 lower_bound q1 - 1.5 * iqr upper_bound q3 1.5 * iqr df_full.loc[log_pm25 lower_bound, pm25] np.nan df_full.loc[log_pm25 upper_bound, pm25] np.nan df_full[pm25] df_full[pm25].interpolate(methodlinear)这里np.log1p的作用是压缩右尾。对于PM10这类受沙尘暴影响的指标IQR方法的上下界会更激进可以把1.5放宽到2.2否则会把真实重污染过程误删。判断异常值和重污染事件的区别需要结合风速、湿度等气象因子综合看——湿度80%且PM2.5浓度持续高位大概率是二次生成污染而非传感器故障。数据清洗做完下一步才是建模。但大多数起步者的误区是把90%的精力花在调参上实际上一份干净、连续、具备特征对齐的数据对预测结果的改善往往比换模型更明显。3. 预测模型选型从ARIMA到对比基线怎么定参数才算数3.1 为什么ARIMA是默认首选项而不是LSTM时间序列分析在这个项目标题里指向的最正统的方法就是ARIMA差分自回归移动平均模型。原因有三第一PM2.5小时浓度序列是典型的非平稳序列ARIMA通过差分可以处理第二ARIMA在小样本几百到几千条上表现稳定不需要GPU和大量训练数据第三在Django里部署一个statsmodels模型远轻于部署TensorFlow/PyTorch推理环境。LSTM在长序列捕捉非线性上有优势但污染数据的非线性很大程度上来自气象因子变化这部分通过外生回归变量可以部分吸收。而LSTM需要至少上万条样本才能发挥效果且超参数随便一调线上预测就飘。常见做法是先把ARIMA/SARIMA当主线做LSTM作为对照而不是反过来。3.2 平稳性检验与差分阶数d的确定ARIMA建模前必须做ADF检验。statsmodels里一条命令就能搞定from statsmodels.tsa.stattools import adfuller result adfuller(df_full[pm25].dropna()) print(fADF统计量: {result[0]:.4f}, p值: {result[1]:.4f}) # p 0.05 则序列非平稳做一阶差分后再次检验 if result[1] 0.05: df_full[pm25_diff] df_full[pm25].diff() result2 adfuller(df_full[pm25_diff].dropna()) print(f一阶差分后p值: {result2[1]:.4f})ADF检验的零假设是“序列存在单位根即非平稳”。p值小于0.05就拒绝原假设认为序列平稳。绝大多数污染物浓度序列一阶差分后就能通过检验如果一阶差分后仍不平稳再看二阶差分但此时要警惕过度差分否则会损失序列中的长期记忆信息。3.3 定阶的两种路径ACF/PACF图法与信息准则自动寻优有了差分阶数d剩下的是确定p自回归阶数和q移动平均阶数。人工看图定阶的规则是ACF图拖尾、PACF图在k阶后截尾则p取截尾阶数反过来则q取ACF的截尾阶数。但实际操作里污染序列的ACF和PACF常常都不是“干脆地截尾”这时候直接靠信息准则定阶更科学。from statsmodels.tsa.arima.model import ARIMA import warnings warnings.filterwarnings(ignore) best_aic float(inf) best_order None for p in range(0, 6): for q in range(0, 6): order (p, 1, q) try: model ARIMA(df_full[pm25].dropna(), orderorder).fit() if model.aic best_aic: best_aic model.aic best_order order except Exception: continue print(f最优ARIMA阶数: {best_order}, AIC: {best_aic:.2f})要说明的是AIC的适用场景AIC适合预测目的的模型比较BIC对参数数量惩罚更重、倾向于更简洁的模型。做预测优先看AIC做解释性分析更看重BIC。3.4 加入外生变量的SARIMAX把气象特征接进去纯ARIMA只利用污染物自身的历史值但气象条件对污染浓度有显著影响——大风天PM2.5浓度会被快速清除静稳天气下污染物持续累积。把气象因子放进模型的做法是使用SARIMAX带外生变量的季节性ARIMA。from statsmodels.tsa.statespace.sarimax import SARIMAX train_df df_full.dropna(subset[pm25]).iloc[:-24] # 前N-24小时训练 test_df df_full.dropna(subset[pm25]).iloc[-24:] # 最后24小时测试 exog_cols [temperature, humidity, wind_speed, pressure] model SARIMAX( train_df[pm25], exogtrain_df[exog_cols], order(2, 1, 2), seasonal_order(1, 1, 1, 24) ) result model.fit(dispFalse) print(result.summary())这里的seasonal_order(1, 1, 1, 24)值得展开说第四个数24表示周期长度对应小时级数据“一天一个周期”的天然节律。污染浓度有明显的日内双峰趋势早晚高峰扩散条件差如果不加季节项模型会把这种日内规律当作随机波动。预测阶段需要提供未来时段的外生变量forecast result.get_forecast(steps24, exogtest_df[exog_cols]) pred_mean forecast.predicted_mean conf_int forecast.conf_int()这里存在一个现实约束预测未来24小时必须知道未来24小时的温度、湿度和风速。常规做法是取气象预报数据比如调用气象平台的预报API或者用过去3年同时刻的平均值近似。实际项目中后者误差很大但架不住它简单稳定用于系统初始版本完全足够。3.5 基线模型它存在的价值不是被超越而是当裁判任何预测项目都该做一个“无脑但稳定”的基线模型。最简单有效的基线是“同期均值法”预测值等于过去7天同一小时的平均浓度。另一个经典基线是持久化模型persistence预测值等于当前时刻的浓度值。# 7日同期均值基线 baseline test_df[pm25].copy() for idx in range(len(test_df)): t test_df.index[idx] baseline.iloc[idx] df_full.loc[ (df_full.index t) (df_full.index t - pd.Timedelta(days7)), pm25 ].resample(1h).mean().loc[t.hour].mean() if False else \ df_full[pm25].shift(24).rolling(7, min_periods1).mean().loc[t] # 与SARIMA预测结果对比 from sklearn.metrics import mean_absolute_error mae_sarimax mean_absolute_error(test_df[pm25], pred_mean) mae_baseline mean_absolute_error(test_df[pm25], baseline) print(fSARIMAX MAE: {mae_sarimax:.2f}, 基线MAE: {mae_baseline:.2f})如果SARIMAX的MAE比基线还差那么问题出在数据比如外生变量的未来值不准而不是模型本身。这个判断方向很重要因为调试时有一个清晰的参照系不会陷入“调参玄学”。4. Django的MTV如何承接预测模型、视图与实时预测接口4.1 Django项目结构怎么组织才不让模型代码变成“一坨糊”很多人拿到训练好的模型第一反应是写一个视图函数在里面对模型做pickle.load然后预测。这在demo阶段没问题但很快你会遇到两个问题每个请求都重复加载模型耗时上百毫秒训练代码、数据处理、预测逻辑全部混在views.py里后续改一个特征都要翻半天文件。我一般会这样拆pollution_project/ ├── manage.py ├── config/ # Django项目配置 │ ├── settings.py │ └── urls.py ├── forecast/ │ ├── models.py # Django ORM模型数据库表 │ ├── views.py # HTTP接口层 │ ├── ml/ │ │ ├── model_loader.py # 模型加载用lru_cache做缓存 │ │ ├── prediction.py # 预测逻辑封装 │ │ └── features.py # 特征构造与对齐 │ └── utils/ │ └── data_access.py # 数据库查询与清洗模型的持久化文件放在forecast/ml/checkpoints/目录下训练脚本单独放training/目录和Django服务代码隔离。这样按“训练时”和“服务时”拆分训练脚本只管产出pm25_arima.pkl和对应的特征元数据预测服务只负责加载和推理。4.2 用Django ORM设计两张核心数据表# forecast/models.py from django.db import models class Station(models.Model): code models.CharField(max_length32, uniqueTrue, verbose_name站点编码) name models.CharField(max_length64, verbose_name站点名称) longitude models.FloatField() latitude models.FloatField() class PollutionRecord(models.Model): station models.ForeignKey(Station, on_deletemodels.CASCADE, related_namerecords) timestamp models.DateTimeField(db_indexTrue) pm25 models.FloatField(nullTrue) pm10 models.FloatField(nullTrue) no2 models.FloatField(nullTrue) temperature models.FloatField(nullTrue) humidity models.FloatField(nullTrue) wind_speed models.FloatField(nullTrue) class Meta: unique_together (station, timestamp) ordering [-timestamp]代码逻辑说明unique_together保证了“一个站点、一个时刻”只有一条记录这从数据库约束层面把重复时间戳问题挡在源头。db_indexTrue很重要。后续按站点时间范围查询是最高频的操作不加索引的话数据量到几十万行时查询速度会明显下降。外键用related_namerecords给关联查询命名用station.records.filter(timestamp__gte...)这种方式访问语义清晰。Django执行查询时的常见写法是from django.db.models import Avg from .models import PollutionRecord def get_daily_avg(station_code, start_date, end_date): records (PollutionRecord.objects .filter(station__codestation_code, timestamp__date__range(start_date, end_date)) .values(timestamp__date) .annotate(avg_pm25Avg(pm25))) return list(records)这里用了values()annotate()的组合在SQL层面转成GROUP BY date比把所有行拉回内存再在Python里分组要高效得多。4.3 模型加载与预测接口避免每次请求都重新load模型文件是pickle格式的statsmodels对象加载一次需要几百毫秒到一两秒。更好的方式是用functools.lru_cache把模型对象缓存起来# forecast/ml/model_loader.py import pickle from functools import lru_cache from django.conf import settings MODEL_PATH settings.BASE_DIR / forecast / ml / checkpoints / pm25_sarimax.pkl lru_cache(maxsize2) def load_model(model_namepm25_sarimax): with open(MODEL_PATH, rb) as f: return pickle.load(f)注意lru_cache会缓存函数返回值但模型对象本身可能包含fit时的超大数据所以在训练端就应该在save之前精简模型只保留必要的参数和内生数据。statsmodels对象可以用.save()方法保存同样支持load。4.4 预测视图的完整实现同步请求的代价与应对预测接口的基本实现如下# forecast/views.py import json from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from .ml.model_loader import load_model from .ml.prediction import build_exog_from_db csrf_exempt def forecast_pm25(request, station_code): if request.method ! POST: return JsonResponse({code: 400, message: 仅支持POST请求}) body json.loads(request.body) hours int(body.get(hours, 24)) # 预测未来hours小时默认24 model load_model(pm25_sarimax) exog build_exog_from_db(station_code, hours) result model.get_forecast(stepshours, exogexog) forecast result.predicted_mean.tolist() conf_low result.conf_int()[lower pm25].tolist() conf_high result.conf_int()[upper pm25].tolist() return JsonResponse({ code: 0, data: { station_code: station_code, forecast: forecast, conf_low: conf_low, conf_high: conf_high } })逻辑说明csrf_exempt是因为这个接口大概率被前端JavaScript或第三方系统调用没有CSRF token传过来。如果只给Django模板渲染的页面用这个装饰器可以去掉。外生变量build_exog_from_db从数据库查询未来时段的气象数据。如果数据库里没有未来数据就用历史同期均值构造。预测结果直接以JSON返回前端拿到后走ECharts绘制预测曲线和置信区间带。这段同步代码在并发量不高比如内部系统或毕设演示时完全够用但当多个用户同时请求不同站点的预测时Django开发服务器的单线程模型会阻塞。下一节讲怎么把预测长任务异步化顺便把数据导出这个同样阻塞的功能解决掉。5. 预测任务的异步化与可视化查询优化5.1 问题重现为什么同步预测在真实环境里会“卡死”Django默认的同步视图里一次耗时3秒的预测会占住整个worker线程。如果部署用的是开发服务器runserver那更糟糕——它是单进程多线程模型同时只能处理少许并发请求。此时另一个用户请求页面历史数据曲线也得排队等预测跑完。解决方案是把“模型推理”拆成两步请求进来只创建任务返回一个task_id。后台线程执行实际预测执行完把结果写到数据库或缓存。前端拿着task_id轮询结果接口。5.2 用后台线程任务表完成异步预测先在models.py里加一张预测任务表class ForecastTask(models.Model): task_id models.CharField(max_length64, uniqueTrue) station models.ForeignKey(Station, on_deletemodels.CASCADE) status models.CharField(max_length16, defaultPENDING) result models.JSONField(nullTrue) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue)视图层分成两个接口import uuid import threading from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from .models import ForecastTask from .ml.prediction import run_forecast csrf_exempt def create_forecast_task(request, station_code): task ForecastTask.objects.create( task_idstr(uuid.uuid4()), station_idstation_code ) # 后台线程执行真实预测 t threading.Thread(targetrun_forecast, args(task.task_id,)) t.daemon True t.start() return JsonResponse({code: 0, task_id: task.task_id}) def get_forecast_result(request, task_id): task ForecastTask.objects.filter(task_idtask_id).first() if not task: return JsonResponse({code: 404, message: 任务不存在}) return JsonResponse({ code: 0, status: task.status, result: task.result })run_forecast函数在执行完成后把结果写进task.result并把status改成SUCCESS。这里要注意两点线程里操作Django ORM必须在函数内部重新导入模型类因为线程环境和主线程的数据库连接不共享。daemon线程意味着主进程退出时线程直接杀掉生产环境不要依赖这种方式更稳妥的做法是上Celery或RQ。对于单机小项目来说用线程已经足够但代码里要写清楚这个上限。5.3 预测结果的长轮询与前端刷新逻辑前端用setInterval每2秒请求一次结果接口直到statusSUCCESSfunction pollForecast(taskId) { const interval setInterval(async () { const resp await fetch(/api/forecast/result/${taskId}); const data await resp.json(); if (data.status SUCCESS) { clearInterval(interval); renderChart(data.result); } else if (data.status FAILED) { clearInterval(interval); alert(预测失败请检查模型日志); } }, 2000); }设置2秒轮询的考量模型预测耗时通常在几百毫秒到几秒加上任务排队时间2秒的查询密度足够及时也不会对服务器造成太大压力。如果任务量再大改成WebSocket推送是后续方向但那是把简单问题复杂化先不展开。5.4 用StreamingHttpResponse做历史数据报表导出很多用户希望一次性导出某个站点近三个月的逐小时数据。直接构建CSV字符串返回HttpResponse的问题是大数据量时三个月大约2160行加上每条记录里的多个字段响应体可能达到几十MB全部在内存里拼好再返回不仅占内存而且响应时间很长。替代方案是用StreamingHttpResponse它的特点是可以把生成器函数产出的内容逐块发给前端配合Content-Disposition让浏览器把响应保存为文件下载。import csv from django.http import StreamingHttpResponse from .models import PollutionRecord def export_station_csv(request, station_code): records (PollutionRecord.objects .filter(station__codestation_code, timestamp__gte2025-01-01, timestamp__lte2025-03-31) .values(timestamp, pm25, pm10, no2)) def iter_rows(): yield [timestamp, pm25, pm10, no2] for row in records.iterator(): yield [row[timestamp].isoformat(), row[pm25], row[pm10], row[no2]] response StreamingHttpResponse( (,.join(str(v) for v in line) \n for line in iter_rows()), content_typetext/csv; charsetutf-8 ) response[Content-Disposition] \ fattachment; filenamestation_{station_code}_history.csv return response参数说明content_type要带上charsetutf-8否则Excel打开中文列名时会乱码。Content-Disposition里的attachment告诉浏览器这是下载文件而非内联展示filename就是下载后默认保存的文件名。.iterator()会把QuerySet变成迭代器逐条从数据库取数而不是一次性全部加载到内存。这个写法对于大数据量导出几乎是免费的性能提升。当数据量超过几十万行时还应该配合iterator(chunk_size1000)控制每次取多少条避免长时间占用数据库连接。6. 预测效果的滚动验证与waitressnginx本地部署6.1 滚动时间序列验证比随机切分更诚实的评测方法普通的交叉验证不适合时间序列——它会用未来的数据训练模型再预测过去的事出现数据泄漏。正确做法是滚动预测验证不断把训练窗口向前推进每次只预测下一个时间点。from statsmodels.tsa.statespace.sarimax import SARIMAX import numpy as np def rolling_validate(series, exog, n_splits7, horizon24): split_ratio 0.8 n_train int(len(series) * split_ratio) preds [] actuals [] for i in range(n_splits): start n_train i * horizon end start horizon train_end start model SARIMAX( series.iloc[:train_end], exogexog.iloc[:train_end], order(2, 1, 2), seasonal_order(1, 1, 1, 24), ).fit(dispFalse) y_pred model.get_forecast(stepshorizon, exogexog.iloc[start:end]) preds.extend(y_pred.predicted_mean.tolist()) actuals.extend(series.iloc[start:end].tolist()) return np.array(actuals), np.array(preds)每次循环都重新fit一次模型虽然耗时但反映的是真实部署场景模型上线后要用最新数据持续重训。这里评估指标重点看两个第一个预测点精度前1小时实际场景中用户最关心的是当前时刻往后几小时的浓度变化。24小时内的平均绝对误差可用MAE或RMSERMSE对“高浓度预测低了”的惩罚更重对重污染天更敏感。6.2 用waitress跑生产级服务并挂到nginx后面Django自带的runserver明确是开发用途不能用于生产。在Windows本机或Linux服务器上的轻量部署方案推荐waitressWindows原生支持替代gunicorn在Windows上跑不起来的问题。安装并启动pip install waitress waitress-serve --listen127.0.0.1:8000 config.wsgi:application参数含义--listen127.0.0.1:8000让waitress只监听本机端口外部请求统一交给nginx转发避免直接暴露Django服务。config.wsgi:application指向项目的WSGI入口。nginx配置一个简单的反向代理server { listen 80; server_name your-domain.com; location /static/ { alias /path/to/pollution_project/staticfiles/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; } }X-Forwarded-For和X-Real-IP这两个头很重要。Django里如果配了django.middleware.csrf.CsrfViewMiddleware或需要获取用户IP做日志分析没有这两个头拿到的全是127.0.0.1。同步部署时还要记得在settings.py里关掉DEBUGFalse并设置好ALLOWED_HOSTS然后执行python manage.py collectstatic这一步把静态文件汇总到staticfiles/目录nginx才能直接托管不用走到Django进程里。6.3 上线前必查的五个坑第一模型文件路径不能用相对路径。waitress的工作目录可能和你manage.py所在的目录不一致推荐在settings.py里用BASE_DIR / forecast / ml / checkpoints这种绝对路径写法。第二statsmodels版本对齐。训练环境和服务环境的statsmodels版本必须一致否则pickle加载模型时会出现ValueError: Unknown parameter name之类的错误。建议在项目里固定statsmodels0.14.x这个级别。第三时区问题。数据库里存的timestamp如果带时区而模型训练时用的是本地时间预测结果会整体偏移一小时。统一在Django的settings.py里设置TIME_ZONE Asia/Shanghai且USE_TZ True并且在查询和写库时都用django.utils.timezone.now()避免datetime.now()混用。第四外生变量缺失时的兜底。预测接口收到查询时如果发现湿度、风速这些字段为空源头断更模型会直接报错。用上一节提到的同期均值兜底填充并把“该时段使用均值替代”的标识一起写进响应体让前端能展示“预测置信度较低”的提示。第五启动顺序。先起waitress再重载nginx。如果在nginx已经启动的情况下改了Django代码只需要重启waitress进程改了nginx配置才需要nginx -s reload两者别搞混。验证部署是否成功的最终方式启动后用curl请求预测接口观察响应码和耗时。curl -X POST http://127.0.0.1:8000/api/forecast/1101A \ -H Content-Type: application/json \ -d {hours: 24}第一次请求如果耗时超过3秒大概率是模型冷启动加载第二次请求应当明显变快。如果连续两次都慢检查模型是否真的被lru_cache缓存住以及数据库查询有没有走索引。用EXPLAIN看一遍PollutionRecord表的查询计划确认WHERE station_id? AND timestamp BETWEEN ? AND ?这条过滤链路命中了联合索引做查询优化时这是最快的入手点。本文还有配套的精品资源点击获取