Compare commits

...

3 Commits

Author SHA1 Message Date
18f505a090 # 修改记录日志 (日期: 2025-07-16) ---未改全
## 1. 训练流程与模型保存逻辑修复 (重大)

- **背景**: 用户报告在“按店铺”和“按药品”模式下,如果选择了特定的子集(如为某个店铺选择特定药品),生成的模型范围 (`scope`) 不正确,始终为 `_all`。此外,所有模型都被错误地保存到 `global` 目录下,且在某些模式下训练会失败。
- **根本原因**:
    1.  `server/core/predictor.py` 中负责准备训练参数的内部函数 (`_prepare_product_params`, `_prepare_store_params`) 逻辑有误,未能正确处理传入的 `product_ids` 和 `store_ids` 列表来构建详细的 `scope`。
    2.  各个训练器 (`server/trainers/*.py`) 内部的日志记录和元数据生成逻辑不统一,且过于依赖 `product_id`,导致在全局或店铺模式下信息展示不清晰。

- **修复方案**:
    - **`server/core/predictor.py`**:
        - **重构 `_prepare_product_params` 和 `_prepare_store_params`**: 修改了这两个函数,使其能够正确使用 `product_ids` 和 `store_ids` 列表。现在,当选择特定范围时,会生成更具描述性的 `scope`,例如 `S001_specific_P001_P002`。
        - **结果**: 确保了传递给模型管理器的 `scope` 是准确且详细的,从而使模型能够根据训练范围被保存到正确的、独立的文件夹中。

    - **`server/trainers/*.py` (mlstm, kan, tcn, transformer)**:
        - **标准化日志与元数据**: 对所有四个训练器文件进行了统一修改。引入了一个通用的 `training_description` 变量,该变量整合了 `training_mode`、`scope` 和 `aggregation_method`。
        - **更新输出**: 修改了所有训练器中的日志消息、图表标题和 `metadata.json` 的生成逻辑,使其全部使用这个标准的 `training_description`。
        - **结果**: 确保了无论在哪种训练模式下,前端收到的日志、保存的图表和元数据都具有一致、清晰的格式,便于调试和结果追溯。

- **总体影响**: 此次修复从根本上解决了模型训练范围处理和模型保存路径的错误问题,使整个训练系统在所有模式下都能可靠、一致地运行。

---

## 2. 核心 Bug 修复

### 文件: `server/core/predictor.py`

- **问题**: 在 `train_model` 方法中调用内部辅助函数 `_prepare_training_params` 时,没有正确传递 `product_ids` 和 `store_ids` 参数,导致在 `_prepare_training_params` 内部发生 `NameError`。
- **修复**:
    - 修正了 `train_model` 方法内部对 `_prepare_training_params` 的调用,确保 `product_ids` 和 `store_ids` 被显式传递。
    - 此前已修复 `train_model` 的函数签名,使其能正确接收 `store_ids`。
- **结果**: 彻底解决了训练流程中的参数传递问题,根除了由此引发的 `NameError`。

## 3. 代码清理与重构

### 文件: `server/api.py`

- **内容**: 移除了在 `start_training` API 端点中遗留的旧版、基于线程(`threading.Thread`)的训练逻辑。
- **原因**: 该代码块已被新的、基于多进程(`multiprocessing`)的 `TrainingProcessManager` 完全取代。旧代码中包含了大量用于调试的 `thread_safe_print` 日志,已无用处。
- **结果**: `start_training` 端点的逻辑变得更加清晰,只负责参数校验和向 `TrainingProcessManager` 提交任务。

### 文件: `server/utils/training_process_manager.py`

- **内容**: 在 `TrainingWorker` 的 `run_training_task` 方法中,移除了一个用于模拟训练进度的 `for` 循环。
- **原因**: 该循环包含 `time.sleep(1)`,仅用于在没有实际训练逻辑时模拟进度更新,现在实际的训练器会通过回调函数报告真实进度,因此该模拟代码不再需要。
- **结果**: `TrainingWorker` 现在直接调用实际的训练器,不再有模拟延迟,代码更贴近生产环境。
2025-07-16 16:51:38 +08:00
a9a0e51769 # 修改记录日志 (日期: 2025-07-16)
## 1. 核心 Bug 修复

### 文件: `server/core/predictor.py`

- **问题**: 在 `train_model` 方法中调用内部辅助函数 `_prepare_training_params` 时,没有正确传递 `product_ids` 和 `store_ids` 参数,导致在 `_prepare_training_params` 内部发生 `NameError`。
- **修复**:
    - 修正了 `train_model` 方法内部对 `_prepare_training_params` 的调用,确保 `product_ids` 和 `store_ids` 被显式传递。
    - 此前已修复 `train_model` 的函数签名,使其能正确接收 `store_ids`。
- **结果**: 彻底解决了训练流程中的参数传递问题,根除了由此引发的 `NameError`。

## 2. 代码清理与重构

### 文件: `server/api.py`

- **内容**: 移除了在 `start_training` API 端点中遗留的旧版、基于线程(`threading.Thread`)的训练逻辑。
- **原因**: 该代码块已被新的、基于多进程(`multiprocessing`)的 `TrainingProcessManager` 完全取代。旧代码中包含了大量用于调试的 `thread_safe_print` 日志,已无用处。
- **结果**: `start_training` 端点的逻辑变得更加清晰,只负责参数校验和向 `TrainingProcessManager` 提交任务。

### 文件: `server/utils/training_process_manager.py`

- **内容**: 在 `TrainingWorker` 的 `run_training_task` 方法中,移除了一个用于模拟训练进度的 `for` 循环。
- **原因**: 该循环包含 `time.sleep(1)`,仅用于在没有实际训练逻辑时模拟进度更新,现在实际的训练器会通过回调函数报告真实进度,因此该模拟代码不再需要。
- **结果**: `TrainingWorker` 现在直接调用实际的训练器,不再有模拟延迟,代码更贴近生产环境。

## 3. 启动依赖

- **Python**: 3.x
- **主要库**:
    - Flask
    - Flask-SocketIO
    - Flasgger
    - pandas
    - numpy
    - torch
    - scikit-learn
    - matplotlib
- **启动命令**: `python server/api.py`
2025-07-16 15:34:57 +08:00
e999ed4af2 ### 2025-07-15 (续): 训练器与核心调用层重构
**核心目标**: 将新的 `ModelManager` 统一应用到项目中所有剩余的模型训练器,并重构核心调用逻辑,确保整个训练链路的架构一致性。

**1. 修改 `server/trainers/kan_trainer.py`**
*   **内容**: 完全重写了 `kan_trainer.py`。
    *   **适配接口**: 函数签名与 `mlstm_trainer` 对齐,增加了 `socketio`, `task_id`, `patience` 等参数。
    *   **集成 `ModelManager`**: 移除了所有旧的、手动的保存逻辑,改为在训练开始时调用 `model_manager` 获取版本号和路径。
    *   **标准化产物保存**: 所有产物(模型、元数据、检查点、损失曲线)均通过 `model_manager.save_model_artifact()` 保存。
    *   **增加健壮性**: 引入了早停(Early Stopping)和保存最佳检查点(Best Checkpoint)的逻辑。

**2. 修改 `server/trainers/tcn_trainer.py`**
*   **内容**: 完全重写了 `tcn_trainer.py`,应用了与 `kan_trainer` 完全相同的重构模式。
    *   移除了旧的 `save_checkpoint` 辅助函数和基于 `core.config` 的版本管理。
    *   全面转向使用 `model_manager` 进行版本控制和文件保存。
    *   统一了函数签名和进度反馈逻辑。

**3. 修改 `server/trainers/transformer_trainer.py`**
*   **内容**: 完全重写了 `transformer_trainer.py`,完成了对所有训练器的统一重构。
    *   移除了所有遗留的、基于文件名的路径拼接和保存逻辑。
    *   实现了与其它训练器一致的、基于 `ModelManager` 的标准化训练流程。

**4. 修改 `server/core/predictor.py`**
*   **内容**: 对核心预测器类 `PharmacyPredictor` 进行了彻底重构。
    *   **统一调用接口**: `train_model` 方法现在以完全一致的方式调用所有(`mlstm`, `kan`, `tcn`, `transformer`)训练器。
    *   **移除旧逻辑**: 删除了 `_parse_model_filename` 等所有基于文件名解析的旧方法。
    *   **适配 `ModelManager`**: `list_models` 和 `delete_model` 等方法现在直接调用 `model_manager` 的相应功能,不再自己实现逻辑。
    *   **简化 `predict`**: 预测方法现在直接接收标准化的模型版本路径 (`model_version_path`) 作为输入,逻辑更清晰。
2025-07-15 20:09:09 +08:00
15 changed files with 1569 additions and 3877 deletions

View File

@ -609,15 +609,17 @@ const startTraining = async () => {
epochs: form.epochs,
training_mode: 'global', //
training_scope: form.training_scope,
aggregation_method: form.aggregation_method
aggregation_method: form.aggregation_method,
store_ids: form.store_ids || [], //
product_ids: form.product_ids || [] //
};
if (form.store_ids.length > 0) {
payload.store_ids = form.store_ids;
// 使ID
if (payload.store_ids.length > 0) {
payload.store_id = payload.store_ids[0];
}
if (form.product_ids.length > 0) {
payload.product_ids = form.product_ids;
if (payload.product_ids.length > 0) {
payload.product_id = payload.product_ids[0];
}
if (form.training_type === "retrain") {

View File

@ -228,7 +228,11 @@
prop="version"
label="版本"
width="80"
/>
>
<template #default="{ row }">
{{ row.version || 'N/A' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">
@ -652,9 +656,17 @@ const getModelTypeName = (modelType) => {
};
const getProductScopeText = (task) => {
if (task.product_scope === 'all' || !task.product_ids) {
// 使 product_scope
if (task.product_scope && task.product_scope.startsWith('指定药品')) {
return task.product_scope;
}
//
if (task.product_scope === 'all' || !task.product_ids || task.product_ids.length === 0) {
return '所有药品';
}
if (task.product_ids.length === 1 && task.product_ids[0] !== 'all') {
return `指定药品 (${task.product_ids[0]})`;
}
return `${task.product_ids.length} 种药品`;
};

View File

@ -55,10 +55,9 @@ from analysis.metrics import evaluate_model, compare_models
# 导入配置和版本管理
from core.config import (
DEFAULT_MODEL_DIR, WEBSOCKET_NAMESPACE,
get_model_versions, get_latest_model_version, get_next_model_version,
get_model_file_path, save_model_version_info
DEFAULT_MODEL_DIR, WEBSOCKET_NAMESPACE
)
from utils.model_manager import model_manager
# 导入多店铺数据工具
from utils.multi_store_data_utils import (
@ -917,6 +916,14 @@ def get_all_training_tasks():
for task_id, task_info in all_tasks.items():
task_copy = task_info.copy()
task_copy['task_id'] = task_id
# 添加药品范围描述
product_id = task_copy.get('product_id')
if product_id and product_id not in ['all', 'unknown']:
task_copy['product_scope'] = f"指定药品 ({product_id})"
else:
task_copy['product_scope'] = "所有药品"
tasks_with_id.append(task_copy)
# 按开始时间降序排序,最新的任务在前面
@ -969,335 +976,89 @@ def get_all_training_tasks():
})
def start_training():
"""
启动模型训练
---
post:
...
启动模型训练 - 已重构
"""
def _prepare_training_args(data):
"""从请求数据中提取并验证训练参数"""
training_mode = data.get('training_mode', 'product')
model_type = data.get('model_type')
epochs = data.get('epochs', 50)
aggregation_method = data.get('aggregation_method', 'sum')
if not model_type:
return None, jsonify({'error': '缺少model_type参数'}), 400
valid_model_types = ['mlstm', 'kan', 'optimized_kan', 'transformer', 'tcn']
if model_type not in valid_model_types:
return None, jsonify({'error': '无效的模型类型'}), 400
args = {
'model_type': model_type,
'epochs': epochs,
'aggregation_method': aggregation_method,
'training_mode': training_mode,
'product_id': data.get('product_id'),
'store_id': data.get('store_id'),
'product_ids': data.get('product_ids') or [],
'store_ids': data.get('store_ids') or [],
'product_scope': data.get('product_scope', 'all'),
'store_scope': data.get('store_scope', 'all'),
'global_scope': data.get('global_scope', 'all'),
}
# 根据 training_mode 进行特定参数的校验
if training_mode == 'product' and not args['product_id']:
return None, jsonify({'error': "当 training_mode 为 'product' 时, 必须提供 product_id。"}), 400
if training_mode == 'store' and not args['store_id']:
return None, jsonify({'error': "当 training_mode 为 'store' 时, 必须提供 store_id。"}), 400
if training_mode == 'global':
global_scope = args['global_scope']
if global_scope == 'selected_stores' and not args['store_ids']:
return None, jsonify({'error': "当 global_scope 为 'selected_stores' 时, 必须提供 store_ids 列表。"}), 400
if global_scope == 'selected_products' and not args['product_ids']:
return None, jsonify({'error': "当 global_scope 为 'selected_products' 时, 必须提供 product_ids 列表。"}), 400
if global_scope == 'custom' and (not args['store_ids'] or not args['product_ids']):
return None, jsonify({'error': "当 global_scope 为 'custom' 时, 必须同时提供 store_ids 和 product_ids 列表。"}), 400
return args, None, None
data = request.get_json()
# 新增训练模式参数
training_mode = data.get('training_mode', 'product') # 'product', 'store', 'global'
# 通用参数
model_type = data.get('model_type')
epochs = data.get('epochs', 50)
# 根据训练模式获取不同的参数
product_id = data.get('product_id')
store_id = data.get('store_id')
# 新增的参数
product_ids = data.get('product_ids', [])
store_ids = data.get('store_ids', [])
product_scope = data.get('product_scope', 'all')
training_scope = data.get('training_scope', 'all_stores_all_products')
aggregation_method = data.get('aggregation_method', 'sum')
if not data:
return jsonify({'error': '无效的请求体需要JSON格式的数据'}), 400
if not model_type:
return jsonify({'error': '缺少model_type参数'}), 400
training_args, error_response, status_code = _prepare_training_args(data)
# 根据训练模式验证必需参数
if training_mode == 'product' and not product_id:
return jsonify({'error': '按药品训练模式需要product_id参数'}), 400
elif training_mode == 'store' and not store_id:
return jsonify({'error': '按店铺训练模式需要store_id参数'}), 400
elif training_mode == 'global':
# 全局模式不需要特定的product_id或store_id
pass
if error_response:
return error_response, status_code
# 检查模型类型是否有效
valid_model_types = ['mlstm', 'kan', 'optimized_kan', 'transformer', 'tcn']
if model_type not in valid_model_types:
return jsonify({'error': '无效的模型类型'}), 400
# 使用新的训练进程管理器提交任务
try:
task_id = training_manager.submit_task(
product_id=product_id or "unknown",
model_type=model_type,
training_mode=training_mode,
store_id=store_id,
epochs=epochs
)
task_id = training_manager.submit_task(**training_args)
logger.info(f"🚀 训练任务已提交到进程管理器: {task_id[:8]}")
# 为响应动态构建一个描述性的 scope 字符串
training_mode = training_args['training_mode']
response_scope = training_mode # 默认值
if training_mode == 'product':
response_scope = f"药品: {training_args.get('product_id')} | 店铺范围: {training_args.get('store_scope')}"
elif training_mode == 'store':
response_scope = f"店铺: {training_args.get('store_id')} | 药品范围: {training_args.get('product_scope')}"
elif training_mode == 'global':
response_scope = f"全局 | 范围: {training_args.get('global_scope')}"
return jsonify({
'message': '模型训练已开始(使用独立进程)',
'task_id': task_id,
'training_mode': training_mode,
'model_type': model_type,
'product_id': product_id,
'epochs': epochs
'training_scope': response_scope,
'model_type': training_args['model_type'],
'epochs': training_args['epochs']
})
except Exception as e:
logger.error(f"❌ 提交训练任务失败: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({'error': f'启动训练任务失败: {str(e)}'}), 500
# 旧的训练逻辑已被现代化进程管理器替代
global training_tasks
# 创建线程安全的日志输出函数
def thread_safe_print(message, prefix=""):
"""线程安全的打印函数,支持并发训练"""
import threading
import time
thread_id = threading.current_thread().ident
timestamp = time.strftime('%H:%M:%S')
formatted_msg = f"[{timestamp}][线程{thread_id}][{task_id[:8]}]{prefix} {message}"
# 简化输出,只使用一种方式避免重复
try:
print(formatted_msg, flush=True)
sys.stdout.flush()
except Exception as e:
try:
print(f"[输出错误] {message}", flush=True)
except:
pass
# 测试输出函数
thread_safe_print("🔥🔥🔥 训练任务线程启动", "[ENTRY]")
thread_safe_print(f"📋 参数: product_id={product_id}, model_type={model_type}, epochs={epochs}", "[PARAMS]")
try:
thread_safe_print("=" * 60, "[START]")
thread_safe_print("🚀 训练任务正式开始", "[START]")
thread_safe_print(f"🧵 线程ID: {threading.current_thread().ident}", "[START]")
thread_safe_print("=" * 60, "[START]")
logger.info(f"🚀 训练任务开始: {task_id}")
# 根据训练模式生成描述信息
if training_mode == 'product':
scope_msg = f"药品 {product_id}" + (f"(店铺 {store_id})" if store_id else "(全局数据)")
elif training_mode == 'store':
scope_msg = f"店铺 {store_id}"
if kwargs.get('product_scope') == 'specific':
scope_msg += f"({len(kwargs.get('product_ids', []))} 种药品)"
else:
scope_msg += "(所有药品)"
elif training_mode == 'global':
scope_msg = f"全局模型({kwargs.get('aggregation_method', 'sum')}聚合)"
if kwargs.get('training_scope') != 'all_stores_all_products':
scope_msg += f"(自定义范围)"
else:
scope_msg = "未知模式"
thread_safe_print(f"📋 任务详情: 训练 {model_type} 模型 - {scope_msg}", "[INFO]")
thread_safe_print(f"⚙️ 配置参数: 共 {epochs} 个轮次", "[CONFIG]")
logger.info(f"📋 任务详情: 训练 {model_type} 模型 - {scope_msg}, 轮次: {epochs}")
# 根据训练模式生成版本号和模型标识
if training_mode == 'product':
model_identifier = product_id
version = get_next_model_version(product_id, model_type) if version is None else version
elif training_mode == 'store':
model_identifier = f"store_{store_id}"
version = get_next_model_version(f"store_{store_id}", model_type) if version is None else version
elif training_mode == 'global':
model_identifier = "global"
version = get_next_model_version("global", model_type) if version is None else version
thread_safe_print(f"🏷️ 版本信息: 版本号 {version}, 模型标识: {model_identifier}", "[VERSION]")
logger.info(f"🏷️ 版本信息: 版本号 {version}, 模型标识: {model_identifier}")
# 初始化训练进度管理器
progress_manager.start_training(
training_id=task_id,
product_id=product_id,
model_type=model_type,
training_mode=training_mode,
total_epochs=epochs,
total_batches=0, # 将在实际训练器中设置
batch_size=32, # 默认值,将在实际训练器中更新
total_samples=0 # 将在实际训练器中设置
)
thread_safe_print("📊 进度管理器已初始化", "[PROGRESS]")
logger.info(f"📊 进度管理器已初始化 - 任务ID: {task_id}")
# 发送训练开始的WebSocket消息
if socketio:
socketio.emit('training_update', {
'task_id': task_id,
'status': 'starting',
'message': f'开始训练 {model_type} 模型版本 {version} - {scope_msg}',
'product_id': product_id,
'store_id': store_id,
'model_type': model_type,
'version': version,
'training_mode': training_mode,
'progress': 0
}, namespace=WEBSOCKET_NAMESPACE, room=task_id)
# 根据训练模式选择不同的训练逻辑
thread_safe_print(f"🏃 开始调用训练器 - 模式: {training_mode}, 模型: {model_type}", "[TRAINER]")
logger.info(f"🏃 开始调用训练器 - 模式: {training_mode}, 模型: {model_type}")
if training_mode == 'product':
# 按药品训练 - 使用现有逻辑
if model_type == 'optimized_kan':
thread_safe_print("🧠 调用优化KAN训练器", "[KAN]")
logger.info(f"🧠 调用优化KAN训练器 - 产品: {product_id}")
metrics = predictor.train_model(
product_id=product_id,
model_type='optimized_kan',
store_id=store_id,
training_mode='product',
epochs=epochs,
socketio=socketio,
task_id=task_id,
version=version
)
else:
thread_safe_print(f"🤖 调用 {model_type.upper()} 训练器 - 产品: {product_id}", "[CALL]")
logger.info(f"🤖 调用 {model_type.upper()} 训练器 - 产品: {product_id}")
metrics = predictor.train_model(
product_id=product_id,
model_type=model_type,
store_id=store_id,
training_mode='product',
epochs=epochs,
socketio=socketio,
task_id=task_id,
version=version
)
thread_safe_print(f"✅ 训练器返回结果: {type(metrics)}", "[RESULT]")
logger.info(f"✅ 训练器返回结果: {type(metrics)}")
elif training_mode == 'store':
# 按店铺训练 - 需要新的训练逻辑
metrics = train_store_model(
store_id=store_id,
model_type=model_type,
epochs=epochs,
product_scope=kwargs.get('product_scope', 'all'),
product_ids=kwargs.get('product_ids', [])
)
elif training_mode == 'global':
# 全局训练 - 需要新的训练逻辑
metrics = train_global_model(
model_type=model_type,
epochs=epochs,
training_scope=kwargs.get('training_scope', 'all_stores_all_products'),
aggregation_method=kwargs.get('aggregation_method', 'sum'),
store_ids=kwargs.get('store_ids', []),
product_ids=kwargs.get('product_ids', [])
)
thread_safe_print(f"📈 训练完成! 结果类型: {type(metrics)}", "[COMPLETE]")
if metrics:
thread_safe_print(f"📊 训练指标: {metrics}", "[METRICS]")
else:
thread_safe_print("⚠️ 训练指标为空", "[WARNING]")
logger.info(f"📈 训练完成 - 结果类型: {type(metrics)}, 内容: {metrics}")
# 更新模型路径使用版本管理
model_path = get_model_file_path(model_identifier, model_type, version)
thread_safe_print(f"💾 模型保存路径: {model_path}", "[SAVE]")
logger.info(f"💾 模型保存路径: {model_path}")
# 更新任务状态
training_tasks[task_id]['status'] = 'completed'
training_tasks[task_id]['metrics'] = metrics
training_tasks[task_id]['model_path'] = model_path
training_tasks[task_id]['version'] = version
print(f"✔️ 任务状态更新: 已完成, 版本: {version}", flush=True)
logger.info(f"✔️ 任务状态更新: 已完成, 版本: {version}, 任务ID: {task_id}")
# 保存模型版本信息到数据库
save_model_version_info(product_id, model_type, version, model_path, metrics)
# 完成训练进度管理器
progress_manager.finish_training(success=True)
# 发送训练完成的WebSocket消息
if socketio:
print(f"📡 发送WebSocket完成消息", flush=True)
logger.info(f"📡 发送WebSocket完成消息 - 任务ID: {task_id}")
socketio.emit('training_update', {
'task_id': task_id,
'status': 'completed',
'message': f'模型 {model_type} 版本 {version} 训练完成',
'product_id': product_id,
'model_type': model_type,
'version': version,
'progress': 100,
'metrics': metrics,
'model_path': model_path
}, namespace=WEBSOCKET_NAMESPACE, room=task_id)
print(f"SUCCESS 任务 {task_id}: 训练完成!评估指标: {metrics}", flush=True)
except Exception as e:
import traceback
print(f"ERROR 任务 {task_id}: 训练过程中发生异常!", flush=True)
traceback.print_exc()
error_msg = str(e)
print(f"FAILED 任务 {task_id}: 训练失败。错误: {error_msg}", flush=True)
training_tasks[task_id]['status'] = 'failed'
training_tasks[task_id]['error'] = error_msg
# 完成训练进度管理器(失败)
progress_manager.finish_training(success=False, error_message=error_msg)
# 发送训练失败的WebSocket消息
if socketio:
socketio.emit('training_update', {
'task_id': task_id,
'status': 'failed',
'message': f'模型 {model_type} 训练失败: {error_msg}',
'product_id': product_id,
'model_type': model_type,
'error': error_msg
}, namespace=WEBSOCKET_NAMESPACE, room=task_id)
# 构建训练任务参数
training_kwargs = {
'product_scope': product_scope,
'product_ids': product_ids,
'training_scope': training_scope,
'aggregation_method': aggregation_method,
'store_ids': store_ids
}
print(f"\n🚀🚀🚀 THREAD START: 准备启动训练线程 task_id={task_id} 🚀🚀🚀", flush=True)
print(f"📋 线程参数: training_mode={training_mode}, product_id={product_id}, model_type={model_type}", flush=True)
sys.stdout.flush()
thread = threading.Thread(
target=train_task,
args=(training_mode, product_id, store_id, epochs, model_type),
kwargs=training_kwargs
)
print(f"🧵 线程已创建,准备启动...", flush=True)
thread.start()
print(f"✅ 线程已启动!", flush=True)
sys.stdout.flush()
training_tasks[task_id] = {
'status': 'running',
'product_id': product_id,
'model_type': model_type,
'store_id': store_id,
'training_mode': training_mode,
'product_scope': product_scope,
'product_ids': product_ids,
'training_scope': training_scope,
'aggregation_method': aggregation_method,
'store_ids': store_ids,
'start_time': datetime.now().isoformat(),
'metrics': None,
'error': None,
'model_path': None
}
print(f"✅ API返回响应: 训练任务 {task_id} 已启动", flush=True)
return jsonify({'message': '模型训练已开始', 'task_id': task_id})
@app.route('/api/test-thread-output', methods=['POST'])
def test_thread_output():
@ -1531,23 +1292,30 @@ def predict():
# 如果指定了版本构造版本化的模型ID
model_id = f"{product_id}_{model_type}_{version}"
# 检查指定版本的模型是否存在
model_file_path = get_model_file_path(product_id, model_type, version)
if not os.path.exists(model_file_path):
return jsonify({"status": "error", "error": f"未找到产品 {product_id}{model_type} 类型模型版本 {version}"}), 404
# 新的预测逻辑
if store_id:
scope = f"{store_id}_{product_id}"
else:
# 如果没有指定版本,使用最新版本
latest_version = get_latest_model_version(product_id, model_type)
if latest_version:
model_id = f"{product_id}_{model_type}_{latest_version}"
version = latest_version
else:
# 兼容旧的无版本模型
model_id = get_latest_model_id(model_type, product_id)
if not model_id:
return jsonify({"status": "error", "error": f"未找到产品 {product_id}{model_type} 类型模型"}), 404
scope = f"{product_id}_all"
model_identifier = model_manager.get_model_identifier(model_type, training_mode, scope, 'sum') # Assuming 'sum' for now
if not version:
latest_version_num = model_manager._read_versions().get(model_identifier)
if latest_version_num is None:
return jsonify({"status": "error", "error": f"未找到模型 '{model_identifier}' 的任何版本。"}), 404
version = f"v{latest_version_num}"
version_num = int(version.replace('v',''))
model_version_path = model_manager.get_model_version_path(model_type, training_mode, scope, version_num, 'sum')
# 执行预测
prediction_result = run_prediction(model_type, product_id, model_id, future_days, start_date, version, store_id)
prediction_result = load_model_and_predict(
model_version_path=model_version_path,
future_days=future_days,
start_date=start_date,
analyze_result=include_visualization
)
if prediction_result is None:
return jsonify({"status": "error", "error": "预测失败预测器返回None"}), 500
@ -2275,14 +2043,7 @@ def list_models():
logger.info(f"[API] 分页参数: page={page}, page_size={page_size}")
# 使用模型管理器获取模型列表
result = model_manager.list_models(
product_id=product_id_filter,
model_type=model_type_filter,
store_id=store_id_filter,
training_mode=training_mode_filter,
page=page,
page_size=page_size
)
result = model_manager.list_models(page=page, page_size=page_size)
models = result['models']
pagination = result['pagination']
@ -2290,40 +2051,21 @@ def list_models():
# 格式化响应数据
formatted_models = []
for model in models:
# 生成唯一且有意义的model_id
model_id = model.get('filename', '').replace('.pth', '')
if not model_id:
# 备用方案基于模型信息生成ID
product_id = model.get('product_id', 'unknown')
model_type = model.get('model_type', 'unknown')
version = model.get('version', 'v1')
training_mode = model.get('training_mode', 'product')
store_id = model.get('store_id')
if training_mode == 'store' and store_id:
model_id = f"{model_type}_store_{store_id}_{product_id}_{version}"
elif training_mode == 'global':
aggregation_method = model.get('aggregation_method', 'mean')
model_id = f"{model_type}_global_{product_id}_{aggregation_method}_{version}"
else:
model_id = f"{model_type}_product_{product_id}_{version}"
# 从 model_manager 获取的信息已经很全面
# 我们只需要确保前端需要的字段都存在
formatted_model = {
'model_id': model_id,
'filename': model.get('filename', ''),
'product_id': model.get('product_id', ''),
'product_name': model.get('product_name', model.get('product_id', '')),
'model_type': model.get('model_type', ''),
'training_mode': model.get('training_mode', 'product'),
'store_id': model.get('store_id'),
'aggregation_method': model.get('aggregation_method'),
'version': model.get('version', 'v1'),
'created_at': model.get('created_at', model.get('modified_at', '')),
'file_size': model.get('file_size', 0),
'metrics': model.get('metrics', {}),
'config': model.get('config', {})
}
formatted_models.append(formatted_model)
# 药品范围
product_id = model.get('product_id')
if product_id and product_id not in ['all', 'unknown']:
model['product_scope'] = f"指定药品 ({product_id})"
else:
model['product_scope'] = "所有药品"
# 确保版本号存在
if 'version' not in model:
model['version'] = 'v1' # 默认值
formatted_models.append(model)
logger.info(f"[API] 成功获取 {len(formatted_models)} 个模型")
for i, model in enumerate(formatted_models):
@ -2578,10 +2320,10 @@ def delete_model(model_id):
print(f" - {test_path}")
return jsonify({"status": "error", "error": "模型未找到"}), 404
# 删除模型文件
os.remove(model_path)
# 新的删除逻辑
shutil.rmtree(model_path)
return jsonify({"status": "success", "message": f"模型 {model_id} 已删除"})
return jsonify({"status": "success", "message": f"模型目录 {model_path} 已删除"})
except ValueError:
return jsonify({"status": "error", "error": "无效的model_id格式"}), 400
except Exception as e:
@ -2639,8 +2381,13 @@ def export_model(model_id):
if not os.path.exists(model_path):
return jsonify({"status": "error", "error": "模型文件未找到"}), 404
# 新的导出逻辑
model_file_path = os.path.join(model_path, 'model.pth')
if not os.path.exists(model_file_path):
return jsonify({"status": "error", "error": "模型文件(model.pth)在目录中未找到"}), 404
return send_file(
model_path,
model_file_path,
as_attachment=True,
download_name=f'{model_id}.pth',
mimetype='application/octet-stream'
@ -2703,92 +2450,6 @@ def get_product_name(product_id):
print(f"获取产品名称失败: {str(e)}")
return None
# 执行预测的辅助函数
def run_prediction(model_type, product_id, model_id, future_days, start_date, version=None, store_id=None):
"""执行模型预测"""
try:
scope_msg = f", store_id={store_id}" if store_id else ", 全局模型"
print(f"开始运行预测: model_type={model_type}, product_id={product_id}, model_id={model_id}, version={version}{scope_msg}")
# 创建预测器实例
predictor = PharmacyPredictor()
# 解析模型类型映射
predictor_model_type = model_type
if model_type == 'optimized_kan':
predictor_model_type = 'optimized_kan'
# 生成预测
prediction_result = predictor.predict(
product_id=product_id,
model_type=predictor_model_type,
store_id=store_id,
future_days=future_days,
start_date=start_date,
version=version
)
if prediction_result is None:
return {"status": "error", "error": "预测失败预测器返回None"}
# 添加版本信息到预测结果
prediction_result['version'] = version
prediction_result['model_id'] = model_id
# 转换数据结构为前端期望的格式
if 'predictions' in prediction_result and isinstance(prediction_result['predictions'], pd.DataFrame):
predictions_df = prediction_result['predictions']
# 将DataFrame转换为prediction_data格式
prediction_data = []
for _, row in predictions_df.iterrows():
item = {
'date': row['date'].strftime('%Y-%m-%d') if hasattr(row['date'], 'strftime') else str(row['date']),
'predicted_sales': float(row['sales']) if pd.notna(row['sales']) else 0.0,
'sales': float(row['sales']) if pd.notna(row['sales']) else 0.0 # 兼容字段
}
prediction_data.append(item)
prediction_result['prediction_data'] = prediction_data
# 获取历史数据用于对比
try:
# 读取原始数据
from utils.multi_store_data_utils import load_multi_store_data
df = load_multi_store_data()
product_df = df[df['product_id'] == product_id].copy()
if not product_df.empty:
# 获取最近30天的历史数据
product_df['date'] = pd.to_datetime(product_df['date'])
product_df = product_df.sort_values('date')
# 取最后30天的数据
recent_history = product_df.tail(30)
history_data = []
for _, row in recent_history.iterrows():
item = {
'date': row['date'].strftime('%Y-%m-%d'),
'sales': float(row['sales']) if pd.notna(row['sales']) else 0.0
}
history_data.append(item)
prediction_result['history_data'] = history_data
else:
prediction_result['history_data'] = []
except Exception as e:
print(f"获取历史数据失败: {str(e)}")
prediction_result['history_data'] = []
return prediction_result
except Exception as e:
import traceback
traceback.print_exc()
print(f"预测过程中发生错误: {str(e)}")
return {"status": "error", "error": str(e)}
# 添加新的API路由支持/api/models/{model_type}/{product_id}/details格式
@app.route('/api/models/<model_type>/<product_id>/details', methods=['GET'])
@ -3768,30 +3429,33 @@ def get_model_types():
})
def get_model_versions_api(product_id, model_type):
"""获取模型版本列表API"""
try:
versions = get_model_versions(product_id, model_type)
latest_version = get_latest_model_version(product_id, model_type)
return jsonify({
"status": "success",
"data": {
"product_id": product_id,
"model_type": model_type,
"versions": versions,
"latest_version": latest_version
}
})
except Exception as e:
print(f"获取模型版本失败: {str(e)}")
return jsonify({"status": "error", "message": str(e)}), 500
# This endpoint needs to be re-evaluated based on the new directory structure.
# For now, it will be a placeholder.
return jsonify({"status": "info", "message": "Endpoint under construction after refactoring."})
@app.route('/api/models/store/<store_id>/<model_type>/versions', methods=['GET'])
def get_store_model_versions_api(store_id, model_type):
"""获取店铺模型版本列表API"""
try:
model_identifier = f"store_{store_id}"
versions = get_model_versions(model_identifier, model_type)
latest_version = get_latest_model_version(model_identifier, model_type)
all_models_data = model_manager.list_models()
all_models = all_models_data.get('models', [])
versions = []
for model in all_models:
is_store_model = model.get('training_mode') == 'store'
# 检查scope是否以 "store_id_" 开头,以匹配 "store_id_product_id" 或 "store_id_all"
is_correct_scope = model.get('scope', '').startswith(f"{store_id}_")
is_correct_type = model.get('model_type') == model_type
if is_store_model and is_correct_scope and is_correct_type:
version_str = model.get('version')
if version_str:
versions.append(version_str)
# 对版本进行排序, e.g., 'v10', 'v2', 'v1' -> 'v10', 'v2', 'v1'
versions.sort(key=lambda v: int(v.replace('v', '')), reverse=True)
latest_version = versions[0] if versions else None
return jsonify({
"status": "success",
@ -3810,9 +3474,23 @@ def get_store_model_versions_api(store_id, model_type):
def get_global_model_versions_api(model_type):
"""获取全局模型版本列表API"""
try:
model_identifier = "global"
versions = get_model_versions(model_identifier, model_type)
latest_version = get_latest_model_version(model_identifier, model_type)
all_models_data = model_manager.list_models()
all_models = all_models_data.get('models', [])
versions = []
for model in all_models:
is_global_model = model.get('training_mode') == 'global'
is_correct_type = model.get('model_type') == model_type
if is_global_model and is_correct_type:
version_str = model.get('version')
if version_str:
versions.append(version_str)
# 对版本进行排序
versions.sort(key=lambda v: int(v.replace('v', '')), reverse=True)
latest_version = versions[0] if versions else None
return jsonify({
"status": "success",

View File

@ -70,217 +70,3 @@ TRAINING_UPDATE_INTERVAL = 1 # 训练进度更新间隔(秒)
# 创建模型保存目录
os.makedirs(DEFAULT_MODEL_DIR, exist_ok=True)
def get_next_model_version(product_id: str, model_type: str) -> str:
"""
获取指定产品和模型类型的下一个版本号
Args:
product_id: 产品ID
model_type: 模型类型
Returns:
下一个版本号格式如 'v2', 'v3'
"""
# 新格式:带版本号的文件
pattern_new = f"{model_type}_model_product_{product_id}_v*.pth"
existing_files_new = glob.glob(os.path.join(DEFAULT_MODEL_DIR, pattern_new))
# 旧格式:不带版本号的文件(兼容性支持)
pattern_old = f"{model_type}_model_product_{product_id}.pth"
old_file_path = os.path.join(DEFAULT_MODEL_DIR, pattern_old)
has_old_format = os.path.exists(old_file_path)
# 如果没有任何格式的文件,返回默认版本
if not existing_files_new and not has_old_format:
return DEFAULT_VERSION
# 提取新格式文件的版本号
versions = []
for file_path in existing_files_new:
filename = os.path.basename(file_path)
version_match = re.search(rf"_v(\d+)\.pth$", filename)
if version_match:
versions.append(int(version_match.group(1)))
# 如果存在旧格式文件将其视为v1
if has_old_format:
versions.append(1)
print(f"检测到旧格式模型文件: {old_file_path}将其视为版本v1")
if versions:
next_version_num = max(versions) + 1
return f"v{next_version_num}"
else:
return DEFAULT_VERSION
def get_model_file_path(product_id: str, model_type: str, version: str = None) -> str:
"""
生成模型文件路径
Args:
product_id: 产品ID
model_type: 模型类型
version: 版本号如果为None则获取下一个版本
Returns:
模型文件的完整路径
"""
if version is None:
version = get_next_model_version(product_id, model_type)
# 特殊处理v1版本检查是否存在旧格式文件
if version == "v1":
# 检查旧格式文件是否存在
old_format_filename = f"{model_type}_model_product_{product_id}.pth"
old_format_path = os.path.join(DEFAULT_MODEL_DIR, old_format_filename)
if os.path.exists(old_format_path):
print(f"找到旧格式模型文件: {old_format_path}将其作为v1版本")
return old_format_path
# 使用新格式文件名
filename = f"{model_type}_model_product_{product_id}_{version}.pth"
return os.path.join(DEFAULT_MODEL_DIR, filename)
def get_model_versions(product_id: str, model_type: str) -> list:
"""
获取指定产品和模型类型的所有版本
Args:
product_id: 产品ID
model_type: 模型类型
Returns:
版本列表按版本号排序
"""
# 新格式:带版本号的文件
pattern_new = f"{model_type}_model_product_{product_id}_v*.pth"
existing_files_new = glob.glob(os.path.join(DEFAULT_MODEL_DIR, pattern_new))
# 旧格式:不带版本号的文件(兼容性支持)
pattern_old = f"{model_type}_model_product_{product_id}.pth"
old_file_path = os.path.join(DEFAULT_MODEL_DIR, pattern_old)
has_old_format = os.path.exists(old_file_path)
versions = []
# 处理新格式文件
for file_path in existing_files_new:
filename = os.path.basename(file_path)
version_match = re.search(rf"_v(\d+)\.pth$", filename)
if version_match:
version_num = int(version_match.group(1))
versions.append(f"v{version_num}")
# 如果存在旧格式文件将其视为v1
if has_old_format:
if "v1" not in versions: # 避免重复添加
versions.append("v1")
print(f"检测到旧格式模型文件: {old_file_path}将其视为版本v1")
# 按版本号排序
versions.sort(key=lambda v: int(v[1:]))
return versions
def get_latest_model_version(product_id: str, model_type: str) -> str:
"""
获取指定产品和模型类型的最新版本
Args:
product_id: 产品ID
model_type: 模型类型
Returns:
最新版本号如果没有则返回None
"""
versions = get_model_versions(product_id, model_type)
return versions[-1] if versions else None
def save_model_version_info(product_id: str, model_type: str, version: str, file_path: str, metrics: dict = None):
"""
保存模型版本信息到数据库
Args:
product_id: 产品ID
model_type: 模型类型
version: 版本号
file_path: 模型文件路径
metrics: 模型性能指标
"""
import sqlite3
import json
from datetime import datetime
try:
conn = sqlite3.connect('prediction_history.db')
cursor = conn.cursor()
# 插入模型版本记录
cursor.execute('''
INSERT INTO model_versions (
product_id, model_type, version, file_path, created_at, metrics, is_active
) VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
product_id,
model_type,
version,
file_path,
datetime.now().isoformat(),
json.dumps(metrics) if metrics else None,
1 # 新模型默认为激活状态
))
conn.commit()
conn.close()
print(f"已保存模型版本信息: {product_id}_{model_type}_{version}")
except Exception as e:
print(f"保存模型版本信息失败: {str(e)}")
def get_model_version_info(product_id: str, model_type: str, version: str = None):
"""
从数据库获取模型版本信息
Args:
product_id: 产品ID
model_type: 模型类型
version: 版本号如果为None则获取最新版本
Returns:
模型版本信息字典
"""
import sqlite3
import json
try:
conn = sqlite3.connect('prediction_history.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if version:
cursor.execute('''
SELECT * FROM model_versions
WHERE product_id = ? AND model_type = ? AND version = ?
ORDER BY created_at DESC LIMIT 1
''', (product_id, model_type, version))
else:
cursor.execute('''
SELECT * FROM model_versions
WHERE product_id = ? AND model_type = ?
ORDER BY created_at DESC LIMIT 1
''', (product_id, model_type))
row = cursor.fetchone()
conn.close()
if row:
result = dict(row)
if result['metrics']:
result['metrics'] = json.loads(result['metrics'])
return result
return None
except Exception as e:
print(f"获取模型版本信息失败: {str(e)}")
return None

View File

@ -1,14 +1,11 @@
"""
药店销售预测系统 - 核心预测器类
支持多店铺销售预测功能
药店销售预测系统 - 核心预测器类 (已重构)
支持多店铺销售预测功能并完全集成新的ModelManager
"""
import os
import pandas as pd
import numpy as np
import torch
import time
import matplotlib.pyplot as plt
from datetime import datetime
from trainers import (
@ -18,14 +15,13 @@ from trainers import (
train_product_model_with_transformer
)
from predictors.model_predictor import load_model_and_predict
from utils.data_utils import prepare_data, prepare_sequences
from utils.multi_store_data_utils import (
load_multi_store_data,
load_multi_store_data,
get_store_product_sales_data,
aggregate_multi_store_data
)
from analysis.metrics import evaluate_model
from core.config import DEVICE, DEFAULT_MODEL_DIR, DEFAULT_DATA_PATH
from utils.model_manager import model_manager
class PharmacyPredictor:
"""
@ -34,16 +30,8 @@ class PharmacyPredictor:
def __init__(self, data_path=None, model_dir=DEFAULT_MODEL_DIR):
"""
初始化预测器
参数:
data_path: 数据文件路径默认使用多店铺CSV文件
model_dir: 模型保存目录
"""
# 设置默认数据路径为多店铺CSV文件
if data_path is None:
data_path = DEFAULT_DATA_PATH
self.data_path = data_path
self.data_path = data_path if data_path else DEFAULT_DATA_PATH
self.model_dir = model_dir
self.device = DEVICE
@ -52,497 +40,297 @@ class PharmacyPredictor:
print(f"使用设备: {self.device}")
# 尝试加载多店铺数据
try:
self.data = load_multi_store_data(data_path)
print(f"已加载多店铺数据,来源: {data_path}")
self.data = load_multi_store_data(self.data_path)
print(f"已加载多店铺数据,来源: {self.data_path}")
except Exception as e:
print(f"加载数据失败: {e}")
self.data = None
def train_model(self, product_id, model_type='transformer', epochs=100, batch_size=32,
learning_rate=0.001, sequence_length=30, forecast_horizon=7,
hidden_size=64, num_layers=2, dropout=0.1, use_optimized=False,
store_id=None, training_mode='product', aggregation_method='sum',
socketio=None, task_id=None, version=None, continue_training=False,
progress_callback=None):
"""
训练预测模型 - 支持多店铺训练
def _prepare_product_params(self, product_id, store_scope, **kwargs):
"""'product' 训练模式准备参数"""
if not product_id:
raise ValueError("进行 'product' 模式训练时,必须提供 product_id。")
参数:
product_id: 产品ID
model_type: 模型类型 ('transformer', 'mlstm', 'kan', 'tcn', 'optimized_kan')
epochs: 训练轮次
batch_size: 批次大小
learning_rate: 学习率
sequence_length: 输入序列长度
forecast_horizon: 预测天数
hidden_size: 隐藏层大小
num_layers: 层数
dropout: Dropout比例
use_optimized: 是否使用优化版KAN仅当model_type为'kan'时有效
store_id: 店铺ID仅当training_mode为'store'时使用
training_mode: 训练模式 ('product', 'store', 'global')
aggregation_method: 聚合方法 ('sum', 'mean', 'median') - 仅用于全局训练
agg_store_id = None
final_scope_suffix = store_scope
返回:
metrics: 模型评估指标
"""
# 创建统一的输出函数
def log_message(message, log_type='info'):
"""统一的日志输出函数"""
print(message, flush=True) # 始终输出到控制台
if store_scope == 'specific':
store_ids = kwargs.get('store_ids')
if not store_ids:
raise ValueError("当 store_scope 为 'specific' 时, 必须提供 store_ids 列表。")
agg_store_id = store_ids
final_scope_suffix = f"specific_{'_'.join(store_ids)}"
elif store_scope != 'all':
# 假设 store_scope 本身就是一个店铺ID
agg_store_id = [store_scope]
return {
'agg_store_id': agg_store_id,
'agg_product_id': [product_id],
'final_scope': f"{product_id}_{final_scope_suffix}",
}
def _prepare_store_params(self, store_id, product_scope, **kwargs):
"""'store' 训练模式准备参数"""
if not store_id:
raise ValueError("进行 'store' 模式训练时,必须提供 store_id。")
agg_product_id = None
final_scope_suffix = product_scope
if product_scope == 'specific':
product_ids = kwargs.get('product_ids')
if not product_ids:
raise ValueError("当 product_scope 为 'specific' 时, 必须提供 product_ids 列表。")
agg_product_id = product_ids
final_scope_suffix = f"specific_{'_'.join(product_ids)}"
elif product_scope != 'all':
# 假设 product_scope 本身就是一个药品ID
agg_product_id = [product_scope]
return {
'agg_store_id': [store_id],
'agg_product_id': agg_product_id,
'final_scope': f"{store_id}_{final_scope_suffix}",
}
def _prepare_global_params(self, global_scope, store_ids, product_ids, **kwargs):
"""'global' 训练模式准备参数"""
agg_store_id, agg_product_id = None, None
if global_scope == 'all':
final_scope = 'all'
elif global_scope == 'selected_stores':
if not store_ids: raise ValueError("global_scope 为 'selected_stores' 时必须提供 store_ids。")
final_scope = f"stores/{'_'.join(store_ids)}"
agg_store_id = store_ids
elif global_scope == 'selected_products':
if not product_ids: raise ValueError("global_scope 为 'selected_products' 时必须提供 product_ids。")
final_scope = f"products/{'_'.join(product_ids)}"
agg_product_id = product_ids
elif global_scope == 'custom':
if not store_ids or not product_ids: raise ValueError("global_scope 为 'custom' 时必须提供 store_ids 和 product_ids。")
final_scope = f"custom/{'_'.join(store_ids)}/{'_'.join(product_ids)}"
agg_store_id = store_ids
agg_product_id = product_ids
else:
raise ValueError(f"不支持的 global_scope: '{global_scope}'")
# 如果有进度回调,也发送到回调
return {
'agg_store_id': agg_store_id,
'agg_product_id': agg_product_id,
'final_scope': final_scope,
}
def _prepare_training_params(self, training_mode, **kwargs):
"""参数准备分发器"""
if training_mode == 'product':
return self._prepare_product_params(**kwargs)
elif training_mode == 'store':
return self._prepare_store_params(**kwargs)
elif training_mode == 'global':
return self._prepare_global_params(**kwargs)
else:
raise ValueError(f"不支持的 training_mode: '{training_mode}'")
def train_model(self, **kwargs):
"""
训练预测模型 - 完全适配新的训练器接口和模型保存规则
"""
# 从 kwargs 中安全地提取参数
product_id = kwargs.get('product_id')
model_type = kwargs.get('model_type', 'transformer')
epochs = kwargs.get('epochs', 100)
learning_rate = kwargs.get('learning_rate', 0.001)
use_optimized = kwargs.get('use_optimized', False)
store_id = kwargs.get('store_id')
training_mode = kwargs.get('training_mode', 'product')
aggregation_method = kwargs.get('aggregation_method', 'sum')
product_scope = kwargs.get('product_scope', 'all')
store_scope = kwargs.get('store_scope', 'all')
global_scope = kwargs.get('global_scope', 'all')
product_ids = kwargs.get('product_ids')
store_ids = kwargs.get('store_ids')
socketio = kwargs.get('socketio')
task_id = kwargs.get('task_id')
progress_callback = kwargs.get('progress_callback')
patience = kwargs.get('patience', 10)
def log_message(message, log_type='info'):
print(f"[{log_type.upper()}] {message}", flush=True)
if progress_callback:
try:
progress_callback({
'log_type': log_type,
'message': message
})
progress_callback({'log_type': log_type, 'message': message})
except Exception as e:
print(f"进度回调失败: {e}", flush=True)
print(f"[ERROR] 进度回调失败: {e}", flush=True)
if self.data is None:
log_message("没有可用的数据,请先加载或生成数据", 'error')
return None
# 根据训练模式准备数据
if training_mode == 'product':
# 按产品训练:使用所有店铺的该产品数据
product_data = self.data[self.data['product_id'] == product_id].copy()
if product_data.empty:
log_message(f"找不到产品 {product_id} 的数据", 'error')
return None
log_message(f"按产品训练模式: 产品 {product_id}, 数据量: {len(product_data)}")
elif training_mode == 'store':
# 按店铺训练
if not store_id:
log_message("店铺训练模式需要指定 store_id", 'error')
return None
# 如果product_id是'unknown',则表示为店铺所有商品训练一个聚合模型
if product_id == 'unknown':
try:
# 使用新的聚合函数,按店铺聚合
product_data = aggregate_multi_store_data(
store_id=store_id,
aggregation_method=aggregation_method,
file_path=self.data_path
)
log_message(f"按店铺聚合训练: 店铺 {store_id}, 聚合方法 {aggregation_method}, 数据量: {len(product_data)}")
# 将product_id设置为店铺ID以便模型保存时使用有意义的标识
product_id = store_id
except Exception as e:
log_message(f"聚合店铺 {store_id} 数据失败: {e}", 'error')
return None
else:
# 为店铺的单个特定产品训练
try:
product_data = get_store_product_sales_data(
store_id=store_id,
product_id=product_id,
file_path=self.data_path
)
log_message(f"按店铺-产品训练: 店铺 {store_id}, 产品 {product_id}, 数据量: {len(product_data)}")
except Exception as e:
log_message(f"获取店铺产品数据失败: {e}", 'error')
return None
elif training_mode == 'global':
# 全局训练:聚合所有店铺的产品数据
try:
# 如果product_id是'unknown',则表示为全局所有商品训练一个聚合模型
if product_id == 'unknown':
product_data = aggregate_multi_store_data(
product_id=None, # 传递None以触发真正的全局聚合
aggregation_method=aggregation_method,
file_path=self.data_path
)
log_message(f"全局训练模式: 所有产品, 聚合方法 {aggregation_method}, 数据量: {len(product_data)}")
# 将product_id设置为一个有意义的标识符
product_id = 'all_products'
else:
product_data = aggregate_multi_store_data(
product_id=product_id,
aggregation_method=aggregation_method,
file_path=self.data_path
)
log_message(f"全局训练模式: 产品 {product_id}, 聚合方法 {aggregation_method}, 数据量: {len(product_data)}")
except Exception as e:
log_message(f"聚合全局数据失败: {e}", 'error')
return None
else:
log_message(f"不支持的训练模式: {training_mode}", 'error')
try:
# 将所有相关参数打包以便传递
prep_args = {
'training_mode': training_mode,
'product_id': product_id, 'store_id': store_id,
'product_scope': product_scope, 'store_scope': store_scope,
'global_scope': global_scope, 'product_ids': product_ids, 'store_ids': store_ids
}
params = self._prepare_training_params(**prep_args)
product_data = aggregate_multi_store_data(
store_id=params['agg_store_id'],
product_id=params['agg_product_id'],
aggregation_method=aggregation_method,
file_path=self.data_path
)
if product_data is None or product_data.empty:
raise ValueError(f"聚合后数据为空,无法继续训练。模式: {training_mode}, Scope: {params['final_scope']}")
except ValueError as e:
log_message(f"参数校验或数据准备失败: {e}", 'error')
return None
except Exception as e:
import traceback
log_message(f"数据准备过程中发生未知错误: {e}", 'error')
log_message(traceback.format_exc(), 'error')
return None
# 根据训练模式构建模型标识符
if training_mode == 'store':
model_identifier = f"{store_id}_{product_id}"
elif training_mode == 'global':
model_identifier = f"global_{product_id}_{aggregation_method}"
else:
model_identifier = product_id
# 调用相应的训练函数
try:
log_message(f"🤖 开始调用 {model_type} 训练器")
if model_type == 'transformer':
model_result, metrics, actual_version = train_product_model_with_transformer(
product_id=product_id,
product_df=product_data,
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
epochs=epochs,
model_dir=self.model_dir,
version=version,
socketio=socketio,
task_id=task_id,
continue_training=continue_training
)
log_message(f"{model_type} 训练器返回: metrics={type(metrics)}, version={actual_version}", 'success')
elif model_type == 'mlstm':
_, metrics, _, _ = train_product_model_with_mlstm(
product_id=product_id,
product_df=product_data,
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
epochs=epochs,
model_dir=self.model_dir,
socketio=socketio,
task_id=task_id,
progress_callback=progress_callback
)
elif model_type == 'kan':
_, metrics = train_product_model_with_kan(
product_id=product_id,
product_df=product_data,
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
epochs=epochs,
use_optimized=use_optimized,
model_dir=self.model_dir
)
elif model_type == 'optimized_kan':
_, metrics = train_product_model_with_kan(
product_id=product_id,
product_df=product_data,
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
epochs=epochs,
use_optimized=True,
model_dir=self.model_dir
)
elif model_type == 'tcn':
_, metrics, _, _ = train_product_model_with_tcn(
product_id=product_id,
product_df=product_data,
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
epochs=epochs,
model_dir=self.model_dir,
socketio=socketio,
task_id=task_id
)
else:
log_message(f"不支持的模型类型: {model_type}", 'error')
return None
# 检查和打印返回的metrics
log_message(f"📊 训练完成检查返回的metrics: {metrics}")
# 在返回的metrics中添加训练信息
if metrics:
log_message(f"✅ metrics不为空添加训练信息")
metrics.update({
'training_mode': training_mode,
'store_id': store_id,
'product_id': product_id,
'model_identifier': model_identifier,
'aggregation_method': aggregation_method if training_mode == 'global' else None
})
log_message(f"📈 最终返回的metrics: {metrics}", 'success')
else:
log_message(f"⚠️ metrics为空或None", 'warning')
return metrics
except Exception as e:
log_message(f"模型训练失败: {e}", 'error')
return None
def predict(self, product_id, model_type, future_days=7, start_date=None, analyze_result=False, version=None,
store_id=None, training_mode='product', aggregation_method='sum'):
"""
使用已训练的模型进行预测 - 支持多店铺预测
参数:
product_id: 产品ID
model_type: 模型类型
future_days: 预测未来天数
start_date: 预测起始日期
analyze_result: 是否分析预测结果
version: 模型版本如果为None则使用最新版本
store_id: 店铺ID仅当training_mode为'store'时使用
training_mode: 训练模式 ('product', 'store', 'global')
aggregation_method: 聚合方法 ('sum', 'mean', 'median') - 仅用于全局预测
返回:
预测结果和分析如果analyze_result为True
"""
# 根据训练模式构建模型标识符
if training_mode == 'store' and store_id:
model_identifier = f"{store_id}_{product_id}"
elif training_mode == 'global':
model_identifier = f"global_{product_id}_{aggregation_method}"
else:
model_identifier = product_id
return load_model_and_predict(
model_identifier,
model_type,
future_days=future_days,
start_date=start_date,
analyze_result=analyze_result,
version=version
)
def train_optimized_kan_model(self, product_id, epochs=100, batch_size=32,
learning_rate=0.001, sequence_length=30, forecast_horizon=7,
hidden_size=64, num_layers=2, dropout=0.1):
"""
训练优化版KAN模型便捷方法
参数与train_model相同但固定model_type为'kan'且use_optimized为True
"""
return self.train_model(
product_id=product_id,
model_type='kan',
epochs=epochs,
batch_size=batch_size,
learning_rate=learning_rate,
sequence_length=sequence_length,
forecast_horizon=forecast_horizon,
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
use_optimized=True
)
def compare_kan_models(self, product_id, epochs=100, batch_size=32,
learning_rate=0.001, sequence_length=30, forecast_horizon=7,
hidden_size=64, num_layers=2, dropout=0.1):
"""
比较原始KAN和优化版KAN模型性能
参数与train_model相同
返回:
比较结果字典
"""
print(f"开始比较产品 {product_id} 的原始KAN和优化版KAN模型性能...")
# 训练原始KAN模型
print("\n训练原始KAN模型...")
kan_metrics = self.train_model(
product_id=product_id,
model_type='kan',
epochs=epochs,
batch_size=batch_size,
learning_rate=learning_rate,
sequence_length=sequence_length,
forecast_horizon=forecast_horizon,
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
use_optimized=False
)
# 训练优化版KAN模型
print("\n训练优化版KAN模型...")
optimized_kan_metrics = self.train_model(
product_id=product_id,
model_type='kan',
epochs=epochs,
batch_size=batch_size,
learning_rate=learning_rate,
sequence_length=sequence_length,
forecast_horizon=forecast_horizon,
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
use_optimized=True
)
# 比较结果
comparison = {
'kan': kan_metrics,
'optimized_kan': optimized_kan_metrics
trainers = {
'transformer': train_product_model_with_transformer,
'mlstm': train_product_model_with_mlstm,
'tcn': train_product_model_with_tcn,
'kan': train_product_model_with_kan,
'optimized_kan': train_product_model_with_kan,
}
# 打印比较结果
print("\n模型性能比较:")
print(f"{'指标':<10} {'原始KAN':<15} {'优化版KAN':<15} {'改进百分比':<15}")
print("-" * 55)
for metric in ['mse', 'rmse', 'mae', 'mape']:
if metric in kan_metrics and metric in optimized_kan_metrics:
kan_value = kan_metrics[metric]
opt_value = optimized_kan_metrics[metric]
improvement = (kan_value - opt_value) / kan_value * 100 if kan_value != 0 else 0
print(f"{metric.upper():<10} {kan_value:<15.4f} {opt_value:<15.4f} {improvement:<15.2f}%")
# R²值越高越好所以计算改进的方式不同
if 'r2' in kan_metrics and 'r2' in optimized_kan_metrics:
kan_r2 = kan_metrics['r2']
opt_r2 = optimized_kan_metrics['r2']
improvement = (opt_r2 - kan_r2) / (1 - kan_r2) * 100 if kan_r2 != 1 else 0
print(f"{'':<10} {kan_r2:<15.4f} {opt_r2:<15.4f} {improvement:<15.2f}%")
# 训练时间
if 'training_time' in kan_metrics and 'training_time' in optimized_kan_metrics:
kan_time = kan_metrics['training_time']
opt_time = optimized_kan_metrics['training_time']
time_diff = (opt_time - kan_time) / kan_time * 100 if kan_time != 0 else 0
print(f"{'时间(秒)':<10} {kan_time:<15.2f} {opt_time:<15.2f} {time_diff:<15.2f}%")
return comparison
def list_available_models(self, product_id=None, store_id=None, training_mode=None):
"""
列出可用的已训练模型 - 支持多店铺模型
参数:
product_id: 产品ID如果为None则列出所有模型
store_id: 店铺ID用于筛选店铺专属模型
training_mode: 训练模式筛选 ('product', 'store', 'global')
返回:
可用模型列表
"""
if not os.path.exists(self.model_dir):
print(f"模型目录 {self.model_dir} 不存在")
return []
model_files = os.listdir(self.model_dir)
models = []
for file in model_files:
if file.endswith('.pth'):
try:
# 解析模型文件名
model_info = self._parse_model_filename(file)
if model_info:
# 应用过滤条件
if product_id and model_info.get('product_id') != product_id:
continue
if store_id and model_info.get('store_id') != store_id:
continue
if training_mode and model_info.get('training_mode') != training_mode:
continue
model_info['file_name'] = file
model_info['file_path'] = os.path.join(self.model_dir, file)
models.append(model_info)
except Exception as e:
print(f"解析模型文件名失败: {file}, 错误: {e}")
continue
return models
def _parse_model_filename(self, filename):
"""
解析模型文件名提取模型信息
参数:
filename: 模型文件名
返回:
dict: 模型信息字典
"""
# 移除文件扩展名
name = filename.replace('.pth', '')
# 解析新的多店铺模型命名格式
if '_model_product_' in name:
parts = name.split('_model_product_')
model_type = parts[0]
product_part = parts[1]
if model_type not in trainers:
log_message(f"不支持的模型类型: {model_type}", 'error')
return None
# 检查是否是店铺模型 (格式: model_type_model_product_store_id_product_id)
if len(product_part.split('_')) > 1:
store_id = product_part.split('_')[0]
product_id = '_'.join(product_part.split('_')[1:])
training_mode = 'store'
# 检查是否是全局模型 (格式: model_type_model_product_global_product_id_method)
elif product_part.startswith('global_'):
parts = product_part.split('_')
if len(parts) >= 3:
product_id = '_'.join(parts[1:-1])
aggregation_method = parts[-1]
store_id = None
training_mode = 'global'
else:
product_id = product_part
store_id = None
training_mode = 'product'
trainer_func = trainers[model_type]
trainer_args = {
"product_df": product_data,
"training_mode": training_mode,
"aggregation_method": aggregation_method,
"scope": params['final_scope'],
"epochs": epochs,
"socketio": socketio,
"task_id": task_id,
"progress_callback": progress_callback,
"patience": patience,
"learning_rate": learning_rate
}
if 'kan' in model_type:
trainer_args['use_optimized'] = (model_type == 'optimized_kan')
# 确保将 product_id 和 store_id 传递给训练器
if product_id:
trainer_args['product_id'] = product_id
if store_id:
trainer_args['store_id'] = store_id
try:
log_message(f"🤖 开始调用 {model_type} 训练器 with scope: '{params['final_scope']}'")
model, metrics, version, model_version_path = trainer_func(**trainer_args)
log_message(f"{model_type} 训练器成功返回", 'success')
if metrics:
relative_model_path = os.path.relpath(model_version_path, os.getcwd())
metrics.update({
'model_type': model_type,
'version': version,
'model_path': relative_model_path.replace('\\', '/'),
'training_mode': training_mode,
'scope': params['final_scope'],
'aggregation_method': aggregation_method
})
log_message(f"📈 最终返回的metrics: {metrics}", 'success')
return metrics
else:
# 常规产品模型
product_id = product_part
store_id = None
training_mode = 'product'
# 处理优化版KAN模型
if 'optimized' in model_type:
model_type = 'optimized_kan'
return {
'model_type': model_type,
'product_id': product_id,
'store_id': store_id,
'training_mode': training_mode,
'aggregation_method': aggregation_method if training_mode == 'global' and 'aggregation_method' in locals() else None
}
# 处理旧格式的向后兼容性
elif "kan_optimized_model" in name:
model_type = "optimized_kan"
product_id = name.split('_product_')[1] if '_product_' in name else 'unknown'
return {
'model_type': model_type,
'product_id': product_id,
'store_id': None,
'training_mode': 'product',
'aggregation_method': None
}
return None
def delete_model(self, product_id, model_type):
log_message("⚠️ 训练器返回的metrics为空", 'warning')
return None
except Exception as e:
import traceback
log_message(f"模型训练过程中发生严重错误: {e}\n{traceback.format_exc()}", 'error')
return None
def predict(self, model_version_path, future_days=7, start_date=None, analyze_result=False):
"""
删除已训练的模型
参数:
product_id: 产品ID
model_type: 模型类型
返回:
是否成功删除
使用已训练的模型进行预测 - 直接使用模型版本路径
"""
model_suffix = '_optimized' if model_type == 'optimized_kan' else ''
model_name = f"{model_type}{model_suffix}_model_product_{product_id}.pth"
model_path = os.path.join(self.model_dir, model_name)
if not os.path.exists(model_version_path):
raise FileNotFoundError(f"指定的模型路径不存在: {model_version_path}")
return load_model_and_predict(
model_version_path=model_version_path,
future_days=future_days,
start_date=start_date,
analyze_result=analyze_result
)
def list_models(self, **kwargs):
"""
列出所有可用的模型版本
直接调用 ModelManager list_models 方法
支持的过滤参数: model_type, training_mode, scope, version
"""
return model_manager.list_models(**kwargs)
def delete_model(self, model_version_path):
"""
删除一个指定的模型版本目录
"""
return model_manager.delete_model_version(model_version_path)
def compare_models(self, product_id, epochs=50, **kwargs):
"""
在相同数据上训练并比较多个模型的性能
"""
results = {}
model_types_to_compare = ['tcn', 'mlstm', 'transformer', 'kan', 'optimized_kan']
if os.path.exists(model_path):
os.remove(model_path)
print(f"已删除模型: {model_path}")
return True
else:
print(f"模型文件 {model_path} 不存在")
return False
for model_type in model_types_to_compare:
print(f"\n{'='*20} 训练模型: {model_type.upper()} {'='*20}")
try:
metrics = self.train_model(
product_id=product_id,
model_type=model_type,
epochs=epochs,
**kwargs
)
results[model_type] = metrics if metrics else {}
except Exception as e:
print(f"训练 {model_type} 模型失败: {e}")
results[model_type] = {'error': str(e)}
# 打印比较结果
print(f"\n{'='*25} 模型性能比较 {'='*25}")
# 准备数据
df_data = []
for model, metrics in results.items():
if metrics and 'rmse' in metrics:
df_data.append({
'Model': model.upper(),
'RMSE': metrics.get('rmse'),
'': metrics.get('r2'),
'MAPE (%)': metrics.get('mape'),
'Time (s)': metrics.get('training_time')
})
if not df_data:
print("没有可供比较的模型结果。")
return results
comparison_df = pd.DataFrame(df_data).set_index('Model')
print(comparison_df.to_string(float_format="%.4f"))
return results

View File

@ -21,78 +21,37 @@ from models.optimized_kan_forecaster import OptimizedKANForecaster
from analysis.trend_analysis import analyze_prediction_result
from utils.visualization import plot_prediction_results
from utils.multi_store_data_utils import get_store_product_sales_data, aggregate_multi_store_data
from core.config import DEVICE, get_model_file_path
from core.config import DEVICE
def load_model_and_predict(product_id, model_type, store_id=None, future_days=7, start_date=None, analyze_result=False, version=None):
def load_model_and_predict(model_version_path: str, future_days=7, start_date=None, analyze_result=False):
"""
加载已训练的模型并进行预测
从指定的版本目录加载模型并进行预测
参数:
product_id: 产品ID
model_type: 模型类型 ('transformer', 'mlstm', 'kan', 'tcn', 'optimized_kan')
store_id: 店铺ID为None时使用全局模型
future_days: 预测未来天数
start_date: 预测起始日期如果为None则使用最后一个已知日期
analyze_result: 是否分析预测结果
version: 模型版本如果为None则使用最新版本
model_version_path: 模型版本目录的绝对路径
future_days: 预测未来天数
start_date: 预测起始日期如果为None则使用最后一个已知日期
analyze_result: 是否分析预测结果
返回:
预测结果和分析如果analyze_result为True
"""
try:
# 确定模型文件路径(支持多店铺)
model_path = None
# 从路径中解析元数据
metadata_path = os.path.join(model_version_path, 'metadata.json')
if not os.path.exists(metadata_path):
raise FileNotFoundError(f"在路径 {model_version_path} 中未找到 metadata.json")
if version:
# 使用版本管理系统获取正确的文件路径
model_path = get_model_file_path(product_id, model_type, version)
else:
# 根据store_id确定搜索目录
if store_id:
# 查找特定店铺的模型
possible_dirs = [
os.path.join('saved_models', model_type, store_id),
os.path.join('models', model_type, store_id)
]
else:
# 查找全局模型
possible_dirs = [
os.path.join('saved_models', model_type, 'global'),
os.path.join('models', model_type, 'global'),
os.path.join('saved_models', model_type), # 后向兼容
'saved_models' # 最基本的目录
]
# 文件名模式
model_suffix = '_optimized' if model_type == 'optimized_kan' else ''
file_model_type = 'kan' if model_type == 'optimized_kan' else model_type
possible_names = [
f"{product_id}_{model_type}_v1_model.pt", # 新多店铺格式
f"{product_id}_{model_type}_v1_global_model.pt", # 全局模型格式
f"{product_id}_{model_type}_v1.pth", # 旧版本格式
f"{file_model_type}{model_suffix}_model_product_{product_id}.pth", # 原始格式
f"{model_type}_model_product_{product_id}.pth" # 简化格式
]
# 搜索模型文件
for dir_path in possible_dirs:
if not os.path.exists(dir_path):
continue
for name in possible_names:
test_path = os.path.join(dir_path, name)
if os.path.exists(test_path):
model_path = test_path
break
if model_path:
break
if not model_path:
scope_msg = f"店铺 {store_id}" if store_id else "全局"
print(f"找不到产品 {product_id}{model_type} 模型文件 ({scope_msg})")
print(f"搜索目录: {possible_dirs}")
return None
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
product_id = metadata.get('product_id')
model_type = metadata.get('model_type')
store_id = metadata.get('store_id')
training_mode = metadata.get('training_mode')
aggregation_method = metadata.get('aggregation_method')
model_path = os.path.join(model_version_path, 'model.pth')
print(f"尝试加载模型文件: {model_path}")
if not os.path.exists(model_path):

View File

@ -1,5 +1,5 @@
"""
药店销售预测系统 - KAN模型训练函数
药店销售预测系统 - KAN模型训练函数 (已重构)
"""
import os
@ -13,299 +13,312 @@ from torch.utils.data import DataLoader
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
from tqdm import tqdm
from datetime import datetime
from models.kan_model import KANForecaster
from models.optimized_kan_forecaster import OptimizedKANForecaster
from utils.data_utils import create_dataset, PharmacyDataset
from utils.visualization import plot_loss_curve
from analysis.metrics import evaluate_model
from core.config import DEVICE, DEFAULT_MODEL_DIR, LOOK_BACK, FORECAST_HORIZON
from core.config import DEVICE, LOOK_BACK, FORECAST_HORIZON
from utils.model_manager import model_manager
from typing import Any
def train_product_model_with_kan(product_id, product_df=None, store_id=None, training_mode='product', aggregation_method='sum', epochs=50, use_optimized=False, model_dir=DEFAULT_MODEL_DIR):
def convert_numpy_types(obj: Any) -> Any:
"""
使用KAN模型训练产品销售预测模型
参数:
product_id: 产品ID
epochs: 训练轮次
use_optimized: 是否使用优化版KAN
model_dir: 模型保存目录默认使用配置中的DEFAULT_MODEL_DIR
返回:
model: 训练好的模型
metrics: 模型评估指标
递归地将字典或列表中的Numpy数值类型转换为Python原生类型
"""
# 如果没有传入product_df则根据训练模式加载数据
if product_df is None:
from utils.multi_store_data_utils import load_multi_store_data, get_store_product_sales_data, aggregate_multi_store_data
if isinstance(obj, dict):
return {k: convert_numpy_types(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_numpy_types(elem) for elem in obj]
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
return obj
def train_product_model_with_kan(
product_df,
product_id=None,
store_id=None,
training_mode='product',
aggregation_method='sum',
scope=None,
epochs=50,
use_optimized=False,
socketio=None,
task_id=None,
progress_callback=None,
patience=10,
learning_rate=0.001
):
"""
使用KAN模型训练产品销售预测模型 (已适配新的ModelManager)
"""
def emit_progress(message, progress=None, metrics=None):
"""发送训练进度到前端"""
progress_data = {
'task_id': task_id,
'message': f"[KAN] {message}",
'timestamp': time.time()
}
if progress is not None:
progress_data['progress'] = progress
if metrics is not None:
progress_data['metrics'] = metrics
if progress_callback:
try:
progress_callback(progress_data)
except Exception as e:
print(f"[KAN] 进度回调失败: {e}")
if socketio and task_id:
try:
socketio.emit('training_progress', progress_data, namespace='/training')
except Exception as e:
print(f"[KAN] WebSocket发送失败: {e}")
print(f"[KAN] {message}", flush=True)
emit_progress("开始KAN模型训练...")
# 1. 确定模型标识符和版本
model_type = 'optimized_kan' if use_optimized else 'kan'
# 直接使用从 predictor 传递过来的、已经构建好的 scope
if scope is None:
# 作为后备如果scope未提供则根据旧逻辑构建不推荐
if training_mode == 'store':
current_product_id = product_id if product_id and product_id not in ['unknown', 'all'] else 'all'
scope = f"{store_id}_{current_product_id}"
elif training_mode == 'product':
scope = f"{product_id}_{store_id or 'all'}"
elif training_mode == 'global':
scope = product_id if product_id else "all"
emit_progress(f"警告: Scope未由调用方提供已自动构建为 '{scope}'", 'warning')
model_identifier = model_manager.get_model_identifier(model_type, training_mode, scope, aggregation_method)
version = model_manager.get_next_version_number(model_identifier)
emit_progress(f"开始训练 {model_type} 模型 v{version}")
# 2. 获取模型版本路径
model_version_path = model_manager.get_model_version_path(
model_type=model_type,
training_mode=training_mode,
scope=scope,
version=version,
aggregation_method=aggregation_method
)
emit_progress(f"模型将保存到: {model_version_path}")
# 3. 数据加载和预处理
if product_df is None:
# 此处保留了原有的数据加载逻辑作为后备
from utils.multi_store_data_utils import load_multi_store_data, get_store_product_sales_data, aggregate_multi_store_data
try:
if training_mode == 'store' and store_id:
# 加载特定店铺的数据
product_df = get_store_product_sales_data(
store_id,
product_id,
'pharmacy_sales_multi_store.csv'
)
training_scope = f"店铺 {store_id}"
product_df = get_store_product_sales_data(store_id, product_id, 'pharmacy_sales_multi_store.csv')
elif training_mode == 'global':
# 聚合所有店铺的数据
product_df = aggregate_multi_store_data(
product_id,
aggregation_method=aggregation_method,
file_path='pharmacy_sales_multi_store.csv'
)
training_scope = f"全局聚合({aggregation_method})"
product_df = aggregate_multi_store_data(product_id, aggregation_method=aggregation_method, file_path='pharmacy_sales_multi_store.csv')
else:
# 默认:加载所有店铺的产品数据
product_df = load_multi_store_data('pharmacy_sales_multi_store.csv', product_id=product_id)
training_scope = "所有店铺"
except Exception as e:
print(f"多店铺数据加载失败: {e}")
# 后备方案:尝试原始数据
emit_progress(f"多店铺数据加载失败: {e}, 尝试后备方案...")
df = pd.read_excel('pharmacy_sales.xlsx')
product_df = df[df['product_id'] == product_id].sort_values('date')
training_scope = "原始数据"
else:
# 如果传入了product_df直接使用
if training_mode == 'store' and store_id:
training_scope = f"店铺 {store_id}"
elif training_mode == 'global':
training_scope = f"全局聚合({aggregation_method})"
# 根据训练模式和参数动态生成更详细的描述
if training_mode == 'store':
training_scope = f"店铺 {store_id}"
if scope and 'specific' in scope:
training_scope += " (指定药品)"
else:
training_scope = "所有店铺"
if product_df.empty:
raise ValueError(f"产品 {product_id} 没有可用的销售数据")
# 数据量检查
training_scope += " (所有药品)"
elif training_mode == 'global':
training_scope = f"全局聚合({aggregation_method})"
else: # product 模式
training_scope = f"药品 {product_id}"
if scope and 'specific' in scope:
training_scope += " (指定店铺)"
elif store_id:
training_scope += f" (店铺 {store_id})"
else:
training_scope += " (所有店铺)"
min_required_samples = LOOK_BACK + FORECAST_HORIZON
if len(product_df) < min_required_samples:
error_msg = (
f"❌ 训练数据不足错误\n"
f"当前配置需要: {min_required_samples} 天数据 (LOOK_BACK={LOOK_BACK} + FORECAST_HORIZON={FORECAST_HORIZON})\n"
f"实际数据量: {len(product_df)}\n"
f"产品ID: {product_id}, 训练模式: {training_mode}\n"
f"建议解决方案:\n"
f"1. 生成更多数据: uv run generate_multi_store_data.py\n"
f"2. 调整配置参数: 减小 LOOK_BACK 或 FORECAST_HORIZON\n"
f"3. 使用全局训练模式聚合更多数据"
)
print(error_msg)
error_msg = f"数据不足: 需要 {min_required_samples} 天, 实际 {len(product_df)} 天。"
emit_progress(f"训练失败:{error_msg}")
raise ValueError(error_msg)
product_df = product_df.sort_values('date')
product_name = product_df['product_name'].iloc[0]
if product_id:
product_name = product_df['product_name'].iloc[0]
else:
product_name = f"Aggregated Model ({training_mode}/{scope})"
model_type = "优化版KAN" if use_optimized else "KAN"
print(f"使用{model_type}模型训练产品 '{product_name}' (ID: {product_id}) 的销售预测模型")
print(f"训练范围: {training_scope}")
print(f"使用设备: {DEVICE}")
print(f"模型将保存到目录: {model_dir}")
# 创建特征和目标变量
print_product_id = product_id if product_id else "N/A"
emit_progress(f"训练产品: '{product_name}' (ID: {print_product_id}) - {training_scope}")
emit_progress(f"使用设备: {DEVICE}, 数据量: {len(product_df)}")
features = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
# 预处理数据
X = product_df[features].values
y = product_df[['sales']].values # 保持为二维数组
y = product_df[['sales']].values
# 归一化数据
scaler_X = MinMaxScaler(feature_range=(0, 1))
scaler_y = MinMaxScaler(feature_range=(0, 1))
X_scaled = scaler_X.fit_transform(X)
y_scaled = scaler_y.fit_transform(y)
# 划分训练集和测试集80% 训练20% 测试)
train_size = int(len(X_scaled) * 0.8)
X_train, X_test = X_scaled[:train_size], X_scaled[train_size:]
y_train, y_test = y_scaled[:train_size], y_scaled[train_size:]
# 创建时间序列数据
trainX, trainY = create_dataset(X_train, y_train, LOOK_BACK, FORECAST_HORIZON)
testX, testY = create_dataset(X_test, y_test, LOOK_BACK, FORECAST_HORIZON)
# 转换为PyTorch的Tensor
trainX_tensor = torch.Tensor(trainX)
trainY_tensor = torch.Tensor(trainY)
testX_tensor = torch.Tensor(testX)
testY_tensor = torch.Tensor(testY)
train_loader = DataLoader(PharmacyDataset(torch.Tensor(trainX), torch.Tensor(trainY)), batch_size=32, shuffle=True)
test_loader = DataLoader(PharmacyDataset(torch.Tensor(testX), torch.Tensor(testY)), batch_size=32, shuffle=False)
# 创建数据加载器
train_dataset = PharmacyDataset(trainX_tensor, trainY_tensor)
test_dataset = PharmacyDataset(testX_tensor, testY_tensor)
batch_size = 32
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 初始化KAN模型
# 4. 模型初始化
input_dim = X_train.shape[1]
output_dim = FORECAST_HORIZON
hidden_size = 64
if use_optimized:
model = OptimizedKANForecaster(
input_features=input_dim,
hidden_sizes=[hidden_size, hidden_size*2, hidden_size],
output_sequence_length=output_dim
)
model = OptimizedKANForecaster(input_features=input_dim, hidden_sizes=[hidden_size, hidden_size*2, hidden_size], output_sequence_length=output_dim)
else:
model = KANForecaster(
input_features=input_dim,
hidden_sizes=[hidden_size, hidden_size*2, hidden_size],
output_sequence_length=output_dim
)
model = KANForecaster(input_features=input_dim, hidden_sizes=[hidden_size, hidden_size*2, hidden_size], output_sequence_length=output_dim)
# 将模型移动到设备上
model = model.to(DEVICE)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
# 训练模型
train_losses = []
test_losses = []
emit_progress("数据预处理完成,开始模型训练...", progress=10)
# 5. 训练循环
train_losses, test_losses = [], []
start_time = time.time()
best_loss = float('inf')
epochs_no_improve = 0
for epoch in range(epochs):
model.train()
epoch_loss = 0
for X_batch, y_batch in tqdm(train_loader, desc=f"Epoch {epoch+1}/{epochs}", leave=False):
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
if y_batch.dim() == 2: y_batch = y_batch.unsqueeze(-1)
# 确保目标张量有正确的形状 (batch_size, forecast_horizon, 1)
if y_batch.dim() == 2:
y_batch = y_batch.unsqueeze(-1)
# 前向传播
outputs = model(X_batch)
# 确保输出形状与目标匹配
if outputs.dim() == 2:
outputs = outputs.unsqueeze(-1)
if outputs.dim() == 2: outputs = outputs.unsqueeze(-1)
loss = criterion(outputs, y_batch)
# 如果是KAN模型加入正则化损失
if hasattr(model, 'regularization_loss'):
loss = loss + model.regularization_loss() * 0.01
# 反向传播和优化
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss += loss.item()
# 计算训练损失
train_loss = epoch_loss / len(train_loader)
train_losses.append(train_loss)
# 在测试集上评估
model.eval()
test_loss = 0
with torch.no_grad():
for X_batch, y_batch in test_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
# 确保目标张量有正确的形状
if y_batch.dim() == 2:
y_batch = y_batch.unsqueeze(-1)
if y_batch.dim() == 2: y_batch = y_batch.unsqueeze(-1)
outputs = model(X_batch)
# 确保输出形状与目标匹配
if outputs.dim() == 2:
outputs = outputs.unsqueeze(-1)
if outputs.dim() == 2: outputs = outputs.unsqueeze(-1)
loss = criterion(outputs, y_batch)
test_loss += loss.item()
test_loss = test_loss / len(test_loader)
test_loss /= len(test_loader)
test_losses.append(test_loss)
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}")
# 计算训练时间
progress_percentage = 10 + ((epoch + 1) / epochs) * 85
emit_progress(f"Epoch {epoch+1}/{epochs} - Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}", progress=progress_percentage)
if test_loss < best_loss:
best_loss = test_loss
epochs_no_improve = 0
checkpoint_data = {
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scaler_X': scaler_X,
'scaler_y': scaler_y,
}
model_manager.save_model_artifact(checkpoint_data, "checkpoint_best.pth", model_version_path)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
else:
epochs_no_improve += 1
if epochs_no_improve >= patience:
emit_progress(f"连续 {patience} 个epoch测试损失未改善提前停止训练。")
break
training_time = time.time() - start_time
# 绘制损失曲线并保存到模型目录
model_name = 'optimized_kan' if use_optimized else 'kan'
loss_curve_path = plot_loss_curve(
train_losses,
test_losses,
product_name,
model_type,
model_dir=model_dir
)
print(f"损失曲线已保存到: {loss_curve_path}")
# 评估模型
# 6. 保存产物和评估
loss_fig = plt.figure(figsize=(10, 6))
plt.plot(train_losses, label='Training Loss')
plt.plot(test_losses, label='Test Loss')
plt.title(f'{model_type} 损失曲线 - {product_name} (v{version}) - {training_scope}')
plt.xlabel('Epoch'); plt.ylabel('Loss'); plt.legend(); plt.grid(True)
model_manager.save_model_artifact(loss_fig, "loss_curve.png", model_version_path)
plt.close(loss_fig)
emit_progress(f"损失曲线已保存到: {os.path.join(model_version_path, 'loss_curve.png')}")
model.eval()
with torch.no_grad():
test_pred = model(testX_tensor.to(DEVICE)).cpu().numpy()
# 处理输出形状
if len(test_pred.shape) == 3:
test_pred = test_pred.squeeze(-1)
# 反归一化预测结果和真实值
test_pred_inv = scaler_y.inverse_transform(test_pred.reshape(-1, 1)).flatten()
test_true_inv = scaler_y.inverse_transform(testY.reshape(-1, 1)).flatten()
testX_tensor = torch.Tensor(testX).to(DEVICE)
test_pred = model(testX_tensor).cpu().numpy()
if len(test_pred.shape) == 3: test_pred = test_pred.squeeze(-1)
# 计算评估指标
metrics = evaluate_model(test_true_inv, test_pred_inv)
test_pred_inv = scaler_y.inverse_transform(test_pred.reshape(-1, FORECAST_HORIZON))
test_true_inv = scaler_y.inverse_transform(testY.reshape(-1, FORECAST_HORIZON))
metrics = evaluate_model(test_true_inv.flatten(), test_pred_inv.flatten())
metrics['training_time'] = training_time
# 打印评估指标
print("\n模型评估指标:")
print(f"MSE: {metrics['mse']:.4f}")
print(f"RMSE: {metrics['rmse']:.4f}")
print(f"MAE: {metrics['mae']:.4f}")
print(f"R²: {metrics['r2']:.4f}")
print(f"MAPE: {metrics['mape']:.2f}%")
print(f"训练时间: {training_time:.2f}")
# 解决 'Object of type float32 is not JSON serializable' 错误
metrics = convert_numpy_types(metrics)
# 使用统一模型管理器保存模型
from utils.model_manager import model_manager
model_type_name = 'optimized_kan' if use_optimized else 'kan'
model_data = {
emit_progress(f"模型评估完成 - RMSE: {metrics['rmse']:.4f}, R²: {metrics['r2']:.4f}")
# 7. 保存最终模型和元数据
final_model_data = {
'model_state_dict': model.state_dict(),
'scaler_X': scaler_X,
'scaler_y': scaler_y,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'hidden_sizes': [hidden_size, hidden_size*2, hidden_size],
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': model_type_name,
'use_optimized': use_optimized
},
'metrics': metrics,
'loss_history': {
'train': train_losses,
'test': test_losses,
'epochs': list(range(1, epochs + 1))
},
'loss_curve_path': loss_curve_path
}
model_manager.save_model_artifact(final_model_data, "model.pth", model_version_path)
metadata = {
'product_id': product_id if product_id else scope, 'product_name': product_name, 'model_type': model_type,
'version': f'v{version}', 'training_mode': training_mode, 'scope': scope,
'aggregation_method': aggregation_method, 'training_scope_description': training_scope,
'product_scope': '所有药品' if not product_id or product_id == 'all' else product_name,
'timestamp': datetime.now().isoformat(), 'metrics': metrics,
'config': {
'input_dim': input_dim, 'output_dim': output_dim,
'hidden_sizes': [hidden_size, hidden_size*2, hidden_size],
'sequence_length': LOOK_BACK, 'forecast_horizon': FORECAST_HORIZON,
'use_optimized': use_optimized
}
}
model_manager.save_model_artifact(metadata, "metadata.json", model_version_path)
# 8. 更新版本文件
model_manager.update_version(model_identifier, version)
model_path = model_manager.save_model(
model_data=model_data,
product_id=product_id,
model_type=model_type_name,
version='v1', # KAN训练器默认使用v1
store_id=store_id,
training_mode=training_mode,
aggregation_method=aggregation_method,
product_name=product_name
)
emit_progress(f"{model_type}模型 v{version} 训练完成!", progress=100, metrics=metrics)
return model, metrics
return model, metrics, version, model_version_path

View File

@ -12,107 +12,42 @@ import torch.optim as optim
from torch.utils.data import DataLoader
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
from tqdm import tqdm
from datetime import datetime
from models.mlstm_model import MLSTMTransformer as MatrixLSTM
from utils.data_utils import create_dataset, PharmacyDataset
from utils.multi_store_data_utils import get_store_product_sales_data, aggregate_multi_store_data
from utils.visualization import plot_loss_curve
from analysis.metrics import evaluate_model
from core.config import (
DEVICE, DEFAULT_MODEL_DIR, LOOK_BACK, FORECAST_HORIZON,
get_next_model_version, get_model_file_path, get_latest_model_version
DEVICE, LOOK_BACK, FORECAST_HORIZON
)
from utils.training_progress import progress_manager
from utils.model_manager import model_manager
from typing import Any
def save_checkpoint(checkpoint_data: dict, epoch_or_label, product_id: str,
model_type: str, model_dir: str, store_id=None,
training_mode: str = 'product', aggregation_method=None):
def convert_numpy_types(obj: Any) -> Any:
"""
保存训练检查点
Args:
checkpoint_data: 检查点数据
epoch_or_label: epoch编号或标签'best'
product_id: 产品ID
model_type: 模型类型
model_dir: 模型保存目录
store_id: 店铺ID
training_mode: 训练模式
aggregation_method: 聚合方法
递归地将字典或列表中的Numpy数值类型转换为Python原生类型
"""
# 创建检查点目录
checkpoint_dir = os.path.join(model_dir, 'checkpoints')
os.makedirs(checkpoint_dir, exist_ok=True)
# 生成检查点文件名
if training_mode == 'store' and store_id:
filename = f"{model_type}_store_{store_id}_{product_id}_epoch_{epoch_or_label}.pth"
elif training_mode == 'global' and aggregation_method:
filename = f"{model_type}_global_{product_id}_{aggregation_method}_epoch_{epoch_or_label}.pth"
else:
filename = f"{model_type}_product_{product_id}_epoch_{epoch_or_label}.pth"
checkpoint_path = os.path.join(checkpoint_dir, filename)
# 保存检查点
torch.save(checkpoint_data, checkpoint_path)
print(f"[mLSTM] 检查点已保存: {checkpoint_path}", flush=True)
return checkpoint_path
def load_checkpoint(product_id: str, model_type: str, epoch_or_label,
model_dir: str, store_id=None, training_mode: str = 'product',
aggregation_method=None):
"""
加载训练检查点
Args:
product_id: 产品ID
model_type: 模型类型
epoch_or_label: epoch编号或标签
model_dir: 模型保存目录
store_id: 店铺ID
training_mode: 训练模式
aggregation_method: 聚合方法
Returns:
checkpoint_data: 检查点数据如果未找到返回None
"""
checkpoint_dir = os.path.join(model_dir, 'checkpoints')
# 生成检查点文件名
if training_mode == 'store' and store_id:
filename = f"{model_type}_store_{store_id}_{product_id}_epoch_{epoch_or_label}.pth"
elif training_mode == 'global' and aggregation_method:
filename = f"{model_type}_global_{product_id}_{aggregation_method}_epoch_{epoch_or_label}.pth"
else:
filename = f"{model_type}_product_{product_id}_epoch_{epoch_or_label}.pth"
checkpoint_path = os.path.join(checkpoint_dir, filename)
if os.path.exists(checkpoint_path):
try:
checkpoint_data = torch.load(checkpoint_path, map_location=DEVICE)
print(f"[mLSTM] 检查点已加载: {checkpoint_path}", flush=True)
return checkpoint_data
except Exception as e:
print(f"[mLSTM] 加载检查点失败: {e}", flush=True)
return None
else:
print(f"[mLSTM] 检查点文件不存在: {checkpoint_path}", flush=True)
return None
if isinstance(obj, dict):
return {k: convert_numpy_types(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_numpy_types(elem) for elem in obj]
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
return obj
def train_product_model_with_mlstm(
product_id,
product_df,
product_id=None,
store_id=None,
training_mode='product',
aggregation_method='sum',
scope=None,
epochs=50,
model_dir=DEFAULT_MODEL_DIR,
version=None,
socketio=None,
task_id=None,
continue_training=False,
@ -123,8 +58,7 @@ def train_product_model_with_mlstm(
):
"""
使用mLSTM训练产品销售预测模型
参数:
参数:
product_id: 产品ID
store_id: 店铺ID为None时使用全局数据
training_mode: 训练模式 ('product', 'store', 'global')
@ -139,6 +73,7 @@ def train_product_model_with_mlstm(
"""
# 创建WebSocket进度反馈函数支持多进程
def emit_progress(message, progress=None, metrics=None):
"""发送训练进度到前端"""
progress_data = {
@ -151,14 +86,12 @@ def train_product_model_with_mlstm(
if metrics is not None:
progress_data['metrics'] = metrics
# 在多进程环境中使用progress_callback
if progress_callback:
try:
progress_callback(progress_data)
except Exception as e:
print(f"[mLSTM] 进度回调失败: {e}")
# 在单进程环境中使用socketio
if socketio and task_id:
try:
socketio.emit('training_progress', progress_data, namespace='/training')
@ -166,81 +99,79 @@ def train_product_model_with_mlstm(
print(f"[mLSTM] WebSocket发送失败: {e}")
print(f"[mLSTM] {message}", flush=True)
# 强制刷新输出缓冲区
import sys
sys.stdout.flush()
sys.stderr.flush()
emit_progress("开始mLSTM模型训练...")
# 确定版本号
if version is None:
if continue_training:
version = get_latest_model_version(product_id, 'mlstm')
if version is None:
version = get_next_model_version(product_id, 'mlstm')
else:
version = get_next_model_version(product_id, 'mlstm')
# 1. 确定模型标识符和版本
model_type = 'mlstm'
emit_progress(f"开始训练 mLSTM 模型版本 {version}")
# 初始化训练进度管理器(如果还未初始化)
if socketio and task_id:
print(f"[mLSTM] 任务 {task_id}: 开始mLSTM训练器", flush=True)
try:
# 初始化进度管理器
if not hasattr(progress_manager, 'training_id') or progress_manager.training_id != task_id:
progress_manager.start_training(
training_id=task_id,
product_id=product_id,
model_type='mlstm',
training_mode=training_mode,
total_epochs=epochs,
total_batches=0, # 将在后面设置
batch_size=32, # 默认值
total_samples=0 # 将在后面设置
)
print(f"[mLSTM] 任务 {task_id}: 进度管理器已初始化", flush=True)
else:
print(f"[mLSTM] 任务 {task_id}: 使用现有进度管理器", flush=True)
except Exception as e:
print(f"[mLSTM] 任务 {task_id}: 进度管理器初始化失败: {e}", flush=True)
# 直接使用从 predictor 传递过来的、已经构建好的 scope
if scope is None:
# 作为后备如果scope未提供则根据旧逻辑构建不推荐
if training_mode == 'store':
current_product_id = product_id if product_id and product_id not in ['unknown', 'all'] else 'all'
scope = f"{store_id}_{current_product_id}"
elif training_mode == 'product':
scope = f"{product_id}_{store_id or 'all'}"
elif training_mode == 'global':
scope = product_id if product_id else "all"
emit_progress(f"警告: Scope未由调用方提供已自动构建为 '{scope}'", 'warning')
# 数据现在由调用方传入,不再在此处加载
if training_mode == 'store' and store_id:
model_identifier = model_manager.get_model_identifier(model_type, training_mode, scope, aggregation_method)
version = model_manager.get_next_version_number(model_identifier)
emit_progress(f"开始训练 mLSTM 模型 v{version}")
# 2. 获取模型版本路径
model_version_path = model_manager.get_model_version_path(
model_type=model_type,
training_mode=training_mode,
scope=scope,
version=version,
aggregation_method=aggregation_method
)
emit_progress(f"模型将保存到: {model_version_path}")
# 根据训练模式和参数动态生成更详细的描述
if training_mode == 'store':
training_scope = f"店铺 {store_id}"
if scope and 'specific' in scope:
training_scope += " (指定药品)"
else:
training_scope += " (所有药品)"
elif training_mode == 'global':
training_scope = f"全局聚合({aggregation_method})"
else:
training_scope = "所有店铺"
else: # product 模式
training_scope = f"药品 {product_id}"
if scope and 'specific' in scope:
training_scope += " (指定店铺)"
elif store_id:
training_scope += f" (店铺 {store_id})"
else:
training_scope += " (所有店铺)"
# 数据量检查
min_required_samples = LOOK_BACK + FORECAST_HORIZON
if len(product_df) < min_required_samples:
error_msg = (
f"❌ 训练数据不足错误\n"
f"当前配置需要: {min_required_samples} 天数据 (LOOK_BACK={LOOK_BACK} + FORECAST_HORIZON={FORECAST_HORIZON})\n"
f"实际数据量: {len(product_df)}\n"
f"产品ID: {product_id}, 训练模式: {training_mode}\n"
f"建议解决方案:\n"
f"1. 生成更多数据: uv run generate_multi_store_data.py\n"
f"2. 调整配置参数: 减小 LOOK_BACK 或 FORECAST_HORIZON\n"
f"3. 使用全局训练模式聚合更多数据"
)
error_msg = f"数据不足: 需要 {min_required_samples} 天, 实际 {len(product_df)} 天。"
print(error_msg)
emit_progress(f"训练失败:数据不足 ({len(product_df)}/{min_required_samples} 天)")
emit_progress(f"训练失败:{error_msg}")
raise ValueError(error_msg)
product_name = product_df['product_name'].iloc[0]
print(f"[mLSTM] 使用mLSTM模型训练产品 '{product_name}' (ID: {product_id}) 的销售预测模型", flush=True)
if product_id:
product_name = product_df['product_name'].iloc[0]
else:
product_name = f"Aggregated Model ({training_mode}/{scope})"
print_product_id = product_id if product_id else "N/A"
print(f"[mLSTM] 使用mLSTM模型训练 '{product_name}' (ID: {print_product_id}) 的销售预测模型", flush=True)
print(f"[mLSTM] 训练范围: {training_scope}", flush=True)
print(f"[mLSTM] 版本: {version}", flush=True)
print(f"[mLSTM] 版本: v{version}", flush=True)
print(f"[mLSTM] 使用设备: {DEVICE}", flush=True)
print(f"[mLSTM] 模型将保存到目录: {model_dir}", flush=True)
print(f"[mLSTM] 数据量: {len(product_df)} 条记录", flush=True)
emit_progress(f"训练产品: {product_name} (ID: {product_id}) - {training_scope}")
emit_progress(f"训练产品: {product_name} (ID: {print_product_id}) - {training_scope}")
# 创建特征和目标变量
features = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
@ -249,283 +180,138 @@ def train_product_model_with_mlstm(
# 预处理数据
X = product_df[features].values
y = product_df[['sales']].values # 保持为二维数组
y = product_df[['sales']].values
print(f"[mLSTM] 特征矩阵形状: {X.shape}, 目标矩阵形状: {y.shape}", flush=True)
emit_progress("数据预处理中...")
# 归一化数据
scaler_X = MinMaxScaler(feature_range=(0, 1))
scaler_y = MinMaxScaler(feature_range=(0, 1))
X_scaled = scaler_X.fit_transform(X)
y_scaled = scaler_y.fit_transform(y)
print(f"[mLSTM] 数据归一化完成", flush=True)
# 划分训练集和测试集80% 训练20% 测试)
train_size = int(len(X_scaled) * 0.8)
X_train, X_test = X_scaled[:train_size], X_scaled[train_size:]
y_train, y_test = y_scaled[:train_size], y_scaled[train_size:]
# 创建时间序列数据
trainX, trainY = create_dataset(X_train, y_train, LOOK_BACK, FORECAST_HORIZON)
testX, testY = create_dataset(X_test, y_test, LOOK_BACK, FORECAST_HORIZON)
# 转换为PyTorch的Tensor
trainX_tensor = torch.Tensor(trainX)
trainY_tensor = torch.Tensor(trainY)
testX_tensor = torch.Tensor(testX)
testY_tensor = torch.Tensor(testY)
train_loader = DataLoader(PharmacyDataset(torch.Tensor(trainX), torch.Tensor(trainY)), batch_size=32, shuffle=True)
test_loader = DataLoader(PharmacyDataset(torch.Tensor(testX), torch.Tensor(testY)), batch_size=32, shuffle=False)
# 创建数据加载器
train_dataset = PharmacyDataset(trainX_tensor, trainY_tensor)
test_dataset = PharmacyDataset(testX_tensor, testY_tensor)
batch_size = 32
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 更新进度管理器的批次信息
total_batches = len(train_loader)
total_samples = len(train_dataset)
total_samples = len(train_loader.dataset)
print(f"[mLSTM] 数据加载器创建完成 - 批次数: {total_batches}, 样本数: {total_samples}", flush=True)
emit_progress(f"数据加载器准备完成 - 批次数: {total_batches}, 样本数: {total_samples}")
# 初始化mLSTM结合Transformer模型
input_dim = X_train.shape[1]
output_dim = FORECAST_HORIZON
hidden_size = 128
num_heads = 4
dropout_rate = 0.1
num_blocks = 3
embed_dim = 32
dense_dim = 32
print(f"[mLSTM] 初始化模型 - 输入维度: {input_dim}, 输出维度: {output_dim}", flush=True)
print(f"[mLSTM] 模型参数 - 隐藏层: {hidden_size}, 注意力头: {num_heads}", flush=True)
emit_progress(f"初始化mLSTM模型 - 输入维度: {input_dim}, 隐藏层: {hidden_size}")
hidden_size, num_heads, dropout_rate, num_blocks, embed_dim, dense_dim = 128, 4, 0.1, 3, 32, 32
model = MatrixLSTM(
num_features=input_dim,
hidden_size=hidden_size,
mlstm_layers=2,
embed_dim=embed_dim,
dense_dim=dense_dim,
num_heads=num_heads,
dropout_rate=dropout_rate,
num_blocks=num_blocks,
output_sequence_length=output_dim
)
num_features=input_dim, hidden_size=hidden_size, mlstm_layers=2, embed_dim=embed_dim,
dense_dim=dense_dim, num_heads=num_heads, dropout_rate=dropout_rate,
num_blocks=num_blocks, output_sequence_length=output_dim
).to(DEVICE)
print(f"[mLSTM] 模型创建完成", flush=True)
emit_progress("mLSTM模型初始化完成")
# 如果是继续训练,加载现有模型
if continue_training and version != 'v1':
try:
existing_model_path = get_model_file_path(product_id, 'mlstm', version)
if os.path.exists(existing_model_path):
checkpoint = torch.load(existing_model_path, map_location=DEVICE)
model.load_state_dict(checkpoint['model_state_dict'])
print(f"加载现有模型: {existing_model_path}")
emit_progress(f"加载现有模型版本 {version} 进行继续训练")
except Exception as e:
print(f"无法加载现有模型,将重新开始训练: {e}")
emit_progress("无法加载现有模型,重新开始训练")
# 将模型移动到设备上
model = model.to(DEVICE)
if continue_training:
emit_progress("继续训练模式启动,但当前重构版本将从头开始。")
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min', patience=patience // 2, factor=0.5)
emit_progress("数据预处理完成,开始模型训练...", progress=10)
# 训练模型
train_losses = []
test_losses = []
train_losses, test_losses = [], []
start_time = time.time()
# 配置检查点保存
checkpoint_interval = max(1, epochs // 10) # 每10%进度保存一次最少每1个epoch
checkpoint_interval = max(1, epochs // 10)
best_loss = float('inf')
epochs_no_improve = 0
emit_progress(f"开始训练 - 总epoch: {epochs}, 检查点间隔: {checkpoint_interval}, 耐心值: {patience}")
for epoch in range(epochs):
emit_progress(f"开始训练 Epoch {epoch+1}/{epochs}")
model.train()
epoch_loss = 0
for batch_idx, (X_batch, y_batch) in enumerate(train_loader):
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
# 确保目标张量有正确的形状
# 前向传播
optimizer.zero_grad()
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
# 反向传播和优化
optimizer.zero_grad()
loss.backward()
if clip_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip_norm)
optimizer.step()
epoch_loss += loss.item()
# 计算训练损失
train_loss = epoch_loss / len(train_loader)
train_losses.append(train_loss)
# 在测试集上评估
model.eval()
test_loss = 0
with torch.no_grad():
for batch_idx, (X_batch, y_batch) in enumerate(test_loader):
for X_batch, y_batch in test_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
test_loss += loss.item()
test_loss = test_loss / len(test_loader)
test_loss /= len(test_loader)
test_losses.append(test_loss)
# 更新学习率
scheduler.step(test_loss)
# 计算总体训练进度
epoch_progress = ((epoch + 1) / epochs) * 90 + 10 # 10-100% 范围
# 发送训练进度
current_metrics = {
'train_loss': train_loss,
'test_loss': test_loss,
'epoch': epoch + 1,
'total_epochs': epochs,
'learning_rate': optimizer.param_groups[0]['lr']
}
emit_progress(f"Epoch {epoch+1}/{epochs} 完成 - Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}",
progress=epoch_progress, metrics=current_metrics)
progress=10 + ((epoch + 1) / epochs) * 85)
# 定期保存检查点
if (epoch + 1) % checkpoint_interval == 0 or epoch == epochs - 1:
checkpoint_data = {
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_loss,
'test_loss': test_loss,
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_heads': num_heads,
'dropout': dropout_rate,
'num_blocks': num_blocks,
'embed_dim': embed_dim,
'dense_dim': dense_dim,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'mlstm'
},
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'training_scope': training_scope,
'timestamp': time.time()
}
}
# 保存检查点
save_checkpoint(checkpoint_data, epoch + 1, product_id, 'mlstm',
model_dir, store_id, training_mode, aggregation_method)
# 如果是最佳模型,额外保存一份
if test_loss < best_loss:
best_loss = test_loss
save_checkpoint(checkpoint_data, 'best', product_id, 'mlstm',
model_dir, store_id, training_mode, aggregation_method)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
epochs_no_improve = 0
else:
epochs_no_improve += 1
# 3. 保存检查点
checkpoint_data = {
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scaler_X': scaler_X,
'scaler_y': scaler_y,
}
if (epoch + 1) % checkpoint_interval == 0:
model_manager.save_model_artifact(checkpoint_data, f"checkpoint_epoch_{epoch+1}.pth", model_version_path)
emit_progress(f"💾 保存训练检查点 epoch_{epoch+1}")
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}", flush=True)
# 提前停止逻辑
if test_loss < best_loss:
best_loss = test_loss
model_manager.save_model_artifact(checkpoint_data, "checkpoint_best.pth", model_version_path)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
epochs_no_improve = 0
else:
epochs_no_improve += 1
if epochs_no_improve >= patience:
emit_progress(f"连续 {patience} 个epoch测试损失未改善提前停止训练。")
break
# 计算训练时间
training_time = time.time() - start_time
emit_progress("生成损失曲线...", progress=95)
# 确定模型保存目录(支持多店铺)
if store_id:
# 为特定店铺创建子目录
store_model_dir = os.path.join(model_dir, 'mlstm', store_id)
os.makedirs(store_model_dir, exist_ok=True)
loss_curve_filename = f"{product_id}_mlstm_{version}_loss_curve.png"
loss_curve_path = os.path.join(store_model_dir, loss_curve_filename)
else:
# 全局模型保存在global目录
global_model_dir = os.path.join(model_dir, 'mlstm', 'global')
os.makedirs(global_model_dir, exist_ok=True)
loss_curve_filename = f"{product_id}_mlstm_{version}_global_loss_curve.png"
loss_curve_path = os.path.join(global_model_dir, loss_curve_filename)
# 绘制损失曲线并保存到模型目录
plt.figure(figsize=(10, 6))
loss_fig = plt.figure(figsize=(10, 6))
plt.plot(train_losses, label='Training Loss')
plt.plot(test_losses, label='Test Loss')
title_suffix = f" - {training_scope}" if store_id else " - 全局模型"
plt.title(f'mLSTM 模型训练损失曲线 - {product_name} ({version}){title_suffix}')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.savefig(loss_curve_path, dpi=300, bbox_inches='tight')
plt.close()
plt.title(f'mLSTM 损失曲线 - {product_name} (v{version}) - {training_scope}')
plt.xlabel('Epoch'); plt.ylabel('Loss'); plt.legend(); plt.grid(True)
model_manager.save_model_artifact(loss_fig, "loss_curve.png", model_version_path)
plt.close(loss_fig)
print(f"损失曲线已保存到: {os.path.join(model_version_path, 'loss_curve.png')}")
print(f"损失曲线已保存到: {loss_curve_path}")
emit_progress("模型评估中...", progress=98)
# 评估模型
model.eval()
with torch.no_grad():
test_pred = model(testX_tensor.to(DEVICE)).cpu().numpy()
test_true = testY
# 反归一化预测结果和真实值
test_pred_inv = scaler_y.inverse_transform(test_pred)
test_true_inv = scaler_y.inverse_transform(test_true)
test_pred = model(torch.Tensor(testX).to(DEVICE)).cpu().numpy()
# 计算评估指标
metrics = evaluate_model(test_true_inv, test_pred_inv)
metrics = evaluate_model(scaler_y.inverse_transform(testY), scaler_y.inverse_transform(test_pred))
metrics['training_time'] = training_time
metrics['version'] = version
# 解决 'Object of type float32 is not JSON serializable' 错误
metrics = convert_numpy_types(metrics)
# 打印评估指标
print("\n模型评估指标:")
print(f"MSE: {metrics['mse']:.4f}")
@ -534,65 +320,33 @@ def train_product_model_with_mlstm(
print(f"R²: {metrics['r2']:.4f}")
print(f"MAPE: {metrics['mape']:.2f}%")
print(f"训练时间: {training_time:.2f}")
emit_progress("保存最终模型...", progress=99)
# 保存最终训练完成的模型基于最终epoch
final_model_data = {
'epoch': epochs, # 最终epoch
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_losses[-1],
'test_loss': test_losses[-1],
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
}
model_manager.save_model_artifact(final_model_data, "model.pth", model_version_path)
metadata = {
'product_id': product_id if product_id else scope, 'product_name': product_name, 'model_type': model_type,
'version': f'v{version}', 'training_mode': training_mode, 'scope': scope,
'aggregation_method': aggregation_method, 'training_scope_description': training_scope,
'product_scope': '所有药品' if not product_id or product_id == 'all' else product_name,
'timestamp': datetime.now().isoformat(), 'metrics': metrics,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_heads': num_heads,
'dropout': dropout_rate,
'num_blocks': num_blocks,
'embed_dim': embed_dim,
'dense_dim': dense_dim,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'mlstm'
},
'metrics': metrics,
'loss_curve_path': loss_curve_path,
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'training_scope': training_scope,
'timestamp': time.time(),
'training_completed': True
'input_dim': input_dim, 'output_dim': output_dim, 'hidden_size': hidden_size,
'num_heads': num_heads, 'dropout': dropout_rate, 'num_blocks': num_blocks,
'embed_dim': embed_dim, 'dense_dim': dense_dim,
'sequence_length': LOOK_BACK, 'forecast_horizon': FORECAST_HORIZON,
}
}
model_manager.save_model_artifact(metadata, "metadata.json", model_version_path)
# 6. 更新版本文件
model_manager.update_version(model_identifier, version)
# 保存最终模型使用epoch标识
final_model_path = save_checkpoint(
final_model_data, f"final_epoch_{epochs}", product_id, 'mlstm',
model_dir, store_id, training_mode, aggregation_method
)
emit_progress(f"✅ mLSTM模型 v{version} 训练完成!", progress=100, metrics=metrics)
# 发送训练完成消息
final_metrics = {
'mse': metrics['mse'],
'rmse': metrics['rmse'],
'mae': metrics['mae'],
'r2': metrics['r2'],
'mape': metrics['mape'],
'training_time': training_time,
'final_epoch': epochs,
'model_path': final_model_path
}
emit_progress(f"✅ mLSTM模型训练完成最终epoch: {epochs} 已保存", progress=100, metrics=final_metrics)
return model, metrics, epochs, final_model_path
return model, metrics, version, model_version_path

View File

@ -1,5 +1,5 @@
"""
药店销售预测系统 - TCN模型训练函数
药店销售预测系统 - TCN模型训练函数 (已重构)
"""
import os
@ -12,233 +12,162 @@ import torch.optim as optim
from torch.utils.data import DataLoader
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
from tqdm import tqdm
from datetime import datetime
from models.tcn_model import TCNForecaster
from utils.data_utils import create_dataset, PharmacyDataset
from utils.visualization import plot_loss_curve
from analysis.metrics import evaluate_model
from core.config import DEVICE, DEFAULT_MODEL_DIR, LOOK_BACK, FORECAST_HORIZON
from utils.training_progress import progress_manager
from core.config import DEVICE, LOOK_BACK, FORECAST_HORIZON
from utils.model_manager import model_manager
from typing import Any
def save_checkpoint(checkpoint_data: dict, epoch_or_label, product_id: str,
model_type: str, model_dir: str, store_id=None,
training_mode: str = 'product', aggregation_method=None):
def convert_numpy_types(obj: Any) -> Any:
"""
保存训练检查点
Args:
checkpoint_data: 检查点数据
epoch_or_label: epoch编号或标签'best'
product_id: 产品ID
model_type: 模型类型
model_dir: 模型保存目录
store_id: 店铺ID
training_mode: 训练模式
aggregation_method: 聚合方法
递归地将字典或列表中的Numpy数值类型转换为Python原生类型
"""
# 创建检查点目录
checkpoint_dir = os.path.join(model_dir, 'checkpoints')
os.makedirs(checkpoint_dir, exist_ok=True)
# 生成检查点文件名
if training_mode == 'store' and store_id:
filename = f"{model_type}_store_{store_id}_{product_id}_epoch_{epoch_or_label}.pth"
elif training_mode == 'global' and aggregation_method:
filename = f"{model_type}_global_{product_id}_{aggregation_method}_epoch_{epoch_or_label}.pth"
else:
filename = f"{model_type}_product_{product_id}_epoch_{epoch_or_label}.pth"
checkpoint_path = os.path.join(checkpoint_dir, filename)
# 保存检查点
torch.save(checkpoint_data, checkpoint_path)
print(f"[TCN] 检查点已保存: {checkpoint_path}", flush=True)
return checkpoint_path
if isinstance(obj, dict):
return {k: convert_numpy_types(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_numpy_types(elem) for elem in obj]
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
return obj
def train_product_model_with_tcn(
product_id,
product_df=None,
product_df,
product_id=None,
store_id=None,
training_mode='product',
aggregation_method='sum',
scope=None,
epochs=50,
model_dir=DEFAULT_MODEL_DIR,
version=None,
socketio=None,
task_id=None,
continue_training=False
progress_callback=None,
patience=10,
learning_rate=0.001
):
"""
使用TCN模型训练产品销售预测模型
参数:
product_id: 产品ID
epochs: 训练轮次
model_dir: 模型保存目录默认使用配置中的DEFAULT_MODEL_DIR
version: 指定版本号如果为None则自动生成
socketio: WebSocket对象用于实时反馈
task_id: 训练任务ID
continue_training: 是否继续训练现有模型
返回:
model: 训练好的模型
metrics: 模型评估指标
version: 实际使用的版本号
model_path: 模型文件路径
使用TCN模型训练产品销售预测模型 (已适配新的ModelManager)
"""
def emit_progress(message, progress=None, metrics=None):
"""发送训练进度到前端"""
progress_data = {
'task_id': task_id,
'message': f"[TCN] {message}",
'timestamp': time.time()
}
if progress is not None:
progress_data['progress'] = progress
if metrics is not None:
progress_data['metrics'] = metrics
if progress_callback:
try:
progress_callback(progress_data)
except Exception as e:
print(f"[TCN] 进度回调失败: {e}")
if socketio and task_id:
data = {
'task_id': task_id,
'message': message,
'timestamp': time.time()
}
if progress is not None:
data['progress'] = progress
if metrics is not None:
data['metrics'] = metrics
socketio.emit('training_progress', data, namespace='/training')
try:
socketio.emit('training_progress', progress_data, namespace='/training')
except Exception as e:
print(f"[TCN] WebSocket发送失败: {e}")
print(f"[TCN] {message}", flush=True)
emit_progress("开始TCN模型训练...")
# 1. 确定模型标识符和版本
model_type = 'tcn'
# 直接使用从 predictor 传递过来的、已经构建好的 scope
if scope is None:
# 作为后备如果scope未提供则根据旧逻辑构建不推荐
if training_mode == 'store':
current_product_id = product_id if product_id and product_id not in ['unknown', 'all'] else 'all'
scope = f"{store_id}_{current_product_id}"
elif training_mode == 'product':
scope = f"{product_id}_{store_id or 'all'}"
elif training_mode == 'global':
scope = product_id if product_id else "all"
emit_progress(f"警告: Scope未由调用方提供已自动构建为 '{scope}'", 'warning')
model_identifier = model_manager.get_model_identifier(model_type, training_mode, scope, aggregation_method)
version = model_manager.get_next_version_number(model_identifier)
# 确定版本号
if version is None:
from core.config import get_latest_model_version, get_next_model_version
if continue_training:
version = get_latest_model_version(product_id, 'tcn')
if version is None:
version = get_next_model_version(product_id, 'tcn')
else:
version = get_next_model_version(product_id, 'tcn')
emit_progress(f"开始训练 TCN 模型版本 {version}")
# 如果没有传入product_df则根据训练模式加载数据
emit_progress(f"开始训练 {model_type} 模型 v{version}")
# 2. 获取模型版本路径
model_version_path = model_manager.get_model_version_path(
model_type=model_type,
training_mode=training_mode,
scope=scope,
version=version,
aggregation_method=aggregation_method
)
emit_progress(f"模型将保存到: {model_version_path}")
# 3. 数据加载和预处理
if product_df is None:
from utils.multi_store_data_utils import load_multi_store_data, get_store_product_sales_data, aggregate_multi_store_data
try:
if training_mode == 'store' and store_id:
# 加载特定店铺的数据
product_df = get_store_product_sales_data(
store_id,
product_id,
'pharmacy_sales_multi_store.csv'
)
training_scope = f"店铺 {store_id}"
product_df = get_store_product_sales_data(store_id, product_id, 'pharmacy_sales_multi_store.csv')
elif training_mode == 'global':
# 聚合所有店铺的数据
product_df = aggregate_multi_store_data(
product_id,
aggregation_method=aggregation_method,
file_path='pharmacy_sales_multi_store.csv'
)
training_scope = f"全局聚合({aggregation_method})"
product_df = aggregate_multi_store_data(product_id, aggregation_method=aggregation_method, file_path='pharmacy_sales_multi_store.csv')
else:
# 默认:加载所有店铺的产品数据
product_df = load_multi_store_data('pharmacy_sales_multi_store.csv', product_id=product_id)
training_scope = "所有店铺"
except Exception as e:
print(f"多店铺数据加载失败: {e}")
# 后备方案:尝试原始数据
emit_progress(f"多店铺数据加载失败: {e}, 尝试后备方案...")
df = pd.read_excel('pharmacy_sales.xlsx')
product_df = df[df['product_id'] == product_id].sort_values('date')
training_scope = "原始数据"
else:
# 如果传入了product_df直接使用
if training_mode == 'store' and store_id:
training_scope = f"店铺 {store_id}"
elif training_mode == 'global':
training_scope = f"全局聚合({aggregation_method})"
else:
training_scope = "所有店铺"
if product_df.empty:
raise ValueError(f"产品 {product_id} 没有可用的销售数据")
# 数据量检查
# 构建一个更通用的训练描述
training_description = f"模式: {training_mode}, 范围: {scope}"
if aggregation_method and aggregation_method != 'none':
training_description += f", 聚合: {aggregation_method}"
min_required_samples = LOOK_BACK + FORECAST_HORIZON
if len(product_df) < min_required_samples:
error_msg = (
f"❌ 训练数据不足错误\n"
f"当前配置需要: {min_required_samples} 天数据 (LOOK_BACK={LOOK_BACK} + FORECAST_HORIZON={FORECAST_HORIZON})\n"
f"实际数据量: {len(product_df)}\n"
f"产品ID: {product_id}, 训练模式: {training_mode}\n"
f"建议解决方案:\n"
f"1. 生成更多数据: uv run generate_multi_store_data.py\n"
f"2. 调整配置参数: 减小 LOOK_BACK 或 FORECAST_HORIZON\n"
f"3. 使用全局训练模式聚合更多数据"
)
print(error_msg)
emit_progress(f"训练失败:数据不足 ({len(product_df)}/{min_required_samples} 天)")
error_msg = f"数据不足: 需要 {min_required_samples} 天, 实际 {len(product_df)} 天。"
emit_progress(f"训练失败:{error_msg}")
raise ValueError(error_msg)
product_df = product_df.sort_values('date')
product_name = product_df['product_name'].iloc[0]
if product_id:
product_name = product_df['product_name'].iloc[0]
else:
product_name = f"Aggregated Model ({training_mode}/{scope})"
print(f"使用TCN模型训练产品 '{product_name}' (ID: {product_id}) 的销售预测模型")
print(f"训练范围: {training_scope}")
print(f"版本: {version}")
print(f"使用设备: {DEVICE}")
print(f"模型将保存到目录: {model_dir}")
emit_progress(f"训练产品: {product_name} (ID: {product_id})")
# 创建特征和目标变量
print_product_id = product_id if product_id else "N/A"
emit_progress(f"开始训练. 描述: {training_description}")
emit_progress(f"使用设备: {DEVICE}, 数据量: {len(product_df)}")
features = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
# 预处理数据
X = product_df[features].values
y = product_df[['sales']].values # 保持为二维数组
y = product_df[['sales']].values
# 设置数据预处理阶段
progress_manager.set_stage("data_preprocessing", 0)
emit_progress("数据预处理中...")
# 归一化数据
scaler_X = MinMaxScaler(feature_range=(0, 1))
scaler_y = MinMaxScaler(feature_range=(0, 1))
X_scaled = scaler_X.fit_transform(X)
y_scaled = scaler_y.fit_transform(y)
# 划分训练集和测试集80% 训练20% 测试)
train_size = int(len(X_scaled) * 0.8)
X_train, X_test = X_scaled[:train_size], X_scaled[train_size:]
y_train, y_test = y_scaled[:train_size], y_scaled[train_size:]
progress_manager.set_stage("data_preprocessing", 50)
# 创建时间序列数据
trainX, trainY = create_dataset(X_train, y_train, LOOK_BACK, FORECAST_HORIZON)
testX, testY = create_dataset(X_test, y_test, LOOK_BACK, FORECAST_HORIZON)
# 转换为PyTorch的Tensor
trainX_tensor = torch.Tensor(trainX)
trainY_tensor = torch.Tensor(trainY)
testX_tensor = torch.Tensor(testX)
testY_tensor = torch.Tensor(testY)
train_loader = DataLoader(PharmacyDataset(torch.Tensor(trainX), torch.Tensor(trainY)), batch_size=32, shuffle=True)
test_loader = DataLoader(PharmacyDataset(torch.Tensor(testX), torch.Tensor(testY)), batch_size=32, shuffle=False)
# 创建数据加载器
train_dataset = PharmacyDataset(trainX_tensor, trainY_tensor)
test_dataset = PharmacyDataset(testX_tensor, testY_tensor)
batch_size = 32
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 更新进度管理器的批次信息
total_batches = len(train_loader)
total_samples = len(train_dataset)
progress_manager.total_batches_per_epoch = total_batches
progress_manager.batch_size = batch_size
progress_manager.total_samples = total_samples
progress_manager.set_stage("data_preprocessing", 100)
# 初始化TCN模型
# 4. 模型初始化
input_dim = X_train.shape[1]
output_dim = FORECAST_HORIZON
hidden_size = 64
@ -252,265 +181,124 @@ def train_product_model_with_tcn(
num_channels=[hidden_size] * num_layers,
kernel_size=kernel_size,
dropout=dropout_rate
)
# 如果是继续训练,加载现有模型
if continue_training and version != 'v1':
try:
from core.config import get_model_file_path
existing_model_path = get_model_file_path(product_id, 'tcn', version)
if os.path.exists(existing_model_path):
checkpoint = torch.load(existing_model_path, map_location=DEVICE)
model.load_state_dict(checkpoint['model_state_dict'])
print(f"加载现有模型: {existing_model_path}")
emit_progress(f"加载现有模型版本 {version} 进行继续训练")
except Exception as e:
print(f"无法加载现有模型,将重新开始训练: {e}")
emit_progress("无法加载现有模型,重新开始训练")
# 将模型移动到设备上
model = model.to(DEVICE)
).to(DEVICE)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
emit_progress("开始模型训练...")
# 训练模型
train_losses = []
test_losses = []
emit_progress("数据预处理完成,开始模型训练...", progress=10)
# 5. 训练循环
train_losses, test_losses = [], []
start_time = time.time()
# 配置检查点保存
checkpoint_interval = max(1, epochs // 10) # 每10%进度保存一次最少每1个epoch
best_loss = float('inf')
progress_manager.set_stage("model_training", 0)
emit_progress(f"开始训练 - 总epoch: {epochs}, 检查点间隔: {checkpoint_interval}")
epochs_no_improve = 0
for epoch in range(epochs):
# 开始新的轮次
progress_manager.start_epoch(epoch)
model.train()
epoch_loss = 0
for batch_idx, (X_batch, y_batch) in enumerate(train_loader):
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
if y_batch.dim() == 2: y_batch = y_batch.unsqueeze(-1)
# 确保目标张量有正确的形状 (batch_size, forecast_horizon, 1)
if y_batch.dim() == 2:
y_batch = y_batch.unsqueeze(-1)
# 前向传播
outputs = model(X_batch)
# 确保输出和目标形状匹配
loss = criterion(outputs, y_batch)
# 反向传播和优化
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss += loss.item()
# 更新批次进度每10个批次更新一次
if batch_idx % 10 == 0 or batch_idx == len(train_loader) - 1:
current_lr = optimizer.param_groups[0]['lr']
progress_manager.update_batch(batch_idx, loss.item(), current_lr)
# 计算训练损失
train_loss = epoch_loss / len(train_loader)
train_losses.append(train_loss)
# 设置验证阶段
progress_manager.set_stage("validation", 0)
# 在测试集上评估
model.eval()
test_loss = 0
with torch.no_grad():
for batch_idx, (X_batch, y_batch) in enumerate(test_loader):
for X_batch, y_batch in test_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
# 确保目标张量有正确的形状
if y_batch.dim() == 2:
y_batch = y_batch.unsqueeze(-1)
if y_batch.dim() == 2: y_batch = y_batch.unsqueeze(-1)
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
test_loss += loss.item()
# 更新验证进度
if batch_idx % 5 == 0 or batch_idx == len(test_loader) - 1:
val_progress = (batch_idx / len(test_loader)) * 100
progress_manager.set_stage("validation", val_progress)
test_loss = test_loss / len(test_loader)
test_loss /= len(test_loader)
test_losses.append(test_loss)
# 完成当前轮次
progress_manager.finish_epoch(train_loss, test_loss)
# 发送训练进度(保持与旧系统的兼容性)
if (epoch + 1) % 5 == 0 or epoch == epochs - 1:
progress = ((epoch + 1) / epochs) * 100
current_metrics = {
'train_loss': train_loss,
'test_loss': test_loss,
'epoch': epoch + 1,
'total_epochs': epochs
}
emit_progress(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}",
progress=progress, metrics=current_metrics)
# 定期保存检查点
if (epoch + 1) % checkpoint_interval == 0 or epoch == epochs - 1:
progress_percentage = 10 + ((epoch + 1) / epochs) * 85
emit_progress(f"Epoch {epoch+1}/{epochs} - Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}", progress=progress_percentage)
if test_loss < best_loss:
best_loss = test_loss
epochs_no_improve = 0
checkpoint_data = {
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_loss,
'test_loss': test_loss,
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_layers': num_layers,
'dropout': dropout_rate,
'kernel_size': kernel_size,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'tcn'
},
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'timestamp': time.time()
}
}
# 保存检查点
save_checkpoint(checkpoint_data, epoch + 1, product_id, 'tcn',
model_dir, store_id, training_mode, aggregation_method)
# 如果是最佳模型,额外保存一份
if test_loss < best_loss:
best_loss = test_loss
save_checkpoint(checkpoint_data, 'best', product_id, 'tcn',
model_dir, store_id, training_mode, aggregation_method)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
emit_progress(f"💾 保存训练检查点 epoch_{epoch+1}")
model_manager.save_model_artifact(checkpoint_data, "checkpoint_best.pth", model_version_path)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
else:
epochs_no_improve += 1
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}")
# 计算训练时间
if epochs_no_improve >= patience:
emit_progress(f"连续 {patience} 个epoch测试损失未改善提前停止训练。")
break
training_time = time.time() - start_time
# 设置模型保存阶段
progress_manager.set_stage("model_saving", 0)
emit_progress("训练完成,正在保存模型...")
# 绘制损失曲线并保存到模型目录
loss_curve_path = plot_loss_curve(
train_losses,
test_losses,
product_name,
'TCN',
model_dir=model_dir
)
print(f"损失曲线已保存到: {loss_curve_path}")
# 评估模型
# 6. 保存产物和评估
loss_fig = plt.figure(figsize=(10, 6))
plt.plot(train_losses, label='Training Loss')
plt.plot(test_losses, label='Test Loss')
plt.title(f'{model_type.upper()} 损失曲线 - {training_description} (v{version})')
plt.xlabel('Epoch'); plt.ylabel('Loss'); plt.legend(); plt.grid(True)
model_manager.save_model_artifact(loss_fig, "loss_curve.png", model_version_path)
plt.close(loss_fig)
emit_progress(f"损失曲线已保存到: {os.path.join(model_version_path, 'loss_curve.png')}")
model.eval()
with torch.no_grad():
# 确保测试数据的形状正确
test_pred = model(testX_tensor.to(DEVICE))
# 将输出转换为二维数组 [samples, forecast_horizon]
test_pred = test_pred.squeeze(-1).cpu().numpy()
# 反归一化预测结果和真实值
test_pred_inv = scaler_y.inverse_transform(test_pred.reshape(-1, 1)).flatten()
test_true_inv = scaler_y.inverse_transform(testY.reshape(-1, 1)).flatten()
testX_tensor = torch.Tensor(testX).to(DEVICE)
test_pred = model(testX_tensor).cpu().numpy()
# 计算评估指标
metrics = evaluate_model(test_true_inv, test_pred_inv)
test_pred_inv = scaler_y.inverse_transform(test_pred.reshape(-1, FORECAST_HORIZON))
test_true_inv = scaler_y.inverse_transform(testY.reshape(-1, FORECAST_HORIZON))
metrics = evaluate_model(test_true_inv.flatten(), test_pred_inv.flatten())
metrics['training_time'] = training_time
# 打印评估指标
print("\n模型评估指标:")
print(f"MSE: {metrics['mse']:.4f}")
print(f"RMSE: {metrics['rmse']:.4f}")
print(f"MAE: {metrics['mae']:.4f}")
print(f"R²: {metrics['r2']:.4f}")
print(f"MAPE: {metrics['mape']:.2f}%")
print(f"训练时间: {training_time:.2f}")
# 解决 'Object of type float32 is not JSON serializable' 错误
metrics = convert_numpy_types(metrics)
# 保存最终训练完成的模型基于最终epoch
emit_progress(f"模型评估完成 - RMSE: {metrics['rmse']:.4f}, R²: {metrics['r2']:.4f}")
# 7. 保存最终模型和元数据
final_model_data = {
'epoch': epochs, # 最终epoch
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_losses[-1],
'test_loss': test_losses[-1],
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
}
model_manager.save_model_artifact(final_model_data, "model.pth", model_version_path)
metadata = {
'product_id': product_id if product_id else scope, 'product_name': product_name, 'model_type': model_type,
'version': f'v{version}', 'training_mode': training_mode, 'scope': scope,
'aggregation_method': aggregation_method, 'training_description': training_description,
'timestamp': datetime.now().isoformat(), 'metrics': metrics,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_layers': num_layers,
'dropout': dropout_rate,
'kernel_size': kernel_size,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'tcn'
},
'metrics': metrics,
'loss_curve_path': loss_curve_path,
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'timestamp': time.time(),
'training_completed': True
'input_dim': input_dim, 'output_dim': output_dim, 'hidden_size': hidden_size,
'num_layers': num_layers, 'kernel_size': kernel_size, 'dropout': dropout_rate,
'sequence_length': LOOK_BACK, 'forecast_horizon': FORECAST_HORIZON,
}
}
model_manager.save_model_artifact(metadata, "metadata.json", model_version_path)
# 8. 更新版本文件
model_manager.update_version(model_identifier, version)
progress_manager.set_stage("model_saving", 50)
emit_progress(f"{model_type.upper()}模型 v{version} 训练完成!", progress=100, metrics=metrics)
# 保存最终模型使用epoch标识
final_model_path = save_checkpoint(
final_model_data, f"final_epoch_{epochs}", product_id, 'tcn',
model_dir, store_id, training_mode, aggregation_method
)
progress_manager.set_stage("model_saving", 100)
final_metrics = {
'mse': metrics['mse'],
'rmse': metrics['rmse'],
'mae': metrics['mae'],
'r2': metrics['r2'],
'mape': metrics['mape'],
'training_time': training_time,
'final_epoch': epochs
}
emit_progress(f"模型训练完成最终epoch: {epochs}", progress=100, metrics=final_metrics)
return model, metrics, epochs, final_model_path
return model, metrics, version, model_version_path

View File

@ -1,5 +1,5 @@
"""
药店销售预测系统 - Transformer模型训练函数
药店销售预测系统 - Transformer模型训练函数 (已重构)
"""
import os
@ -12,249 +12,164 @@ import torch.optim as optim
from torch.utils.data import DataLoader
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
from tqdm import tqdm
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
from datetime import datetime
from models.transformer_model import TimeSeriesTransformer
from utils.data_utils import create_dataset, PharmacyDataset
from utils.multi_store_data_utils import get_store_product_sales_data, aggregate_multi_store_data
from utils.visualization import plot_loss_curve
from analysis.metrics import evaluate_model
from core.config import (
DEVICE, DEFAULT_MODEL_DIR, LOOK_BACK, FORECAST_HORIZON,
get_next_model_version, get_model_file_path, get_latest_model_version
)
from utils.training_progress import progress_manager
from core.config import DEVICE, LOOK_BACK, FORECAST_HORIZON
from utils.model_manager import model_manager
from typing import Any
def save_checkpoint(checkpoint_data: dict, epoch_or_label, product_id: str,
model_type: str, model_dir: str, store_id=None,
training_mode: str = 'product', aggregation_method=None):
def convert_numpy_types(obj: Any) -> Any:
"""
保存训练检查点
Args:
checkpoint_data: 检查点数据
epoch_or_label: epoch编号或标签'best'
product_id: 产品ID
model_type: 模型类型
model_dir: 模型保存目录
store_id: 店铺ID
training_mode: 训练模式
aggregation_method: 聚合方法
递归地将字典或列表中的Numpy数值类型转换为Python原生类型
"""
# 创建检查点目录
checkpoint_dir = os.path.join(model_dir, 'checkpoints')
os.makedirs(checkpoint_dir, exist_ok=True)
# 生成检查点文件名
if training_mode == 'store' and store_id:
filename = f"{model_type}_store_{store_id}_{product_id}_epoch_{epoch_or_label}.pth"
elif training_mode == 'global' and aggregation_method:
filename = f"{model_type}_global_{product_id}_{aggregation_method}_epoch_{epoch_or_label}.pth"
else:
filename = f"{model_type}_product_{product_id}_epoch_{epoch_or_label}.pth"
checkpoint_path = os.path.join(checkpoint_dir, filename)
# 保存检查点
torch.save(checkpoint_data, checkpoint_path)
print(f"[Transformer] 检查点已保存: {checkpoint_path}", flush=True)
return checkpoint_path
if isinstance(obj, dict):
return {k: convert_numpy_types(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_numpy_types(elem) for elem in obj]
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
return obj
def train_product_model_with_transformer(
product_id,
product_df=None,
product_df,
product_id=None,
store_id=None,
training_mode='product',
aggregation_method='sum',
scope=None,
epochs=50,
model_dir=DEFAULT_MODEL_DIR,
version=None,
socketio=None,
task_id=None,
continue_training=False,
progress_callback=None,
patience=10,
learning_rate=0.001,
clip_norm=1.0
):
"""
使用Transformer模型训练产品销售预测模型
参数:
product_id: 产品ID
epochs: 训练轮次
model_dir: 模型保存目录默认使用配置中的DEFAULT_MODEL_DIR
version: 指定版本号如果为None则自动生成
socketio: WebSocket对象用于实时反馈
task_id: 训练任务ID
continue_training: 是否继续训练现有模型
返回:
model: 训练好的模型
metrics: 模型评估指标
version: 实际使用的版本号
使用Transformer模型训练产品销售预测模型 (已适配新的ModelManager)
"""
# WebSocket进度反馈函数
def emit_progress(message, progress=None, metrics=None):
"""发送训练进度到前端"""
progress_data = {
'task_id': task_id,
'message': f"[Transformer] {message}",
'timestamp': time.time()
}
if progress is not None:
progress_data['progress'] = progress
if metrics is not None:
progress_data['metrics'] = metrics
if progress_callback:
try:
progress_callback(progress_data)
except Exception as e:
print(f"[Transformer] 进度回调失败: {e}")
if socketio and task_id:
data = {
'task_id': task_id,
'message': message,
'timestamp': time.time()
}
if progress is not None:
data['progress'] = progress
if metrics is not None:
data['metrics'] = metrics
socketio.emit('training_progress', data, namespace='/training')
print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)
# 强制刷新输出缓冲区
import sys
sys.stdout.flush()
sys.stderr.flush()
try:
socketio.emit('training_progress', progress_data, namespace='/training')
except Exception as e:
print(f"[Transformer] WebSocket发送失败: {e}")
print(f"[Transformer] {message}", flush=True)
emit_progress("开始Transformer模型训练...")
# 1. 确定模型标识符和版本
model_type = 'transformer'
# 直接使用从 predictor 传递过来的、已经构建好的 scope
if scope is None:
# 作为后备如果scope未提供则根据旧逻辑构建不推荐
if training_mode == 'store':
current_product_id = product_id if product_id and product_id not in ['unknown', 'all'] else 'all'
scope = f"{store_id}_{current_product_id}"
elif training_mode == 'product':
scope = f"{product_id}_{store_id or 'all'}"
elif training_mode == 'global':
scope = product_id if product_id else "all"
emit_progress(f"警告: Scope未由调用方提供已自动构建为 '{scope}'", 'warning')
model_identifier = model_manager.get_model_identifier(model_type, training_mode, scope, aggregation_method)
version = model_manager.get_next_version_number(model_identifier)
# 获取训练进度管理器实例
try:
from utils.training_progress import progress_manager
except ImportError:
# 如果无法导入,创建一个空的管理器以避免错误
class DummyProgressManager:
def set_stage(self, *args, **kwargs): pass
def start_training(self, *args, **kwargs): pass
def start_epoch(self, *args, **kwargs): pass
def update_batch(self, *args, **kwargs): pass
def finish_epoch(self, *args, **kwargs): pass
def finish_training(self, *args, **kwargs): pass
progress_manager = DummyProgressManager()
# 如果没有传入product_df则根据训练模式加载数据
emit_progress(f"开始训练 {model_type} 模型 v{version}")
# 2. 获取模型版本路径
model_version_path = model_manager.get_model_version_path(
model_type=model_type,
training_mode=training_mode,
scope=scope,
version=version,
aggregation_method=aggregation_method
)
emit_progress(f"模型将保存到: {model_version_path}")
# 3. 数据加载和预处理
if product_df is None:
from utils.multi_store_data_utils import load_multi_store_data, get_store_product_sales_data, aggregate_multi_store_data
try:
if training_mode == 'store' and store_id:
# 加载特定店铺的数据
product_df = get_store_product_sales_data(
store_id,
product_id,
'pharmacy_sales_multi_store.csv'
)
training_scope = f"店铺 {store_id}"
product_df = get_store_product_sales_data(store_id, product_id, 'pharmacy_sales_multi_store.csv')
elif training_mode == 'global':
# 聚合所有店铺的数据
product_df = aggregate_multi_store_data(
product_id,
aggregation_method=aggregation_method,
file_path='pharmacy_sales_multi_store.csv'
)
training_scope = f"全局聚合({aggregation_method})"
product_df = aggregate_multi_store_data(product_id, aggregation_method=aggregation_method, file_path='pharmacy_sales_multi_store.csv')
else:
# 默认:加载所有店铺的产品数据
product_df = load_multi_store_data('pharmacy_sales_multi_store.csv', product_id=product_id)
training_scope = "所有店铺"
except Exception as e:
print(f"多店铺数据加载失败: {e}")
# 后备方案:尝试原始数据
emit_progress(f"多店铺数据加载失败: {e}, 尝试后备方案...")
df = pd.read_excel('pharmacy_sales.xlsx')
product_df = df[df['product_id'] == product_id].sort_values('date')
training_scope = "原始数据"
else:
# 如果传入了product_df直接使用
if training_mode == 'store' and store_id:
training_scope = f"店铺 {store_id}"
elif training_mode == 'global':
training_scope = f"全局聚合({aggregation_method})"
else:
training_scope = "所有店铺"
if product_df.empty:
raise ValueError(f"产品 {product_id} 没有可用的销售数据")
# 数据量检查
# 构建一个更通用的训练描述
training_description = f"模式: {training_mode}, 范围: {scope}"
if aggregation_method and aggregation_method != 'none':
training_description += f", 聚合: {aggregation_method}"
min_required_samples = LOOK_BACK + FORECAST_HORIZON
if len(product_df) < min_required_samples:
error_msg = (
f"❌ 训练数据不足错误\n"
f"当前配置需要: {min_required_samples} 天数据 (LOOK_BACK={LOOK_BACK} + FORECAST_HORIZON={FORECAST_HORIZON})\n"
f"实际数据量: {len(product_df)}\n"
f"产品ID: {product_id}, 训练模式: {training_mode}\n"
f"建议解决方案:\n"
f"1. 生成更多数据: uv run generate_multi_store_data.py\n"
f"2. 调整配置参数: 减小 LOOK_BACK 或 FORECAST_HORIZON\n"
f"3. 使用全局训练模式聚合更多数据"
)
print(error_msg)
error_msg = f"数据不足: 需要 {min_required_samples} 天, 实际 {len(product_df)} 天。"
emit_progress(f"训练失败:{error_msg}")
raise ValueError(error_msg)
product_df = product_df.sort_values('date')
product_name = product_df['product_name'].iloc[0]
if product_id:
product_name = product_df['product_name'].iloc[0]
else:
product_name = f"Aggregated Model ({training_mode}/{scope})"
print(f"[Transformer] 训练产品 '{product_name}' (ID: {product_id}) 的销售预测模型", flush=True)
print(f"[Device] 使用设备: {DEVICE}", flush=True)
print(f"[Model] 模型将保存到目录: {model_dir}", flush=True)
# 创建特征和目标变量
print_product_id = product_id if product_id else "N/A"
emit_progress(f"开始训练. 描述: {training_description}")
emit_progress(f"使用设备: {DEVICE}, 数据量: {len(product_df)}")
features = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
# 设置数据预处理阶段
progress_manager.set_stage("data_preprocessing", 0)
emit_progress("数据预处理中...")
# 预处理数据
X = product_df[features].values
y = product_df[['sales']].values # 保持为二维数组
y = product_df[['sales']].values
# 归一化数据
scaler_X = MinMaxScaler(feature_range=(0, 1))
scaler_y = MinMaxScaler(feature_range=(0, 1))
X_scaled = scaler_X.fit_transform(X)
y_scaled = scaler_y.fit_transform(y)
progress_manager.set_stage("data_preprocessing", 40)
# 划分训练集和测试集80% 训练20% 测试)
train_size = int(len(X_scaled) * 0.8)
X_train, X_test = X_scaled[:train_size], X_scaled[train_size:]
y_train, y_test = y_scaled[:train_size], y_scaled[train_size:]
# 创建时间序列数据
trainX, trainY = create_dataset(X_train, y_train, LOOK_BACK, FORECAST_HORIZON)
testX, testY = create_dataset(X_test, y_test, LOOK_BACK, FORECAST_HORIZON)
progress_manager.set_stage("data_preprocessing", 70)
# 转换为PyTorch的Tensor
trainX_tensor = torch.Tensor(trainX)
trainY_tensor = torch.Tensor(trainY)
testX_tensor = torch.Tensor(testX)
testY_tensor = torch.Tensor(testY)
# 创建数据加载器
train_dataset = PharmacyDataset(trainX_tensor, trainY_tensor)
test_dataset = PharmacyDataset(testX_tensor, testY_tensor)
batch_size = 32
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
train_loader = DataLoader(PharmacyDataset(torch.Tensor(trainX), torch.Tensor(trainY)), batch_size=batch_size, shuffle=True)
test_loader = DataLoader(PharmacyDataset(torch.Tensor(testX), torch.Tensor(testY)), batch_size=batch_size, shuffle=False)
# 更新进度管理器的批次信息
total_batches = len(train_loader)
total_samples = len(train_dataset)
progress_manager.total_batches_per_epoch = total_batches
progress_manager.batch_size = batch_size
progress_manager.total_samples = total_samples
progress_manager.set_stage("data_preprocessing", 100)
emit_progress("数据预处理完成,开始模型训练...")
# 初始化Transformer模型
# 4. 模型初始化
input_dim = X_train.shape[1]
output_dim = FORECAST_HORIZON
hidden_size = 64
@ -270,258 +185,127 @@ def train_product_model_with_transformer(
dim_feedforward=hidden_size * 2,
dropout=dropout_rate,
output_sequence_length=output_dim,
seq_length=LOOK_BACK,
batch_size=batch_size
)
# 将模型移动到设备上
model = model.to(DEVICE)
seq_length=LOOK_BACK
).to(DEVICE)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min', patience=patience // 2, factor=0.5)
# 训练模型
train_losses = []
test_losses = []
emit_progress("数据预处理完成,开始模型训练...", progress=10)
# 5. 训练循环
train_losses, test_losses = [], []
start_time = time.time()
# 配置检查点保存
checkpoint_interval = max(1, epochs // 10) # 每10%进度保存一次最少每1个epoch
best_loss = float('inf')
epochs_no_improve = 0
progress_manager.set_stage("model_training", 0)
emit_progress(f"开始训练 - 总epoch: {epochs}, 检查点间隔: {checkpoint_interval}, 耐心值: {patience}")
for epoch in range(epochs):
# 开始新的轮次
progress_manager.start_epoch(epoch)
model.train()
epoch_loss = 0
for batch_idx, (X_batch, y_batch) in enumerate(train_loader):
for X_batch, y_batch in train_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
# 确保目标张量有正确的形状
# 前向传播
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
# 反向传播和优化
optimizer.zero_grad()
loss.backward()
if clip_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=clip_norm)
optimizer.step()
epoch_loss += loss.item()
# 更新批次进度
if batch_idx % 5 == 0 or batch_idx == len(train_loader) - 1:
current_lr = optimizer.param_groups[0]['lr']
progress_manager.update_batch(batch_idx, loss.item(), current_lr)
# 计算训练损失
train_loss = epoch_loss / len(train_loader)
train_losses.append(train_loss)
# 设置验证阶段
progress_manager.set_stage("validation", 0)
# 在测试集上评估
model.eval()
test_loss = 0
with torch.no_grad():
for batch_idx, (X_batch, y_batch) in enumerate(test_loader):
for X_batch, y_batch in test_loader:
X_batch, y_batch = X_batch.to(DEVICE), y_batch.to(DEVICE)
# 确保目标张量有正确的形状
outputs = model(X_batch)
loss = criterion(outputs, y_batch)
test_loss += loss.item()
# 更新验证进度
if batch_idx % 3 == 0 or batch_idx == len(test_loader) - 1:
val_progress = (batch_idx / len(test_loader)) * 100
progress_manager.set_stage("validation", val_progress)
test_loss = test_loss / len(test_loader)
test_loss /= len(test_loader)
test_losses.append(test_loss)
# 更新学习率
scheduler.step(test_loss)
# 完成当前轮次
progress_manager.finish_epoch(train_loss, test_loss)
# 发送训练进度
if (epoch + 1) % 5 == 0 or epoch == epochs - 1:
progress = ((epoch + 1) / epochs) * 100
current_metrics = {
'train_loss': train_loss,
'test_loss': test_loss,
'epoch': epoch + 1,
'total_epochs': epochs
}
emit_progress(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}",
progress=progress, metrics=current_metrics)
# 定期保存检查点
if (epoch + 1) % checkpoint_interval == 0 or epoch == epochs - 1:
progress_percentage = 10 + ((epoch + 1) / epochs) * 85
emit_progress(f"Epoch {epoch+1}/{epochs} - Train Loss: {train_loss:.4f}, Test Loss: {test_loss:.4f}", progress=progress_percentage)
if test_loss < best_loss:
best_loss = test_loss
epochs_no_improve = 0
checkpoint_data = {
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_loss,
'test_loss': test_loss,
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_heads': num_heads,
'dropout': dropout_rate,
'num_layers': num_layers,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'transformer'
},
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'timestamp': time.time()
}
}
# 保存检查点
save_checkpoint(checkpoint_data, epoch + 1, product_id, 'transformer',
model_dir, store_id, training_mode, aggregation_method)
# 如果是最佳模型,额外保存一份
if test_loss < best_loss:
best_loss = test_loss
save_checkpoint(checkpoint_data, 'best', product_id, 'transformer',
model_dir, store_id, training_mode, aggregation_method)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
epochs_no_improve = 0
else:
epochs_no_improve += 1
emit_progress(f"💾 保存训练检查点 epoch_{epoch+1}")
model_manager.save_model_artifact(checkpoint_data, "checkpoint_best.pth", model_version_path)
emit_progress(f"💾 保存最佳模型检查点 (epoch {epoch+1}, test_loss: {test_loss:.4f})")
else:
epochs_no_improve += 1
if (epoch + 1) % 10 == 0:
print(f"📊 Epoch {epoch+1}/{epochs}, 训练损失: {train_loss:.4f}, 测试损失: {test_loss:.4f}", flush=True)
# 提前停止逻辑
if epochs_no_improve >= patience:
emit_progress(f"连续 {patience} 个epoch测试损失未改善提前停止训练。")
break
# 计算训练时间
training_time = time.time() - start_time
# 设置模型保存阶段
progress_manager.set_stage("model_saving", 0)
emit_progress("训练完成,正在保存模型...")
# 绘制损失曲线并保存到模型目录
loss_curve_path = plot_loss_curve(
train_losses,
test_losses,
product_name,
'Transformer',
model_dir=model_dir
)
print(f"📈 损失曲线已保存到: {loss_curve_path}", flush=True)
# 评估模型
# 6. 保存产物和评估
loss_fig = plt.figure(figsize=(10, 6))
plt.plot(train_losses, label='Training Loss')
plt.plot(test_losses, label='Test Loss')
plt.title(f'{model_type.upper()} 损失曲线 - {training_description} (v{version})')
plt.xlabel('Epoch'); plt.ylabel('Loss'); plt.legend(); plt.grid(True)
model_manager.save_model_artifact(loss_fig, "loss_curve.png", model_version_path)
plt.close(loss_fig)
emit_progress(f"损失曲线已保存到: {os.path.join(model_version_path, 'loss_curve.png')}")
model.eval()
with torch.no_grad():
test_pred = model(testX_tensor.to(DEVICE)).cpu().numpy()
test_true = testY
# 反归一化预测结果和真实值
test_pred_inv = scaler_y.inverse_transform(test_pred)
test_true_inv = scaler_y.inverse_transform(test_true)
testX_tensor = torch.Tensor(testX).to(DEVICE)
test_pred = model(testX_tensor).cpu().numpy()
# 计算评估指标
metrics = evaluate_model(test_true_inv, test_pred_inv)
test_pred_inv = scaler_y.inverse_transform(test_pred)
test_true_inv = scaler_y.inverse_transform(testY)
metrics = evaluate_model(test_true_inv.flatten(), test_pred_inv.flatten())
metrics['training_time'] = training_time
# 打印评估指标
print(f"\n📊 模型评估指标:", flush=True)
print(f" MSE: {metrics['mse']:.4f}", flush=True)
print(f" RMSE: {metrics['rmse']:.4f}", flush=True)
print(f" MAE: {metrics['mae']:.4f}", flush=True)
print(f" R²: {metrics['r2']:.4f}", flush=True)
print(f" MAPE: {metrics['mape']:.2f}%", flush=True)
print(f" ⏱️ 训练时间: {training_time:.2f}", flush=True)
# 解决 'Object of type float32 is not JSON serializable' 错误
metrics = convert_numpy_types(metrics)
# 保存最终训练完成的模型基于最终epoch
emit_progress(f"模型评估完成 - RMSE: {metrics['rmse']:.4f}, R²: {metrics['r2']:.4f}")
# 7. 保存最终模型和元数据
final_model_data = {
'epoch': epochs, # 最终epoch
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_losses[-1],
'test_loss': test_losses[-1],
'train_losses': train_losses,
'test_losses': test_losses,
'scaler_X': scaler_X,
'scaler_y': scaler_y,
}
model_manager.save_model_artifact(final_model_data, "model.pth", model_version_path)
metadata = {
'product_id': product_id if product_id else scope, 'product_name': product_name, 'model_type': model_type,
'version': f'v{version}', 'training_mode': training_mode, 'scope': scope,
'aggregation_method': aggregation_method, 'training_description': training_description,
'timestamp': datetime.now().isoformat(), 'metrics': metrics,
'config': {
'input_dim': input_dim,
'output_dim': output_dim,
'hidden_size': hidden_size,
'num_heads': num_heads,
'dropout': dropout_rate,
'num_layers': num_layers,
'sequence_length': LOOK_BACK,
'forecast_horizon': FORECAST_HORIZON,
'model_type': 'transformer'
},
'metrics': metrics,
'loss_curve_path': loss_curve_path,
'training_info': {
'product_id': product_id,
'product_name': product_name,
'training_mode': training_mode,
'store_id': store_id,
'aggregation_method': aggregation_method,
'timestamp': time.time(),
'training_completed': True
'input_dim': input_dim, 'output_dim': output_dim, 'd_model': hidden_size,
'nhead': num_heads, 'num_encoder_layers': num_layers, 'dim_feedforward': hidden_size * 2,
'dropout': dropout_rate, 'sequence_length': LOOK_BACK, 'forecast_horizon': FORECAST_HORIZON,
}
}
model_manager.save_model_artifact(metadata, "metadata.json", model_version_path)
# 8. 更新版本文件
model_manager.update_version(model_identifier, version)
progress_manager.set_stage("model_saving", 50)
emit_progress(f"{model_type.upper()}模型 v{version} 训练完成!", progress=100, metrics=metrics)
# 保存最终模型使用epoch标识
final_model_path = save_checkpoint(
final_model_data, f"final_epoch_{epochs}", product_id, 'transformer',
model_dir, store_id, training_mode, aggregation_method
)
progress_manager.set_stage("model_saving", 100)
emit_progress(f"模型已保存到 {final_model_path}")
print(f"💾 模型已保存到 {final_model_path}", flush=True)
# 准备最终返回的指标
final_metrics = {
'mse': metrics['mse'],
'rmse': metrics['rmse'],
'mae': metrics['mae'],
'r2': metrics['r2'],
'mape': metrics['mape'],
'training_time': training_time,
'final_epoch': epochs
}
return model, final_metrics, epochs
return model, metrics, version, model_version_path

View File

@ -1,6 +1,7 @@
"""
统一模型管理工具
处理模型文件的统一命名存储和检索
遵循层级式目录结构和文件版本管理规则
"""
import os
@ -8,376 +9,223 @@ import json
import torch
import glob
from datetime import datetime
from typing import List, Dict, Optional, Tuple
from typing import List, Dict, Optional, Any
from threading import Lock
from core.config import DEFAULT_MODEL_DIR
class ModelManager:
"""统一模型管理器"""
"""
统一模型管理器采用结构化目录和版本文件进行管理
"""
VERSION_FILE = 'versions.json'
def __init__(self, model_dir: str = DEFAULT_MODEL_DIR):
self.model_dir = model_dir
self.model_dir = os.path.abspath(model_dir)
self.versions_path = os.path.join(self.model_dir, self.VERSION_FILE)
self._lock = Lock()
self.ensure_model_dir()
def ensure_model_dir(self):
"""确保模型目录存在"""
if not os.path.exists(self.model_dir):
os.makedirs(self.model_dir)
def generate_model_filename(self,
product_id: str,
model_type: str,
version: str,
store_id: Optional[str] = None,
training_mode: str = 'product',
"""确保模型根目录存在"""
os.makedirs(self.model_dir, exist_ok=True)
def _read_versions(self) -> Dict[str, int]:
"""线程安全地读取版本文件"""
with self._lock:
if not os.path.exists(self.versions_path):
return {}
try:
with open(self.versions_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def _write_versions(self, versions: Dict[str, int]):
"""线程安全地写入版本文件"""
with self._lock:
with open(self.versions_path, 'w', encoding='utf-8') as f:
json.dump(versions, f, indent=2)
def get_model_identifier(self,
model_type: str,
training_mode: str,
scope: str,
aggregation_method: Optional[str] = None) -> str:
"""
生成模型的唯一标识符用于版本文件中的key
"""
if training_mode == 'global':
return f"{training_mode}_{scope}_{aggregation_method}_{model_type}"
return f"{training_mode}_{scope}_{model_type}"
def get_next_version_number(self, model_identifier: str) -> int:
"""
获取指定模型的下一个版本号整数
"""
versions = self._read_versions()
current_version = versions.get(model_identifier, 0)
return current_version + 1
def update_version(self, model_identifier: str, new_version: int):
"""
更新模型的最新版本号
"""
versions = self._read_versions()
versions[model_identifier] = new_version
self._write_versions(versions)
def get_model_version_path(self,
model_type: str,
version: int,
training_mode: str,
scope: str,
aggregation_method: Optional[str] = None) -> str:
"""
生成统一的模型文件名
格式规范:
- 产品模式: {model_type}_product_{product_id}_{version}.pth
- 店铺模式: {model_type}_store_{store_id}_{product_id}_{version}.pth
- 全局模式: {model_type}_global_{product_id}_{aggregation_method}_{version}.pth
根据 `xz训练模型保存规则.md` 中定义的新规则生成模型版本目录的完整路径
"""
if training_mode == 'store' and store_id:
return f"{model_type}_store_{store_id}_{product_id}_{version}.pth"
elif training_mode == 'global' and aggregation_method:
return f"{model_type}_global_{product_id}_{aggregation_method}_{version}.pth"
base_path = self.model_dir
path_parts = [base_path]
if training_mode == 'product':
# saved_models/product/{scope}/{model_type}/v{N}/
if not scope: raise ValueError("scope is required for 'product' training mode.")
path_parts.extend(['product', scope, model_type, f'v{version}'])
elif training_mode == 'store':
# saved_models/store/{scope}/{model_type}/v{N}/
if not scope: raise ValueError("scope is required for 'store' training mode.")
path_parts.extend(['store', scope, model_type, f'v{version}'])
elif training_mode == 'global':
# saved_models/global/{scope_path}/{aggregation_method}/{model_type}/v{N}/
if not scope: raise ValueError("scope is required for 'global' training mode.")
if not aggregation_method: raise ValueError("aggregation_method is required for 'global' training mode.")
scope_parts = scope.split('/')
path_parts.extend(['global', *scope_parts, str(aggregation_method), model_type, f'v{version}'])
else:
# 默认产品模式
return f"{model_type}_product_{product_id}_{version}.pth"
def save_model(self,
model_data: dict,
product_id: str,
model_type: str,
version: str,
store_id: Optional[str] = None,
training_mode: str = 'product',
aggregation_method: Optional[str] = None,
product_name: Optional[str] = None) -> str:
raise ValueError(f"不支持的 training_mode: {training_mode}")
return os.path.join(*path_parts)
def save_model_artifact(self,
artifact_data: Any,
artifact_name: str,
model_version_path: str):
"""
保存模型到统一位置
在指定的模型版本目录下保存一个产物
参数:
model_data: 包含模型状态和配置的字典
product_id: 产品ID
model_type: 模型类型
version: 版本号
store_id: 店铺ID (可选)
training_mode: 训练模式
aggregation_method: 聚合方法 (可选)
product_name: 产品名称 (可选)
返回:
模型文件路径
Args:
artifact_data: 要保存的数据 (e.g., model state dict, figure object).
artifact_name: 标准化的产物文件名 (e.g., 'model.pth', 'loss_curve.png').
model_version_path: 模型版本目录的路径.
"""
filename = self.generate_model_filename(
product_id, model_type, version, store_id, training_mode, aggregation_method
)
# 统一保存到根目录,避免复杂的子目录结构
model_path = os.path.join(self.model_dir, filename)
# 增强模型数据,添加管理信息
enhanced_model_data = model_data.copy()
enhanced_model_data.update({
'model_manager_info': {
'product_id': product_id,
'product_name': product_name or product_id,
'model_type': model_type,
'version': version,
'store_id': store_id,
'training_mode': training_mode,
'aggregation_method': aggregation_method,
'created_at': datetime.now().isoformat(),
'filename': filename
}
})
# 保存模型
torch.save(enhanced_model_data, model_path)
print(f"模型已保存: {model_path}")
return model_path
def list_models(self,
product_id: Optional[str] = None,
model_type: Optional[str] = None,
store_id: Optional[str] = None,
training_mode: Optional[str] = None,
os.makedirs(model_version_path, exist_ok=True)
full_path = os.path.join(model_version_path, artifact_name)
if artifact_name.endswith('.pth'):
torch.save(artifact_data, full_path)
elif artifact_name.endswith('.png') and hasattr(artifact_data, 'savefig'):
artifact_data.savefig(full_path, dpi=300, bbox_inches='tight')
elif artifact_name.endswith('.json'):
with open(full_path, 'w', encoding='utf-8') as f:
json.dump(artifact_data, f, indent=2, ensure_ascii=False)
else:
raise ValueError(f"不支持的产物类型: {artifact_name}")
print(f"产物已保存: {full_path}")
def list_models(self,
page: Optional[int] = None,
page_size: Optional[int] = None) -> Dict:
"""
列出所有模型文件
参数:
product_id: 产品ID过滤 (可选)
model_type: 模型类型过滤 (可选)
store_id: 店铺ID过滤 (可选)
training_mode: 训练模式过滤 (可选)
page: 页码从1开始 (可选)
page_size: 每页数量 (可选)
返回:
包含模型列表和分页信息的字典
通过扫描目录结构来列出所有模型 (适配新结构)
"""
models = []
# 搜索所有.pth文件
pattern = os.path.join(self.model_dir, "*.pth")
model_files = glob.glob(pattern)
for model_file in model_files:
try:
# 解析文件名
filename = os.path.basename(model_file)
model_info = self.parse_model_filename(filename)
if not model_info:
continue
# 尝试从模型文件中读取额外信息
try:
# Try with weights_only=False first for backward compatibility
try:
model_data = torch.load(model_file, map_location='cpu', weights_only=False)
except Exception:
# If that fails, try with weights_only=True (newer PyTorch versions)
model_data = torch.load(model_file, map_location='cpu', weights_only=True)
if 'model_manager_info' in model_data:
# 使用新的管理信息
manager_info = model_data['model_manager_info']
model_info.update(manager_info)
# 添加评估指标
if 'metrics' in model_data:
model_info['metrics'] = model_data['metrics']
# 添加配置信息
if 'config' in model_data:
model_info['config'] = model_data['config']
except Exception as e:
print(f"读取模型文件失败 {model_file}: {e}")
# Continue with just the filename-based info
# 应用过滤器
if product_id and model_info.get('product_id') != product_id:
continue
if model_type and model_info.get('model_type') != model_type:
continue
if store_id and model_info.get('store_id') != store_id:
continue
if training_mode and model_info.get('training_mode') != training_mode:
continue
# 添加文件信息
model_info['filename'] = filename
model_info['file_path'] = model_file
model_info['file_size'] = os.path.getsize(model_file)
model_info['modified_at'] = datetime.fromtimestamp(
os.path.getmtime(model_file)
).isoformat()
models.append(model_info)
except Exception as e:
print(f"处理模型文件失败 {model_file}: {e}")
continue
# 按创建时间排序(最新的在前)
models.sort(key=lambda x: x.get('created_at', x.get('modified_at', '')), reverse=True)
# 计算分页信息
total_count = len(models)
# 如果没有指定分页参数,返回所有数据
if page is None or page_size is None:
return {
'models': models,
'pagination': {
'total': total_count,
'page': 1,
'page_size': total_count,
'total_pages': 1,
'has_next': False,
'has_previous': False
}
}
# 应用分页
total_pages = (total_count + page_size - 1) // page_size if page_size > 0 else 1
start_index = (page - 1) * page_size
end_index = start_index + page_size
paginated_models = models[start_index:end_index]
all_models = []
# 使用glob查找所有版本目录
search_pattern = os.path.join(self.model_dir, '**', 'v*')
for version_path in glob.glob(search_pattern, recursive=True):
# 确保它是一个目录并且包含 metadata.json
metadata_path = os.path.join(version_path, 'metadata.json')
if os.path.isdir(version_path) and os.path.exists(metadata_path):
model_info = self._parse_info_from_path(version_path)
if model_info:
all_models.append(model_info)
# 按时间戳降序排序
all_models.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
total_count = len(all_models)
if page and page_size:
start_index = (page - 1) * page_size
end_index = start_index + page_size
paginated_models = all_models[start_index:end_index]
else:
paginated_models = all_models
return {
'models': paginated_models,
'pagination': {
'total': total_count,
'page': page,
'page_size': page_size,
'total_pages': total_pages,
'has_next': page < total_pages,
'has_previous': page > 1
'page': page or 1,
'page_size': page_size or total_count,
'total_pages': (total_count + page_size - 1) // page_size if page_size and page_size > 0 else 1,
}
}
def parse_model_filename(self, filename: str) -> Optional[Dict]:
"""
解析模型文件名提取模型信息
支持的格式:
- {model_type}_product_{product_id}_{version}.pth
- {model_type}_store_{store_id}_{product_id}_{version}.pth
- {model_type}_global_{product_id}_{aggregation_method}_{version}.pth
- 旧格式兼容
"""
if not filename.endswith('.pth'):
return None
base_name = filename.replace('.pth', '')
try:
# 新格式解析
if '_product_' in base_name:
# 产品模式: model_type_product_product_id_version
parts = base_name.split('_product_')
model_type = parts[0]
rest = parts[1]
# 分离产品ID和版本
if '_v' in rest:
last_v_index = rest.rfind('_v')
product_id = rest[:last_v_index]
version = rest[last_v_index+1:]
else:
product_id = rest
version = 'v1'
return {
'model_type': model_type,
'product_id': product_id,
'version': version,
'training_mode': 'product',
'store_id': None,
'aggregation_method': None
}
elif '_store_' in base_name:
# 店铺模式: model_type_store_store_id_product_id_version
parts = base_name.split('_store_')
model_type = parts[0]
rest = parts[1]
# 分离店铺ID、产品ID和版本
rest_parts = rest.split('_')
if len(rest_parts) >= 3:
store_id = rest_parts[0]
if rest_parts[-1].startswith('v'):
# 最后一部分是版本号
version = rest_parts[-1]
product_id = '_'.join(rest_parts[1:-1])
else:
version = 'v1'
product_id = '_'.join(rest_parts[1:])
return {
'model_type': model_type,
'product_id': product_id,
'version': version,
'training_mode': 'store',
'store_id': store_id,
'aggregation_method': None
}
elif '_global_' in base_name:
# 全局模式: model_type_global_product_id_aggregation_method_version
parts = base_name.split('_global_')
model_type = parts[0]
rest = parts[1]
rest_parts = rest.split('_')
if len(rest_parts) >= 3:
if rest_parts[-1].startswith('v'):
# 最后一部分是版本号
version = rest_parts[-1]
aggregation_method = rest_parts[-2]
product_id = '_'.join(rest_parts[:-2])
else:
version = 'v1'
aggregation_method = rest_parts[-1]
product_id = '_'.join(rest_parts[:-1])
return {
'model_type': model_type,
'product_id': product_id,
'version': version,
'training_mode': 'global',
'store_id': None,
'aggregation_method': aggregation_method
}
# 兼容旧格式
else:
# 尝试解析其他格式
if 'model_product_' in base_name:
parts = base_name.split('_model_product_')
model_type = parts[0]
product_part = parts[1]
if '_v' in product_part:
last_v_index = product_part.rfind('_v')
product_id = product_part[:last_v_index]
version = product_part[last_v_index+1:]
else:
product_id = product_part
version = 'v1'
return {
'model_type': model_type,
'product_id': product_id,
'version': version,
'training_mode': 'product',
'store_id': None,
'aggregation_method': None
}
except Exception as e:
print(f"解析文件名失败 {filename}: {e}")
return None
def delete_model(self, model_file: str) -> bool:
"""删除模型文件"""
try:
if os.path.exists(model_file):
os.remove(model_file)
print(f"已删除模型文件: {model_file}")
return True
else:
print(f"模型文件不存在: {model_file}")
return False
except Exception as e:
print(f"删除模型文件失败: {e}")
return False
def get_model_by_id(self, model_id: str) -> Optional[Dict]:
"""根据模型ID获取模型信息"""
models = self.list_models()
for model in models:
if model.get('filename', '').replace('.pth', '') == model_id:
return model
return None
def _parse_info_from_path(self, version_path: str) -> Optional[Dict]:
"""根据新的目录结构从版本目录路径解析模型信息"""
try:
norm_path = os.path.normpath(version_path)
norm_model_dir = os.path.normpath(self.model_dir)
relative_path = os.path.relpath(norm_path, norm_model_dir)
parts = relative_path.split(os.sep)
if len(parts) < 4:
return None
info = {
'model_path': version_path,
'version': parts[-1],
'model_type': parts[-2],
'training_mode': parts[0],
'store_id': None,
'product_id': None,
'aggregation_method': None,
'scope': None
}
mode = parts[0]
if mode == 'product':
# product/{scope}/mlstm/v1
info['scope'] = parts[1]
elif mode == 'store':
# store/{scope}/mlstm/v1
info['scope'] = parts[1]
elif mode == 'global':
# global/{scope...}/sum/mlstm/v1
info['aggregation_method'] = parts[-3]
info['scope'] = '/'.join(parts[1:-3])
else:
return None # 未知模式
metadata_path = os.path.join(version_path, 'metadata.json')
if os.path.exists(metadata_path):
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
info.update(metadata)
# 确保从路径解析出的关键信息覆盖元数据中的,因为路径是权威来源
info['version'] = parts[-1]
info['model_type'] = parts[-2]
info['training_mode'] = parts[0]
return info
except (IndexError, IOError) as e:
print(f"解析路径失败 {version_path}: {e}")
return None
# 全局模型管理器实例
# 确保使用项目根目录的saved_models而不是相对于当前工作目录
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir)) # 向上两级到项目根目录
absolute_model_dir = os.path.join(project_root, 'saved_models')
model_manager = ModelManager(absolute_model_dir)
model_manager = ModelManager()

View File

@ -268,7 +268,7 @@ def get_store_product_sales_data(store_id: str,
# 数据标准化已在load_multi_store_data中完成
# 验证必要的列是否存在
required_columns = ['sales', 'price', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
required_columns = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
@ -287,96 +287,72 @@ def get_store_product_sales_data(store_id: str,
# 返回只包含这些必需列的DataFrame
return df[existing_columns]
def aggregate_multi_store_data(product_id: Optional[str] = None,
store_id: Optional[str] = None,
aggregation_method: str = 'sum',
file_path: str = None) -> pd.DataFrame:
def aggregate_multi_store_data(product_id: Optional[Any] = None,
store_id: Optional[Any] = None,
aggregation_method: str = 'sum',
file_path: Optional[str] = None) -> Optional[pd.DataFrame]:
"""
聚合销售数据可按产品全局或按店铺所有产品
参数:
file_path: 数据文件路径
product_id: 产品ID (用于全局模型)
store_id: 店铺ID (用于店铺聚合模型)
aggregation_method: 聚合方法 ('sum', 'mean', 'median')
返回:
DataFrame: 聚合后的销售数据
聚合销售数据 (已修复支持ID列表)
- 如果提供了 product_id(s)则聚合指定产品的数据
- 如果提供了 store_id(s)则聚合指定店铺的数据
"""
# 根据是全局聚合、店铺聚合还是真正全局聚合来加载数据
if store_id:
# 店铺聚合:加载该店铺的所有数据
df = load_multi_store_data(file_path, store_id=store_id)
if len(df) == 0:
raise ValueError(f"没有找到店铺 {store_id} 的销售数据")
grouping_entity = f"店铺 {store_id}"
elif product_id:
# 按产品聚合:加载该产品在所有店铺的数据
df = load_multi_store_data(file_path, product_id=product_id)
if len(df) == 0:
raise ValueError(f"没有找到产品 {product_id} 的销售数据")
grouping_entity = f"产品 {product_id}"
else:
# 真正全局聚合:加载所有数据
if file_path is None:
file_path = DEFAULT_DATA_PATH
try:
# 先加载所有数据,再进行过滤
df = load_multi_store_data(file_path)
if len(df) == 0:
raise ValueError("数据文件为空,无法进行全局聚合")
grouping_entity = "所有产品"
# 按日期聚合(使用标准化后的列名)
agg_dict = {}
if aggregation_method == 'sum':
agg_dict = {
'sales': 'sum', # 标准化后的销量列
'sales_amount': 'sum',
'price': 'mean' # 标准化后的价格列,取平均值
}
elif aggregation_method == 'mean':
agg_dict = {
'sales': 'mean',
'sales_amount': 'mean',
'price': 'mean'
}
elif aggregation_method == 'median':
agg_dict = {
'sales': 'median',
'sales_amount': 'median',
'price': 'median'
}
# 确保列名存在
available_cols = df.columns.tolist()
agg_dict = {k: v for k, v in agg_dict.items() if k in available_cols}
# 聚合数据
aggregated_df = df.groupby('date').agg(agg_dict).reset_index()
# 获取产品信息(取第一个店铺的信息)
product_info = df[['product_id', 'product_name', 'product_category']].iloc[0]
for col, val in product_info.items():
aggregated_df[col] = val
# 添加店铺信息标识为全局
aggregated_df['store_id'] = 'GLOBAL'
aggregated_df['store_name'] = f'全部店铺-{aggregation_method.upper()}'
aggregated_df['store_location'] = '全局聚合'
aggregated_df['store_type'] = 'global'
# 对聚合后的数据进行标准化(添加缺失的特征列)
aggregated_df = aggregated_df.sort_values('date').copy()
aggregated_df = standardize_column_names(aggregated_df)
# 定义模型训练所需的所有列(特征 + 目标)
final_columns = [
'date', 'sales', 'product_id', 'product_name', 'store_id', 'store_name',
'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature'
]
# 筛选出DataFrame中实际存在的列
existing_columns = [col for col in final_columns if col in aggregated_df.columns]
# 返回只包含这些必需列的DataFrame
return aggregated_df[existing_columns]
if df.empty:
raise ValueError("数据文件为空或加载失败")
# 根据 store_id 和 product_id 进行过滤 (支持列表和单个ID)
if store_id:
if isinstance(store_id, list):
df = df[df['store_id'].isin(store_id)]
else:
df = df[df['store_id'] == store_id]
if product_id:
if isinstance(product_id, list):
df = df[df['product_id'].isin(product_id)]
else:
df = df[df['product_id'] == product_id]
if df.empty:
raise ValueError(f"根据所选店铺/产品过滤后无数据")
# 确定聚合后的实体名称
if store_id and not product_id:
grouping_entity_name = df['store_name'].iloc[0] if len(df['store_id'].unique()) == 1 else "多个店铺聚合"
elif product_id and not store_id:
grouping_entity_name = df['product_name'].iloc[0] if len(df['product_id'].unique()) == 1 else "多个产品聚合"
elif store_id and product_id:
grouping_entity_name = f"{df['store_name'].iloc[0]} - {df['product_name'].iloc[0]}" if len(df['store_id'].unique()) == 1 and len(df['product_id'].unique()) == 1 else "自定义聚合"
else:
grouping_entity_name = "全局聚合模型"
# 按日期聚合
agg_df = df.groupby('date').agg({
'sales': aggregation_method,
'temperature': 'mean',
'is_holiday': 'max',
'is_weekend': 'max',
'is_promotion': 'max',
'weekday': 'first',
'month': 'first'
}).reset_index()
agg_df['product_name'] = grouping_entity_name
for col in ['is_holiday', 'is_weekend', 'is_promotion']:
if col not in agg_df:
agg_df[col] = 0
return agg_df
except Exception as e:
print(f"聚合数据失败: {e}")
return None
def get_sales_statistics(file_path: str = None,
store_id: Optional[str] = None,

View File

@ -45,6 +45,11 @@ class TrainingTask:
training_mode: str
store_id: Optional[str] = None
epochs: int = 100
product_scope: Optional[str] = 'all'
product_ids: Optional[list] = None
store_ids: Optional[list] = None
training_scope: Optional[str] = 'all_stores_all_products'
aggregation_method: Optional[str] = 'sum'
status: str = "pending" # pending, running, completed, failed
start_time: Optional[str] = None
end_time: Optional[str] = None
@ -90,24 +95,6 @@ class TrainingWorker:
task.process_id = os.getpid()
self.result_queue.put(('update', asdict(task)))
# 模拟训练进度更新
for epoch in range(1, task.epochs + 1):
progress = (epoch / task.epochs) * 100
# 发送进度更新
self.progress_queue.put({
'task_id': task.task_id,
'progress': progress,
'epoch': epoch,
'total_epochs': task.epochs,
'message': f"Epoch {epoch}/{task.epochs}"
})
training_logger.info(f"🔄 训练进度: Epoch {epoch}/{task.epochs} ({progress:.1f}%)")
# 模拟训练时间
time.sleep(1) # 实际训练中这里会是真正的训练代码
# 导入真正的训练函数
try:
# 添加服务器目录到路径确保能找到core模块
@ -138,17 +125,19 @@ class TrainingWorker:
training_logger.error(f"进度回调失败: {e}")
# 执行真正的训练,传递进度回调
# 执行真正的训练,传递所有任务参数
metrics = predictor.train_model(
product_id=task.product_id,
model_type=task.model_type,
epochs=task.epochs,
store_id=task.store_id,
training_mode=task.training_mode,
**asdict(task),
socketio=None, # 子进程中不能直接使用socketio
task_id=task.task_id,
progress_callback=progress_callback # 传递进度回调函数
)
# 检查训练结果如果为None则表示训练失败
if metrics is None:
# predictor.py 已经记录了详细的错误日志
# 这里我们抛出异常,以触发通用的失败处理流程
raise ValueError("训练器未能成功返回结果,任务失败。请检查之前的日志获取详细错误信息。")
# 发送训练完成日志到主控制台
self.progress_queue.put({
'task_id': task.task_id,
@ -156,12 +145,11 @@ class TrainingWorker:
'message': f"{task.model_type} 模型训练完成!"
})
if metrics:
self.progress_queue.put({
'task_id': task.task_id,
'log_type': 'info',
'message': f"📊 训练指标: MSE={metrics.get('mse', 'N/A'):.4f}, RMSE={metrics.get('rmse', 'N/A'):.4f}"
})
self.progress_queue.put({
'task_id': task.task_id,
'log_type': 'info',
'message': f"📊 训练指标: MSE={metrics.get('mse', 'N/A'):.4f}, RMSE={metrics.get('rmse', 'N/A'):.4f}"
})
except ImportError as e:
training_logger.error(f"❌ 导入训练器失败: {e}")
# 返回模拟的训练结果用于测试
@ -281,23 +269,34 @@ class TrainingProcessManager:
self.logger.info("✅ 训练进程管理器已停止")
def submit_task(self, product_id: str, model_type: str, training_mode: str = "product",
store_id: str = None, epochs: int = 100, **kwargs) -> str:
def submit_task(self, product_id: str, model_type: str, training_mode: str = "product",
store_id: Optional[str] = None, epochs: int = 100,
product_ids: Optional[list] = None,
product_scope: str = 'all',
store_ids: Optional[list] = None,
training_scope: str = 'all_stores_all_products',
aggregation_method: str = 'sum',
**kwargs) -> str:
"""提交训练任务"""
task_id = str(uuid.uuid4())
task = TrainingTask(
task_id=task_id,
product_id=product_id,
model_type=model_type,
training_mode=training_mode,
store_id=store_id,
epochs=epochs
epochs=epochs,
product_ids=product_ids,
product_scope=product_scope,
store_ids=store_ids,
training_scope=training_scope,
aggregation_method=aggregation_method
)
with self.lock:
self.tasks[task_id] = task
# 将任务放入队列
self.task_queue.put(asdict(task))

View File

@ -1,803 +1,60 @@
### 根目录启动
**1**:`uv venv`
**2**:`uv pip install loguru numpy pandas torch matplotlib flask flask_cors flask_socketio flasgger scikit-learn tqdm pytorch_tcn pyarrow`
**3**: `uv run .\server\api.py`
### UI
**1**:`npm install` `npm run dev`
# 修改记录日志 (日期: 2025-07-16)
# “预测分析”模块UI重构修改记录
## 1. 训练流程与模型保存逻辑修复 (重大)
**任务目标**: 将原有的、通过下拉菜单切换模式的单一预测页面重构为通过左侧子导航切换模式的多页面布局使其UI结构与“模型训练”模块保持一致。
- **背景**: 用户报告在“按店铺”和“按药品”模式下,如果选择了特定的子集(如为某个店铺选择特定药品),生成的模型范围 (`scope`) 不正确,始终为 `_all`。此外,所有模型都被错误地保存到 `global` 目录下,且在某些模式下训练会失败。
- **根本原因**:
1. `server/core/predictor.py` 中负责准备训练参数的内部函数 (`_prepare_product_params`, `_prepare_store_params`) 逻辑有误,未能正确处理传入的 `product_ids``store_ids` 列表来构建详细的 `scope`
2. 各个训练器 (`server/trainers/*.py`) 内部的日志记录和元数据生成逻辑不统一,且过于依赖 `product_id`,导致在全局或店铺模式下信息展示不清晰。
- **修复方案**:
- **`server/core/predictor.py`**:
- **重构 `_prepare_product_params` 和 `_prepare_store_params`**: 修改了这两个函数,使其能够正确使用 `product_ids``store_ids` 列表。现在,当选择特定范围时,会生成更具描述性的 `scope`,例如 `S001_specific_P001_P002`
- **结果**: 确保了传递给模型管理器的 `scope` 是准确且详细的,从而使模型能够根据训练范围被保存到正确的、独立的文件夹中。
### 后端修复 (2025-07-13)
- **`server/trainers/*.py` (mlstm, kan, tcn, transformer)**:
- **标准化日志与元数据**: 对所有四个训练器文件进行了统一修改。引入了一个通用的 `training_description` 变量,该变量整合了 `training_mode``scope``aggregation_method`
- **更新输出**: 修改了所有训练器中的日志消息、图表标题和 `metadata.json` 的生成逻辑,使其全部使用这个标准的 `training_description`
- **结果**: 确保了无论在哪种训练模式下,前端收到的日志、保存的图表和元数据都具有一致、清晰的格式,便于调试和结果追溯。
**任务目标**: 解决模型训练时因数据文件路径错误导致的数据加载失败问题。
- **核心问题**: `server/core/predictor.py` 中的 `PharmacyPredictor` 类初始化时,硬编码了错误的默认数据文件路径 (`'pharmacy_sales_multi_store.csv'`)。
- **修复方案**:
1. 修改 `server/core/predictor.py`,将默认数据路径更正为 `'data/timeseries_training_data_sample_10s50p.parquet'`
2. 同步更新了 `server/trainers/mlstm_trainer.py` 中所有对数据加载函数的调用,确保使用正确的文件路径。
- **结果**: 彻底解决了在独立训练进程中数据加载失败的问题。
---
### 后端修复 (2025-07-13) - 数据流重构
**任务目标**: 解决因数据处理流程中断导致 `sales``price` 关键特征丢失,从而引发模型训练失败的根本问题。
- **核心问题**:
1. `server/core/predictor.py` 中的 `train_model` 方法在调用训练器(如 `train_product_model_with_mlstm`)时,没有将预处理好的数据传递过去。
2. `server/trainers/mlstm_trainer.py` 因此被迫重新加载和处理数据,但其使用的数据标准化函数 `standardize_column_names` 存在逻辑缺陷,导致关键列丢失。
- **修复方案 (数据流重构)**:
1. **修改 `server/trainers/mlstm_trainer.py`**:
- 重构 `train_product_model_with_mlstm` 函数,使其能够接收一个预处理好的 DataFrame (`product_df`) 作为参数。
- 移除了函数内部所有的数据加载和重复处理逻辑。
2. **修改 `server/core/predictor.py`**:
- 在 `train_model` 方法中,将已经加载并处理好的 `product_data` 作为参数,显式传递给 `train_product_model_with_mlstm` 函数。
3. **修改 `server/utils/multi_store_data_utils.py`**:
- 在 `standardize_column_names` 函数中,使用 Pandas 的 `rename` 方法强制进行列名转换,确保 `quantity_sold``unit_price` 被可靠地重命名为 `sales``price`
- **结果**: 彻底修复了数据处理流程,确保数据只被加载和标准化一次,并被正确传递,从根本上解决了模型训练失败的问题。
---
### 第一次重构 (多页面、双栏布局)
- **新增文件**:
- `UI/src/views/prediction/ProductPredictionView.vue`
- `UI/src/views/prediction/StorePredictionView.vue`
- `UI/src/views/prediction/GlobalPredictionView.vue`
- **修改文件**:
- `UI/src/router/index.js`: 添加了指向新页面的路由。
- `UI/src/App.vue`: 将“预测分析”修改为包含三个子菜单的父菜单。
- **总体影响**: 此次修复从根本上解决了模型训练范围处理和模型保存路径的错误问题,使整个训练系统在所有模式下都能可靠、一致地运行。
---
### 第二次重构 (基于用户反馈的单页面布局)
## 2. 核心 Bug 修复
**任务目标**: 统一三个预测子页面的布局,采用旧的单页面预测样式,并将导航功能与页面内容解耦。
### 文件: `server/core/predictor.py`
- **修改文件**:
- **`UI/src/views/prediction/ProductPredictionView.vue`**:
- **内容**: 使用 `UI/src/views/NewPredictionView.vue` 的布局进行替换。
- **逻辑**: 移除了“模型训练方式”选择器,并将该页面的预测模式硬编码为 `product`
- **`UI/src/views/prediction/StorePredictionView.vue`**:
- **内容**: 使用 `UI/src/views/NewPredictionView.vue` 的布局进行替换。
- **逻辑**: 移除了“模型训练方式”选择器,并将该页面的预测模式硬编码为 `store`
- **`UI/src/views/prediction/GlobalPredictionView.vue`**:
- **内容**: 使用 `UI/src/views/NewPredictionView.vue` 的布局进行替换。
- **逻辑**: 移除了“模型训练方式”及特定目标选择器,并将该页面的预测模式硬编码为 `global`
- **问题**: 在 `train_model` 方法中调用内部辅助函数 `_prepare_training_params` 时,没有正确传递 `product_ids``store_ids` 参数,导致在 `_prepare_training_params` 内部发生 `NameError`
- **修复**:
- 修正了 `train_model` 方法内部对 `_prepare_training_params` 的调用,确保 `product_ids``store_ids` 被显式传递。
- 此前已修复 `train_model` 的函数签名,使其能正确接收 `store_ids`
- **结果**: 彻底解决了训练流程中的参数传递问题,根除了由此引发的 `NameError`
---
## 3. 代码清理与重构
**总结**: 通过两次重构,最终实现了使用左侧导航栏切换预测模式,同时右侧内容区域保持统一、简洁的单页面布局,完全符合用户的最终要求。
### 文件: `server/api.py`
- **内容**: 移除了在 `start_training` API 端点中遗留的旧版、基于线程(`threading.Thread`)的训练逻辑。
- **原因**: 该代码块已被新的、基于多进程(`multiprocessing`)的 `TrainingProcessManager` 完全取代。旧代码中包含了大量用于调试的 `thread_safe_print` 日志,已无用处。
- **结果**: `start_training` 端点的逻辑变得更加清晰,只负责参数校验和向 `TrainingProcessManager` 提交任务。
### 文件: `server/utils/training_process_manager.py`
- **内容**: 在 `TrainingWorker``run_training_task` 方法中,移除了一个用于模拟训练进度的 `for` 循环。
- **原因**: 该循环包含 `time.sleep(1)`,仅用于在没有实际训练逻辑时模拟进度更新,现在实际的训练器会通过回调函数报告真实进度,因此该模拟代码不再需要。
- **结果**: `TrainingWorker` 现在直接调用实际的训练器,不再有模拟延迟,代码更贴近生产环境。
## 4. 启动依赖
---
**按药品训练修改**
**日期**: 2025-07-14
**文件**: `server/trainers/mlstm_trainer.py`
**问题**: 模型训练因 `KeyError: "['sales', 'price'] not in index"` 失败。
**分析**:
1. `'price'` 列在提供的数据中不存在,导致 `KeyError`
2. `'sales'` 列作为历史输入(自回归特征)对于模型训练是必要的。
**解决方案**: 从 `mlstm_trainer` 的特征列表中移除了不存在的 `'price'` 列,保留了 `'sales'` 列用于自回归。
---
**日期**: 2025-07-14 (补充)
**文件**:
* `server/trainers/transformer_trainer.py`
* `server/trainers/tcn_trainer.py`
* `server/trainers/kan_trainer.py`
**问题**: 预防性修复。这些文件存在与 `mlstm_trainer.py` 相同的 `KeyError` 隐患。
**分析**: 经过检查,这些训练器与 `mlstm_trainer` 共享相同的数据处理逻辑,其硬编码的特征列表中都包含了不存在的 `'price'` 列。
**解决方案**: 统一从所有相关训练器的特征列表中移除了 `'price'` 列,以确保所有模型训练的健壮性。
---
**日期**: 2025-07-14 (深度修复)
**文件**: `server/utils/multi_store_data_utils.py`
**问题**: 追踪 `KeyError: "['sales'] not in index"` 时,发现数据标准化流程存在多个问题。
**分析**:
1. 通过 `uv run` 读取了 `.parquet` 数据文件,确认了原始列名。
2. 发现 `standardize_column_names` 函数中的重命名映射与原始列名不匹配 (例如 `quantity_sold` vs `sales_quantity`)。
3. 确认了原始数据中没有 `price` 列,但代码中存在对它的依赖。
4. 函数缺乏一个明确的返回列选择机制,导致 `sales` 列在数据准备阶段被意外丢弃。
**解决方案**:
1. 修正了 `rename_map` 以正确匹配原始数据列名 (`sales_quantity` -> `sales`, `temperature_2m_mean` -> `temperature`, `dayofweek` -> `weekday`)。
2. 移除了对不存在的 `price` 列的依赖。
3. 在函数末尾添加了逻辑,确保返回的 `DataFrame` 包含所有模型训练所需的标准列(特征 + 目标),保证了数据流的稳定性。
4. 原始数据列名:['date', 'store_id', 'product_id', 'sales_quantity', 'sales_amount', 'gross_profit', 'customer_traffic', 'store_name', 'city', 'product_name', 'manufacturer', 'category_l1', 'category_l2', 'category_l3', 'abc_category', 'temperature_2m_mean', 'temperature_2m_max', 'temperature_2m_min', 'year', 'month', 'day', 'dayofweek', 'dayofyear', 'weekofyear', 'is_weekend', 'sl_lag_7', 'sl_lag_14', 'sl_rolling_mean_7', 'sl_rolling_std_7', 'sl_rolling_mean_14', 'sl_rolling_std_14']
---
**日期**: 2025-07-14 10:16
**主题**: 修复模型训练中的 `KeyError` 及数据流问题 (详细版)
### 阶段一:修复训练器层 `KeyError`
* **问题**: 模型训练因 `KeyError: "['sales', 'price'] not in index"` 失败。
* **分析**: 训练器硬编码的特征列表中包含了数据源中不存在的 `'price'` 列。
* **涉及文件**:
* `server/trainers/mlstm_trainer.py`
* `server/trainers/transformer_trainer.py`
* `server/trainers/tcn_trainer.py`
* `server/trainers/kan_trainer.py`
* **修改详情**:
* **位置**: 每个训练器文件中的 `features` 列表定义处。
* **操作**: 修改。
* **内容**:
```diff
- features = ['sales', 'price', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
+ features = ['sales', 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature']
```
* **原因**: 移除对不存在的 `'price'` 列的依赖,解决 `KeyError`
### 阶段二:修复数据标准化层
* **问题**: 修复后出现新错误 `KeyError: "['sales'] not in index"`,表明数据标准化流程存在缺陷。
* **分析**: 通过 `uv run` 读取 `.parquet` 文件确认,`standardize_column_names` 函数中的列名映射错误,且缺少最终列选择机制。
* **涉及文件**: `server/utils/multi_store_data_utils.py`
* **修改详情**:
1. **位置**: `standardize_column_names` 函数, `rename_map` 字典。
* **操作**: 修改。
* **内容**:
```diff
- rename_map = { 'quantity_sold': 'sales', 'unit_price': 'price', 'day_of_week': 'weekday' }
+ rename_map = { 'sales_quantity': 'sales', 'temperature_2m_mean': 'temperature', 'dayofweek': 'weekday' }
```
* **原因**: 修正键名以匹配数据源的真实列名 (`sales_quantity`, `temperature_2m_mean`, `dayofweek`)。
2. **位置**: `standardize_column_names` 函数, `sales_amount` 计算部分。
* **操作**: 修改 (注释)。
* **内容**:
```diff
- if 'sales_amount' not in df.columns and 'sales' in df.columns and 'price' in df.columns:
- df['sales_amount'] = df['sales'] * df['price']
+ # 由于没有price列sales_amount的计算逻辑需要调整或移除
+ # if 'sales_amount' not in df.columns and 'sales' in df.columns and 'price' in df.columns:
+ # df['sales_amount'] = df['sales'] * df['price']
```
* **原因**: 避免因缺少 `'price'` 列而导致潜在错误。
3. **位置**: `standardize_column_names` 函数, `numeric_columns` 列表。
* **操作**: 删除。
* **内容**:
```diff
- numeric_columns = ['sales', 'price', 'sales_amount', 'weekday', 'month', 'temperature']
+ numeric_columns = ['sales', 'sales_amount', 'weekday', 'month', 'temperature']
```
* **原因**: 从数值类型转换列表中移除不存在的 `'price'` 列。
4. **位置**: `standardize_column_names` 函数, `return` 语句前。
* **操作**: 增加。
* **内容**:
```diff
+ # 定义模型训练所需的所有列(特征 + 目标)
+ final_columns = [
+ 'date', 'sales', 'product_id', 'product_name', 'store_id', 'store_name',
+ 'weekday', 'month', 'is_holiday', 'is_weekend', 'is_promotion', 'temperature'
+ ]
+ # 筛选出DataFrame中实际存在的列
+ existing_columns = [col for col in final_columns if col in df.columns]
+ # 返回只包含这些必需列的DataFrame
+ return df[existing_columns]
```
* **原因**: 增加列选择机制,确保函数返回的 `DataFrame` 结构统一且包含 `sales` 列,从根源上解决 `KeyError: "['sales'] not in index"`
### 阶段三:修复数据流分发层
* **问题**: `predictor.py` 未将处理好的数据统一传递给所有训练器。
* **分析**: `train_model` 方法中,只有 `mlstm` 的调用传递了 `product_df`,其他模型则没有,导致它们重新加载未处理的数据。
* **涉及文件**: `server/core/predictor.py`
* **修改详情**:
* **位置**: `train_model` 方法中对 `train_product_model_with_transformer`, `_tcn`, `_kan` 的调用处。
* **操作**: 增加。
* **内容**: 在函数调用中增加了 `product_df=product_data` 参数。
```diff
- model_result, metrics, actual_version = train_product_model_with_transformer(product_id, ...)
+ model_result, metrics, actual_version = train_product_model_with_transformer(product_id=product_id, product_df=product_data, ...)
```
*(对 `tcn``kan` 的调用也做了类似修改)*
* **原因**: 统一数据流,确保所有训练器都使用经过正确预处理的、包含完整信息的 `DataFrame`
### 阶段四:适配训练器以接收数据
* **问题**: `transformer`, `tcn`, `kan` 训练器需要能接收上游传来的数据。
* **分析**: 需要修改这三个训练器的函数签名和内部逻辑,使其在接收到 `product_df` 时跳过数据加载。
* **涉及文件**: `server/trainers/transformer_trainer.py`, `tcn_trainer.py`, `kan_trainer.py`
* **修改详情**:
1. **位置**: 每个训练器主函数的定义处。
* **操作**: 增加。
* **内容**: 在函数参数中增加了 `product_df=None`
```diff
- def train_product_model_with_transformer(product_id, ...)
+ def train_product_model_with_transformer(product_id, product_df=None, ...)
```
2. **位置**: 每个训练器内部的数据加载逻辑处。
* **操作**: 增加。
* **内容**: 增加了 `if product_df is None:` 的判断逻辑,只有在未接收到数据时才执行内部加载。
```diff
+ if product_df is None:
- # 根据训练模式加载数据
- from utils.multi_store_data_utils import load_multi_store_data
- ...
+ # [原有的数据加载逻辑]
+ else:
+ # 如果传入了product_df直接使用
+ ...
```
* **原因**: 完成数据流修复的最后一环,使训练器能够灵活地接收外部数据或自行加载,彻底解决问题。
---
**日期**: 2025-07-14 10:38
**主题**: 修复因NumPy类型导致的JSON序列化失败问题
### 阶段五:修复前后端通信层
* **问题**: 模型训练成功后后端向前端发送包含训练指标metrics的WebSocket消息或API响应时失败导致前端状态无法更新为“已完成”。
* **日志错误**: `Object of type float32 is not JSON serializable`
* **分析**: 训练过程产生的评估指标(如 `mse`, `rmse`是NumPy的 `float32` 类型。Python标准的 `json` 库无法直接序列化这种类型导致在通过WebSocket或HTTP API发送数据时出错。
* **涉及文件**: `server/utils/training_process_manager.py`
* **修改详情**:
1. **位置**: 文件顶部。
* **操作**: 增加。
* **内容**:
```python
import numpy as np
def convert_numpy_types(obj):
"""递归地将字典/列表中的NumPy类型转换为Python原生类型"""
if isinstance(obj, dict):
return {k: convert_numpy_types(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_numpy_types(i) for i in obj]
elif isinstance(obj, np.generic):
return obj.item()
return obj
```
* **原因**: 添加一个通用的辅助函数用于将包含NumPy类型的数据结构转换为JSON兼容的格式。
2. **位置**: `_monitor_results` 方法内部,调用 `self.websocket_callback` 之前。
* **操作**: 增加。
* **内容**:
```diff
+ serializable_task_data = convert_numpy_types(task_data)
- self.websocket_callback('training_update', { ... 'metrics': task_data.get('metrics'), ... })
+ self.websocket_callback('training_update', { ... 'metrics': serializable_task_data.get('metrics'), ... })
```
* **原因**: 在通过WebSocket发送数据之前调用 `convert_numpy_types` 函数对包含训练结果的 `task_data` 进行处理,确保所有 `float32` 等类型都被转换为Python原生的 `float`,从而解决序列化错误。
**总结**: 通过在数据发送前进行类型转换,彻底解决了前后端通信中的序列化问题,确保了训练状态能够被正确地更新到前端。
---
**日期**: 2025-07-14 11:04
**主题**: 根治JSON序列化问题
### 阶段六修复API层序列化错误
* **问题**: 在修复WebSocket的序列化问题后发现直接轮询 `GET /api/training` 接口时,仍然出现 `Object of type float32 is not JSON serializable` 错误。
* **分析**: 上一阶段的修复只转换了准备通过WebSocket发送的数据但没有转换**存放在 `TrainingProcessManager` 内部 `self.tasks` 字典中的数据**。因此当API通过 `get_all_tasks()` 方法读取这个字典时获取到的仍然是包含NumPy类型的原始数据导致 `jsonify` 失败。
* **涉及文件**: `server/utils/training_process_manager.py`
* **修改详情**:
* **位置**: `_monitor_results` 方法,从 `result_queue` 获取数据之后。
* **操作**: 调整逻辑。
* **内容**:
```diff
- with self.lock:
- # ... 更新 self.tasks ...
- if self.websocket_callback:
- serializable_task_data = convert_numpy_types(task_data)
- # ... 使用 serializable_task_data 发送消息 ...
+ # 立即对从队列中取出的数据进行类型转换
+ serializable_task_data = convert_numpy_types(task_data)
+ with self.lock:
+ # 使用转换后的数据更新任务状态
+ for key, value in serializable_task_data.items():
+ setattr(self.tasks[task_id], key, value)
+ # WebSocket通知 - 使用已转换的数据
+ if self.websocket_callback:
+ # ... 使用 serializable_task_data 发送消息 ...
```
* **原因**: 将类型转换的步骤提前,确保存入 `self.tasks` 的数据已经是JSON兼容的。这样无论是通过WebSocket推送还是通过API查询获取到的都是安全的数据从根源上解决了所有序列化问题。
**最终总结**: 至此,所有已知的数据流和数据类型问题均已解决。
---
**日期**: 2025-07-14 11:15
**主题**: 修复模型评估中的MAPE计算错误
### 阶段七:修复评估指标计算
* **问题**: 训练 `transformer` 模型时,日志显示 `MAPE: nan%` 并伴有 `RuntimeWarning: Mean of empty slice.`
* **分析**: `MAPE` (平均绝对百分比误差) 的计算涉及除以真实值。当测试集中的所有真实销量(`y_true`都为0时用于避免除零错误的 `mask` 会导致一个空数组被传递给 `np.mean()`,从而产生 `nan` 和运行时警告。
* **涉及文件**: `server/analysis/metrics.py`
* **修改详情**:
* **位置**: `evaluate_model` 函数中计算 `mape` 的部分。
* **操作**: 增加条件判断。
* **内容**:
```diff
- mask = y_true != 0
- mape = np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100
+ mask = y_true != 0
+ if np.any(mask):
+ mape = np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100
+ else:
+ # 如果所有真实值都为0无法计算MAPE返回0
+ mape = 0.0
```
* **原因**: 在计算MAPE之前先检查是否存在任何非零的真实值。如果不存在则直接将MAPE设为0避免了对空数组求平均值从而解决了 `nan``RuntimeWarning` 的问题。
## 2025-07-14 11:41修复“按店铺训练”页面店铺列表加载失败问题
**问题描述:**
在“模型训练” -> “按店铺训练”页面中,“选择店铺”的下拉列表为空,无法加载任何店铺信息。
**根本原因:**
位于 `server/utils/multi_store_data_utils.py``standardize_column_names` 函数在标准化数据后,错误地移除了包括店铺元数据在内的非训练必需列。这导致调用该函数的 `get_available_stores` 函数无法获取到完整的店铺信息,最终返回一个空列表。
**解决方案:**
本着最小改动和保持代码清晰的原则,我进行了以下重构:
1. **净化 `standardize_column_names` 函数**:移除了其中所有与列筛选相关的代码,使其只专注于数据标准化这一核心职责。
2. **精确应用筛选逻辑**:将列筛选的逻辑精确地移动到了 `get_store_product_sales_data``aggregate_multi_store_data` 这两个为模型训练准备数据的函数中。这确保了只有在需要为模型准备数据时,才会执行列筛选。
3. **增强 `get_available_stores` 函数**:由于 `load_multi_store_data` 现在可以返回所有列,`get_available_stores` 将能够正常工作。同时,我增强了其代码的健壮性,以优雅地处理数据文件中可能存在的列缺失问题。
**代码变更:**
- **文件:** `server/utils/multi_store_data_utils.py`
- **主要改动:**
- 从 `standardize_column_names` 中移除列筛选逻辑。
- 在 `get_store_product_sales_data``aggregate_multi_store_data` 中添加列筛选逻辑。
- 重写 `get_available_stores` 以更健壮地处理数据。
---
**日期**: 2025-07-14 13:00
**主题**: 修复“按店铺训练-所有药品”模式下的训练失败问题
### 问题描述
在“模型训练” -> “按店铺训练”页面,当选择“所有药品”进行训练时,后端日志显示 `获取店铺产品数据失败: 没有找到店铺 [store_id] 产品 unknown 的销售数据`,导致训练任务失败。
### 根本原因
1. **API层**: `server/api.py` 在处理来自前端的训练请求时,如果 `product_id``null`(对应“所有药品”选项),会执行 `product_id or "unknown"`错误地将产品ID设置为字符串 `"unknown"`
2. **预测器层**: `server/core/predictor.py` 中的 `train_model` 方法接收到无效的 `product_id="unknown"`尝试使用它来获取数据但数据源中不存在ID为“unknown”的产品导致数据加载失败。
3. **数据工具层**: `server/utils/multi_store_data_utils.py` 中的 `aggregate_multi_store_data` 函数只支持按产品ID进行全局聚合不支持按店铺ID聚合其下所有产品的数据。
### 解决方案 (保留"unknown"字符串)
为了在不改变API层行为的前提下解决问题采用了在下游处理这个特殊值的策略
1. **修改 `server/core/predictor.py`**:
* **位置**: `train_model` 方法。
* **操作**: 增加了对 `product_id == 'unknown'` 的特殊处理逻辑。
* **内容**:
```python
# 如果product_id是'unknown',则表示为店铺所有商品训练一个聚合模型
if product_id == 'unknown':
try:
# 使用聚合函数,按店铺聚合
product_data = aggregate_multi_store_data(
store_id=store_id,
aggregation_method=aggregation_method,
file_path=self.data_path
)
# 将product_id设置为店铺ID以便模型保存时使用有意义的标识
product_id = store_id
except Exception as e:
# ... 错误处理 ...
else:
# ... 原有的按单个产品获取数据的逻辑 ...
```
* **原因**: 在预测器层面拦截无效的 `"unknown"` ID并将其意图正确地转换为“聚合此店铺的所有产品数据”。同时`product_id` 重新赋值为 `store_id`,确保了后续模型保存时能使用一个唯一且有意义的名称(如 `store_01010023_mlstm_v1.pth`)。
2. **修改 `server/utils/multi_store_data_utils.py`**:
* **位置**: `aggregate_multi_store_data` 函数。
* **操作**: 重构函数签名和内部逻辑。
* **内容**:
```python
def aggregate_multi_store_data(product_id: Optional[str] = None,
store_id: Optional[str] = None,
aggregation_method: str = 'sum',
...)
# ...
if store_id:
# 店铺聚合:加载该店铺的所有数据
df = load_multi_store_data(file_path, store_id=store_id)
# ...
elif product_id:
# 全局聚合:加载该产品的所有数据
df = load_multi_store_data(file_path, product_id=product_id)
# ...
else:
raise ValueError("必须提供 product_id 或 store_id")
```
* **原因**: 扩展了数据聚合函数的功能,使其能够根据传入的 `store_id` 参数,加载并聚合特定店铺的所有销售数据,为店铺级别的综合模型训练提供了数据基础。
**最终结果**: 通过这两处修改,系统现在可以正确处理“按店铺-所有药品”的训练请求。它会聚合该店铺所有产品的销售数据训练一个综合模型并以店铺ID为标识来保存该模型彻底解决了该功能点的训练失败问题。
---
**日期**: 2025-07-14 14:19
**主题**: 修复并发训练中的稳定性和日志错误
### 阶段八:修复并发训练中的多个错误
* **问题**: 在并发执行多个训练任务时,系统出现 `JSON序列化错误``API列表排序错误``WebSocket连接错误`
* **分析**:
1. **`Object of type float32 is not JSON serializable`**: `training_process_manager.py` 在通过WebSocket发送**中途**的训练进度时没有对包含NumPy `float32` 类型的 `metrics` 数据进行序列化。
2. **`'<' not supported between instances of 'str' and 'NoneType'`**: `api.py` 在获取训练任务列表时,对 `start_time` 进行排序,但未处理某些任务的 `start_time` 可能为 `None` 的情况,导致 `TypeError`
3. **`AssertionError: write() before start_response`**: `api.py` 中,当以 `debug=True` 模式运行时Flask内置的Werkzeug服务器的调试器与Socket.IO的连接管理机制发生冲突。
* **解决方案**:
1. **文件**: `server/utils/training_process_manager.py`
* **位置**: `_monitor_progress` 方法。
* **操作**: 在发送 `training_progress` 事件前,调用 `convert_numpy_types` 函数对 `progress_data` 进行完全序列化。
* **原因**: 确保所有通过WebSocket发送的数据包括中途进度都是JSON兼容的彻底解决序列化问题。
2. **文件**: `server/api.py`
* **位置**: `get_all_training_tasks` 函数。
* **操作**: 修改 `sorted` 函数的 `key`,使用 `lambda x: x.get('start_time') or '1970-01-01 00:00:00'`
* **原因**: 为 `None` 类型的 `start_time` 提供一个有效的默认值,使其可以和字符串类型的日期进行安全比较,解决了排序错误。
3. **文件**: `server/api.py`
* **位置**: `socketio.run()` 调用处。
* **操作**: 增加 `allow_unsafe_werkzeug=True if args.debug else False` 参数。
* **原因**: 这是 `Flask-SocketIO` 官方推荐的解决方案用于在调试模式下协调Werkzeug与Socket.IO的事件循环避免底层WSGI错误。
**最终结果**: 通过这三项修复,系统的并发稳定性和健壮性得到显著提升,解决了在高并发训练场景下出现的各类错误。
---
**日期**: 2025-07-14 14:48
**主题**: 修复模型评估指标计算错误并优化训练过程
### 阶段九:修复模型评估与训练优化
* **问题**: 所有模型训练完成后,评估指标 `R²` 始终为0.0`MAPE` 始终为0.00%,这表明模型评估或训练过程存在严重问题。
* **分析**:
1. **核心错误**: 在 `mlstm_trainer.py``transformer_trainer.py` 中,计算损失函数时,模型输出 `outputs` 的维度是 `(batch_size, forecast_horizon)`,而目标 `y_batch` 的维度被错误地通过 `unsqueeze(-1)` 修改为 `(batch_size, forecast_horizon, 1)`。这种维度不匹配导致损失计算错误,模型无法正确学习。
2. **优化缺失**: 训练过程中缺少学习率调度、梯度裁剪和提前停止等关键的优化策略,影响了训练效率和稳定性。
* **解决方案**:
1. **修复维度不匹配 (关键修复)**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **位置**: 训练和验证循环中的损失计算部分。
* **操作**: 移除了对 `y_batch``unsqueeze(-1)` 操作,确保 `outputs``y_batch` 维度一致。
```diff
- loss = criterion(outputs, y_batch.unsqueeze(-1))
+ loss = criterion(outputs, y_batch.squeeze(-1) if y_batch.dim() == 3 else y_batch)
```
* **原因**: 修正损失函数的输入使模型能够根据正确的误差进行学习从而解决评估指标恒为0的问题。
2. **增加训练优化策略**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 在两个训练器中增加了以下功能:
* **学习率调度器**: 引入 `torch.optim.lr_scheduler.ReduceLROnPlateau`,当测试损失停滞时自动降低学习率。
* **梯度裁剪**: 在优化器更新前,使用 `torch.nn.utils.clip_grad_norm_` 对梯度进行裁剪,防止梯度爆炸。
* **提前停止**: 增加了 `patience` 参数当测试损失连续多个epoch未改善时提前终止训练防止过拟合。
* **原因**: 引入这些业界标准的优化技术,可以显著提高训练过程的稳定性、收敛速度和最终的模型性能。
**最终结果**: 通过修复核心的逻辑错误并引入多项优化措施,模型现在不仅能够正确学习,而且训练过程更加健壮和高效。
---
**日期**: 2025-07-14 15:20
**主题**: 根治模型维度错误并统一数据流 (完整调试过程)
### 阶段九:错误的修复尝试 (记录备查)
* **问题**: 所有模型训练完成后,评估指标 `R²` 始终为0.0`MAPE` 始终为0.00%。
* **初步分析**: 怀疑损失函数计算时,`outputs``y_batch` 维度不匹配。
* **错误的假设**: 当时错误地认为是 `y_batch` 的维度有问题,而 `outputs` 的维度是正确的。
* **错误的修复**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 尝试在训练器层面使用 `squeeze` 调整 `y_batch` 的维度来匹配 `outputs`
```diff
- loss = criterion(outputs, y_batch)
+ loss = criterion(outputs, y_batch.squeeze(-1) if y_batch.dim() == 3 else y_batch)
```
* **结果**: 此修改导致了新的运行时错误 `UserWarning: Using a target size (torch.Size([32, 3])) that is different to the input size (torch.Size([32, 3, 1]))`,证明了修复方向错误,但帮助定位了问题的真正根源。
### 阶段十:根治维度不匹配问题
* **问题**: 深入分析阶段九的错误后,确认了问题的根源。
* **根本原因**: `server/models/mlstm_model.py` 中的 `MLSTMTransformer` 模型,其 `forward` 方法的最后一层输出了一个多余的维度,导致其输出形状为 `(B, H, 1)`,而并非期望的 `(B, H)`
* **正确的解决方案 (端到端维度一致性)**:
1. **修复模型层 (治本)**:
* **文件**: `server/models/mlstm_model.py`
* **位置**: `MLSTMTransformer``forward` 方法。
* **操作**: 在 `output_layer` 之后增加 `.squeeze(-1)`,将模型输出的维度从 `(B, H, 1)` 修正为 `(B, H)`
```diff
- return self.output_layer(decoder_outputs)
+ return self.output_layer(decoder_outputs).squeeze(-1)
```
2. **净化训练器层 (治标)**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 撤销了阶段九的错误修改,恢复为最直接的损失计算 `loss = criterion(outputs, y_batch)`
3. **优化评估逻辑**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 简化了模型评估部分的反归一化逻辑,使其更清晰、更直接地处理 `(样本数, 预测步长)` 形状的数据。
```diff
- test_pred_inv = scaler_y.inverse_transform(test_pred.reshape(-1, 1)).flatten()
- test_true_inv = scaler_y.inverse_transform(testY.reshape(-1, 1)).flatten()
+ test_pred_inv = scaler_y.inverse_transform(test_pred)
+ test_true_inv = scaler_y.inverse_transform(test_true)
```
**最终结果**: 通过记录整个调试过程,我们不仅修复了问题,还理解了其根本原因。通过在模型源头修正维度,并在整个数据流中保持维度一致性,彻底解决了训练失败的问题。代码现在更简洁、健壮,并遵循了良好的设计实践。
---
**日期**: 2025-07-14 15:30
**主题**: 根治模型维度错误并统一数据流 (完整调试过程)
### 阶段九:错误的修复尝试 (记录备查)
* **问题**: 所有模型训练完成后,评估指标 `R²` 始终为0.0`MAPE` 始终为0.00%。
* **初步分析**: 怀疑损失函数计算时,`outputs``y_batch` 维度不匹配。
* **错误的假设**: 当时错误地认为是 `y_batch` 的维度有问题,而 `outputs` 的维度是正确的。
* **错误的修复**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 尝试在训练器层面使用 `squeeze` 调整 `y_batch` 的维度来匹配 `outputs`
```diff
- loss = criterion(outputs, y_batch)
+ loss = criterion(outputs, y_batch.squeeze(-1) if y_batch.dim() == 3 else y_batch)
```
* **结果**: 此修改导致了新的运行时错误 `UserWarning: Using a target size (torch.Size([32, 3])) that is different to the input size (torch.Size([32, 3, 1]))`,证明了修复方向错误,但帮助定位了问题的真正根源。
### 阶段十:根治维度不匹配问题
* **问题**: 深入分析阶段九的错误后,确认了问题的根源在于模型输出维度。
* **根本原因**: `server/models/mlstm_model.py` 中的 `MLSTMTransformer` 模型,其 `forward` 方法的最后一层输出了一个多余的维度,导致其输出形状为 `(B, H, 1)`,而并非期望的 `(B, H)`
* **正确的解决方案 (端到端维度一致性)**:
1. **修复模型层 (治本)**:
* **文件**: `server/models/mlstm_model.py`
* **位置**: `MLSTMTransformer``forward` 方法。
* **操作**: 在 `output_layer` 之后增加 `.squeeze(-1)`,将模型输出的维度从 `(B, H, 1)` 修正为 `(B, H)`
2. **净化训练器层 (治标)**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 撤销了阶段九的错误修改,恢复为最直接的损失计算 `loss = criterion(outputs, y_batch)`
3. **优化评估逻辑**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **操作**: 简化了模型评估部分的反归一化逻辑,使其更清晰、更直接地处理 `(样本数, 预测步长)` 形状的数据。
### 阶段十一:最终修复与逻辑统一
* **问题**: 在应用阶段十的修复后训练仍然失败。mLSTM出现维度反转错误 (`target size (B, H, 1)` vs `input size (B, H)`)而Transformer则出现评估错误 (`'numpy.ndarray' object has no attribute 'numpy'`)。
* **分析**:
1. **维度反转根源**: 问题的最终根源在 `server/utils/data_utils.py``create_dataset` 函数。它在创建目标数据集 `dataY` 时,错误地保留了一个多余的维度,导致 `y_batch` 的形状变为 `(B, H, 1)`
2. **评估Bug**: 在 `mlstm_trainer.py``transformer_trainer.py` 的评估部分,代码 `test_true = testY.numpy()` 是错误的,因为 `testY` 已经是Numpy数组。
* **最终解决方案 (端到端修复)**:
1. **修复数据加载层 (治本)**:
* **文件**: `server/utils/data_utils.py`
* **位置**: `create_dataset` 函数。
* **操作**: 修改 `dataY.append(y)``dataY.append(y.flatten())`,从源头上确保 `y` 标签的维度是正确的 `(B, H)`
2. **修复训练器评估层**:
* **文件**: `server/trainers/mlstm_trainer.py`, `server/trainers/transformer_trainer.py`
* **位置**: 模型评估部分。
* **操作**: 修正 `test_true = testY.numpy()``test_true = testY`,解决了属性错误。
**最终结果**: 通过记录并分析整个调试过程(阶段九到十一),我们最终定位并修复了从数据加载、模型设计到训练器评估的整个流程中的维度不一致问题。代码现在更加简洁、健壮,并遵循了端到端维度一致的良好设计实践。
---
**日期**: 2025-07-14 15:34
**主题**: 扩展维度修复至Transformer模型
### 阶段十二:统一所有模型的输出维度
* **问题**: 在修复 `mLSTM` 模型后,`Transformer` 模型的训练仍然因为完全相同的维度不匹配问题而失败。
* **分析**: `server/models/transformer_model.py` 中的 `TimeSeriesTransformer` 类也存在与 `mLSTM` 相同的设计缺陷,其 `forward` 方法的输出维度为 `(B, H, 1)` 而非 `(B, H)`
* **解决方案**:
1. **修复Transformer模型层**:
* **文件**: `server/models/transformer_model.py`
* **位置**: `TimeSeriesTransformer``forward` 方法。
* **操作**: 在 `output_layer` 之后增加 `.squeeze(-1)`,将模型输出的维度从 `(B, H, 1)` 修正为 `(B, H)`
```diff
- return self.output_layer(decoder_outputs)
+ return self.output_layer(decoder_outputs).squeeze(-1)
```
**最终结果**: 通过将维度修复方案应用到所有相关的模型文件,我们确保了整个系统的模型层都遵循了统一的、正确的输出维度标准。至此,所有已知的维度相关问题均已从根源上解决。
---
**日期**: 2025-07-14 16:10
**主题**: 修复“全局模型训练-所有药品”模式下的训练失败问题
### 问题描述
在“全局模型训练”页面,当选择“所有药品”进行训练时,后端日志显示 `聚合全局数据失败: 没有找到产品 unknown 的销售数据`,导致训练任务失败。
### 根本原因
1. **API层 (`server/api.py`)**: 在处理全局训练请求时,如果前端未提供 `product_id`对应“所有药品”选项API层会执行 `product_id or "unknown"`错误地将产品ID设置为字符串 `"unknown"`
2. **预测器层 (`server/core/predictor.py`)**: `train_model` 方法接收到无效的 `product_id="unknown"` 后,在 `training_mode='global'` 分支下,直接将其传递给数据聚合函数。
3. **数据工具层 (`server/utils/multi_store_data_utils.py`)**: `aggregate_multi_store_data` 函数缺少处理“真正”全局聚合(即不按任何特定产品或店铺过滤)的逻辑,当收到 `product_id="unknown"` 时,它会尝试按一个不存在的产品进行过滤,最终导致失败。
### 解决方案 (遵循现有设计模式)
为了在不影响现有功能的前提下修复此问题,采用了与历史修复类似的、在中间层进行逻辑适配的策略。
1. **修改 `server/utils/multi_store_data_utils.py`**:
* **位置**: `aggregate_multi_store_data` 函数。
* **操作**: 扩展了函数功能。
* **内容**: 增加了新的逻辑分支。当 `product_id``store_id` 参数都为 `None` 时,函数现在会加载**所有**数据进行聚合,以支持真正的全局模型训练。
```python
# ...
elif product_id:
# 按产品聚合...
else:
# 真正全局聚合:加载所有数据
df = load_multi_store_data(file_path)
if len(df) == 0:
raise ValueError("数据文件为空,无法进行全局聚合")
grouping_entity = "所有产品"
```
* **原因**: 使数据聚合函数的功能更加完整和健壮,能够服务于真正的全局训练场景,同时不影响其原有的按店铺或按产品的聚合功能。
2. **修改 `server/core/predictor.py`**:
* **位置**: `train_model` 方法,`training_mode == 'global'` 的逻辑分支内。
* **操作**: 增加了对 `product_id == 'unknown'` 的特殊处理。
* **内容**:
```python
if product_id == 'unknown':
product_data = aggregate_multi_store_data(
product_id=None, # 传递None以触发真正的全局聚合
# ...
)
# 将product_id设置为一个有意义的标识符
product_id = 'all_products'
else:
# ...原有的按单个产品聚合的逻辑...
```
* **原因**: 在核心预测器层面拦截无效的 `"unknown"` ID并将其正确地解释为“聚合所有产品数据”的意图。通过向聚合函数传递 `product_id=None` 来调用新增强的全局聚合功能,并用一个有意义的标识符 `all_products` 来命名模型,确保了后续流程的正确执行。
**最终结果**: 通过这两处修改,系统现在可以正确处理“全局模型-所有药品”的训练请求,聚合所有产品的销售数据来训练一个通用的全局模型,彻底解决了该功能点的训练失败问题。
---
**日期**: 2025-07-14
**主题**: UI导航栏重构
### 描述
根据用户请求,对左侧功能导航栏进行了调整。
### 主要改动
1. **删除“数据管理”**:
* 从 `UI/src/App.vue` 的导航菜单中移除了“数据管理”项。
* 从 `UI/src/router/index.js` 中删除了对应的 `/data` 路由。
* 删除了视图文件 `UI/src/views/DataView.vue`
2. **提升“店铺管理”**:
* 将“店铺管理”菜单项在 `UI/src/App.vue` 中的位置提升,以填补原“数据管理”的位置,使其在导航中更加突出。
### 涉及文件
* `UI/src/App.vue`
* `UI/src/router/index.js`
* `UI/src/views/DataView.vue` (已删除)
**按药品模型预测**
---
**日期**: 2025-07-14
**主题**: 修复导航菜单高亮问题
### 描述
修复了首次进入或刷新页面时,左侧导航菜单项与当前路由不匹配导致不高亮的问题。
### 主要改动
* **文件**: `UI/src/App.vue`
* **修改**:
1. 引入 `useRoute``computed`
2. 创建了一个计算属性 `activeMenu`,其值动态地等于当前路由的路径 (`route.path`)。
3. 将 `el-menu` 组件的 `:default-active` 属性绑定到 `activeMenu`
### 结果
确保了导航菜单的高亮状态始终与当前页面的URL保持同步。
---
**日期**: 2025-07-15
**主题**: 修复硬编码文件路径问题,提高项目可移植性
### 问题描述
项目在从一台计算机迁移到另一台时,由于数据文件路径被硬编码在代码中,导致程序无法找到数据文件而运行失败。
### 根本原因
多个Python文件`predictor.py`, `multi_store_data_utils.py`)中直接写入了相对路径 `'data/timeseries_training_data_sample_10s50p.parquet'` 作为默认值。这种方式在不同运行环境下(如从根目录运行 vs 从子目录运行)会产生路径解析错误。
### 解决方案:集中配置,统一管理
1. **修改 `server/core/config.py` (核心)**:
* 动态计算并定义了一个全局变量 `PROJECT_ROOT`,它始终指向项目的根目录。
* 基于 `PROJECT_ROOT`,使用 `os.path.join` 创建了一个跨平台的、绝对的默认数据路径 `DEFAULT_DATA_PATH` 和模型保存路径 `DEFAULT_MODEL_DIR`
* 这确保了无论从哪个位置执行代码,路径总能被正确解析。
2. **修改 `server/utils/multi_store_data_utils.py`**:
* 从 `server/core/config` 导入 `DEFAULT_DATA_PATH`
* 将所有数据加载函数的 `file_path` 参数的默认值从硬编码的字符串改为 `None`
* 在函数内部,如果 `file_path``None`,则自动使用导入的 `DEFAULT_DATA_PATH`
* 移除了原有的、复杂的、为了猜测正确路径而编写的冗余代码。
3. **修改 `server/core/predictor.py`**:
* 同样从 `server/core/config` 导入 `DEFAULT_DATA_PATH`
* 在初始化 `PharmacyPredictor` 时,如果未提供数据路径,则使用导入的 `DEFAULT_DATA_PATH` 作为默认值。
### 最终结果
通过将数据源路径集中到唯一的配置文件中进行管理,彻底解决了因硬编码路径导致的可移植性问题。项目现在可以在任何环境下可靠地运行。
---
### 未来如何修改数据源(例如,连接到服务器数据库)
本次重构为将来更换数据源打下了坚实的基础。操作非常简单:
1. **定位配置文件**: 打开 `server/core/config.py` 文件。
2. **修改数据源定义**:
* **当前 (文件)**:
```python
DEFAULT_DATA_PATH = os.path.join(PROJECT_ROOT, 'data', 'timeseries_training_data_sample_10s50p.parquet')
```
* **未来 (数据库示例)**:
您可以将这行替换为数据库连接字符串,或者添加新的数据库配置变量。例如:
```python
# 注释掉或删除旧的文件路径配置
# DEFAULT_DATA_PATH = ...
# 新增数据库连接配置
DATABASE_URL = "postgresql://user:password@your_server_ip:5432/your_database_name"
```
3. **修改数据加载逻辑**:
* **定位数据加载函数**: 打开 `server/utils/multi_store_data_utils.py`
* **修改 `load_multi_store_data` 函数**:
* 引入数据库连接库(如 `sqlalchemy``psycopg2`)。
* 修改函数逻辑,使其使用 `config.py` 中的 `DATABASE_URL` 来连接数据库并执行SQL查询来获取数据而不是读取文件。
* **示例**:
```python
from sqlalchemy import create_engine
from core.config import DATABASE_URL # 导入新的数据库配置
def load_multi_store_data(...):
# ...
engine = create_engine(DATABASE_URL)
query = "SELECT * FROM sales_data" # 根据需要构建查询
df = pd.read_sql(query, engine)
# ... 后续处理逻辑保持不变 ...
```
通过以上步骤,您就可以在不改动项目其他任何部分的情况下,轻松地将数据源从本地文件切换到服务器数据库。
---
**日期**: 2025-07-15 11:43
**主题**: 修复因PyTorch版本不兼容导致的训练失败问题
### 问题描述
在修复了路径和依赖问题后,在某些机器上运行模型训练时,程序因 `TypeError: ReduceLROnPlateau.__init__() got an unexpected keyword argument 'verbose'` 而崩溃。但在本地开发机上运行正常。
### 根本原因
此问题是典型的**环境不一致**导致的兼容性错误。
1. **PyTorch版本差异**: 本地开发环境安装了较旧版本的PyTorch其学习率调度器 `ReduceLROnPlateau` 支持 `verbose` 参数(用于在学习率变化时打印日志)。
2. **新环境**: 在其他计算机或新创建的虚拟环境中安装了较新版本的PyTorch。在新版本中`ReduceLROnPlateau``verbose` 参数已被移除。
3. **代码问题**: `server/trainers/mlstm_trainer.py``server/trainers/transformer_trainer.py` 的代码中,在创建 `ReduceLROnPlateau` 实例时硬编码了 `verbose=True` 参数导致在新版PyTorch环境下调用时出现 `TypeError`
### 解决方案:移除已弃用的参数
1. **全面排查**: 检查了项目中所有训练器文件 (`mlstm_trainer.py`, `transformer_trainer.py`, `kan_trainer.py`, `tcn_trainer.py`)。
2. **精确定位**: 确认只有 `mlstm_trainer.py``transformer_trainer.py` 使用了 `ReduceLROnPlateau` 并传递了 `verbose` 参数。
3. **执行修复**:
* **文件**: `server/trainers/mlstm_trainer.py``server/trainers/transformer_trainer.py`
* **位置**: `ReduceLROnPlateau` 的初始化调用处。
* **操作**: 删除了 `verbose=True` 参数。
```diff
- scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min', ..., verbose=True)
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min', ...)
```
* **原因**: 移除这个在新版PyTorch中已不存在的参数可以从根本上解决 `TypeError`并确保代码在不同版本的PyTorch环境中都能正常运行。此修改不影响学习率调度器的核心功能。
### 最终结果
通过移除已弃用的 `verbose` 参数,彻底解决了由于环境差异导致的版本兼容性问题,确保了项目在所有目标机器上都能稳定地执行训练任务。
---
**日期**: 2025-07-15 14:05
**主题**: 仪表盘UI调整
### 描述
根据用户请求,将仪表盘上的“数据管理”卡片替换为“店铺管理”。
### 主要改动
* **文件**: `UI/src/views/DashboardView.vue`
* **修改**:
1. 在 `featureCards` 数组中,将原“数据管理”的对象修改为“店铺管理”。
2. 更新了卡片的 `title`, `description`, `icon``path`,使其指向店铺管理页面 (`/store-management`)。
3. 在脚本中导入了新的 `Shop` 图标。
### 结果
仪表盘现在直接提供到“店铺管理”页面的快捷入口,提高了操作效率。
- **Python**: 3.x
- **主要库**:
- Flask
- Flask-SocketIO
- Flasgger
- pandas
- numpy
- torch
- scikit-learn
- matplotlib
- **启动命令**: `python server/api.py`

View File

@ -1,4 +1,66 @@
跟文件夹save_models
根文件夹save_models
### 新模型文件系统设计
我们已经从“一个文件包含所有信息”的模式,转向了“目录结构本身就是信息”的模式。
基本结构:
```
saved_models/
├── versions.json # 记录所有模型最新版本号的“注册表”
├── product/
│ └── {product_id}_{scope}/
│ └── {model_type}/
│ └── v{N}/
│ ├── model.pth # 最终用于预测的模型文件
│ ├── checkpoint_best.pth # 训练中性能最佳的检查点
│ ├── metadata.json # 包含训练参数、scaler等元数据
│ └── loss_curve.png # 训练过程的损失曲线图
├── store/
│ └── {store_id}_{scope}/
│ └── {model_type}/
│ └── v{N}/
│ ├── model.pth
│ ├── checkpoint_best.pth
│ ├── metadata.json
│ └── loss_curve.png
└── global/
├── all/{aggregation_method}/{model_type}/v{N}/
│ ├── model.pth
│ ├── checkpoint_best.pth
│ ├── metadata.json
│ └── loss_curve.png
├── stores/{store_id}/{aggregation_method}/{model_type}/v{N}/
│ ├── model.pth
│ ├── checkpoint_best.pth
│ ├── metadata.json
│ └── loss_curve.png
├── products/{product_id}/{aggregation_method}/{model_type}/v{N}/
│ ├── model.pth
│ ├── checkpoint_best.pth
│ ├── metadata.json
│ └── loss_curve.png
└── custom/{store_id}/{product_id}/{aggregation_method}/{model_type}/v{N}/
├── model.pth
├── checkpoint_best.pth
├── metadata.json
└── loss_curve.png
```
关键点解读:
versions.json: 这是整个系统的“注册表”。它记录了每一种模型由mode, scope, type唯一确定的最新版本号。所有新的训练任务都会先读取这个文件来确定下一个版本号应该是多少从而避免了冲突。
目录路径: 模型的路径现在包含了它的核心元数据。例如,路径 saved_models/product/all/MLSTM/v1 清晰地告诉我们:
训练模式 (Mode): product (产品模式)
范围 (Scope): all (适用于所有产品)
模型类型 (Type): MLSTM
版本 (Version): v1
版本目录内容: 每个版本目录(如 v1/)下都包含了一次完整训练的所有产物,并且文件名是标准化的:
model.pth: 最终保存的、用于预测的模型。
metadata.json: 包含训练参数、数据标准化scaler对象等重要元数据。
loss_curve.png: 训练过程中的损失曲线图。
checkpoint_best.pth: 训练过程中验证集上表现最好的模型检查点。
## 按药品训练 ##
@ -74,18 +136,18 @@
#### 3. 全局训练 (Global Training)
* **目录结构**: `saved_models/global/{scope_path}/{aggregation_method}/{model_type}/v{N}/`
* **目录结构**: `saved_models/global/{scope}/{aggregation_method}/{model_type}/v{N}/`
* **路径解析**:
* `global`: 表示这是“全局”训练模式。
* `{scope_path}`: 描述训练所用数据的范围,结构比较灵活
* `all`: 代表所有店铺所有药品。
* `stores/{store_id}`: 代表选择了特定的店铺。
* `products/{product_id}`: 代表选择了特定的药品。
* `custom/{store_id}/{product_id}`: 代表自定义范围,同时指定了店铺和药品。
* `{scope}`: 描述训练所用数据的范围,有以下几种情况
* `all`: 代表所有店铺所有药品
* `stores/{store_id}`: 代表选择了特定的店铺
* `products/{product_id}`: 代表选择了特定的药品
* `custom/{store_id}/{product_id}`: 代表自定义范围同时指定了店铺和药品。
* `{aggregation_method}`: 数据的聚合方式 (例如 `sum`, `mean`)。
* `{model_type}`: 模型的类型。
* `v{N}`: 模型的版本号。
* **文件夹内容**: 与“按药品训练”模式相同。
* **文件夹内容**: 与“按药品训练”模式相同,包含 `model.pth`, `metadata.json` 等标准产物
### 总结
@ -107,20 +169,6 @@
* **损失曲线图**: `loss_curve.png`
* **训练元数据**: `metadata.json` (包含训练参数、指标等详细信息)
**示例路径:**
1. **按药品训练 (P001, 所有店铺, mlstm, v2)**:
* **目录**: `saved_models/product/P001_all/mlstm/v2/`
* **最终模型**: `saved_models/product/P001_all/mlstm/v2/model.pth`
* **损失曲线**: `saved_models/product/P001_all/mlstm/v2/loss_curve.png`
2. **按店铺训练 (S001, 指定药品P002, transformer, v1)**:
* **目录**: `saved_models/store/S001_P002/transformer/v1/`
* **最终模型**: `saved_models/store/S001_P002/transformer/v1/model.pth`
3. **全局训练 (所有数据, sum聚合, kan, v5)**:
* **目录**: `saved_models/global/all/sum/kan/v5/`
* **最终模型**: `saved_models/global/all/sum/kan/v5/model.pth`
#### 二、 文件读取规则