Files
WQ_GUI/src/core/prediction/inference_batch.py
duxin 04f9a647d8 fix: 推理端自适应反射率缩放 + Pipeline 列精确对齐
1. 自适应反射率量级缩放 (inference_pipeline/batch_inference/batch_inference_multi_data):
   - 检测光谱列 max > 10 时自动 ÷10000 统一到 0-1 区间
   - 三个推理入口全量同步保护

2. Pipeline 列精确对齐 (preprocess_spectra 两处):
   - 旧: not in 盲目排除多余列 → 140个光谱列被当多余移除
   - 新: 遍历 train_cols,精确字符串匹配 → math.isclose 浮点近似
        匹配列重命名为训练列名,缺失列补零
        最终 spectra[train_cols] 严格按训练顺序输出
   - 兼容路径同步修复

3. 误导性日志修正:
   - 改前: '正在应用预处理方法: D1' → 让用户以为推理端在手动做 D1
   - 改后: '[模型信息] 训练预处理方法: D1 — 由 Pipeline 内部自动执行'
2026-08-04 09:10:26 +08:00

1707 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import numpy as np
import pandas as pd
import joblib
import os
import math
from pathlib import Path
from typing import List, Dict, Union, Tuple, Optional
import warnings
warnings.filterwarnings('ignore')
# 导入预处理模块 - 动态添加路径支持
import sys
import os
from src.preprocessing.spectral_Preprocessing import Preprocessing, get_preprocessing_transformer
from src.core.utils.split_methods import spxy, ks
# try:
# from modeling import WaterQualityModeling
# except ImportError:
# from src.core.modeling.modeling_batch import WaterQualityModeling
# 机器学习相关导入
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
class WaterQualityInference:
"""水质参数反演推理类"""
def __init__(self, artifacts_dir: str = "models/artifacts",
external_model=None, external_model_path=None):
"""
初始化推理类
Args:
artifacts_dir: 模型保存目录
external_model: 外部预训练模型对象(来自 GUI 导入,跳过磁盘加载)
external_model_path: 外部模型文件路径(仅用于日志)
"""
self.artifacts_dir = Path(artifacts_dir)
if not self.artifacts_dir.exists():
print(f"警告: 模型目录不存在: {artifacts_dir},将在需要时创建")
self.best_model_info = None
self.external_model = external_model
self.external_model_path = external_model_path
# 规范化 loaded_model_data:始终为 dict,确保 ['model'] 访问不崩溃
if external_model is not None:
# ★ 外部模型可能是完整 dict(含 model + metadata + train_wavelengths),
# 也可能是裸 Pipeline 对象(旧版兼容)
if isinstance(external_model, dict) and 'model' in external_model:
self.loaded_model_data = external_model
print(f" 外部模型已规范化: dict (含 metadata)")
else:
self.loaded_model_data = {'model': external_model,
'preprocess_method': 'None'}
print(f" 外部模型已规范化: type={type(external_model).__name__}")
else:
self.loaded_model_data = None
def load_sampling_data(self, csv_path: str) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""
加载sampling生成的CSV数据(兼容 WQI 增强版 CSV)
Args:
csv_path: CSV文件路径
旧版:x_coord,y_coord,pixel_x,pixel_y,波长...
新版:x_coord,y_coord,WQI_...,波长...
Returns:
coords: 经纬度数据 (DataFrame, 2列)
spectra: 纯光谱数据 (DataFrame, 跳过 WQI 列)
wqi_df: WQI 指数列 (DataFrame, 0或45列)
"""
print(f"正在加载采样数据: {csv_path}")
if not os.path.exists(csv_path):
raise FileNotFoundError(f"采样数据文件不存在: {csv_path}")
# 读取CSV文件
data = pd.read_csv(csv_path)
print(f"采样数据加载完成:")
print(f" 数据形状: {data.shape}")
print(f" 列名: {list(data.columns[:5])}...") # 只显示前5列
# 检查数据列数
if data.shape[1] < 4:
raise ValueError(f"数据列数不足,期望至少4列(经度、纬度、其他列、光谱数据),实际得到{data.shape[1]}列")
# 前两列为经纬度
coords = data.iloc[:, :2].copy()
coords.columns = ['longitude', 'latitude']
# 动态识别光谱列(兼容 sampling_spectra.csv 列顺序变更)
# 列名约定:波长为纯数字字符串如 "374.285004";WQI 为 "WQI_xxx" 前缀
# 旧版 CSV(无WQI):x_coord,y_coord,pixel_x,pixel_y,波长... → 取 [4:]
# 新版 CSV(有WQI):x_coord,y_coord,WQI_...,波长... → 过滤 WQI 列后取光谱
all_cols = list(data.columns)
spectral_col_indices = []
wqi_col_indices = []
for i, col in enumerate(all_cols):
col_str = str(col)
if col_str.startswith('WQI_'):
wqi_col_indices.append(i)
elif col_str.replace('.', '').lstrip('-').isdigit():
# 波长列:纯数字字符串
spectral_col_indices.append(i)
else:
# 其他元数据列(x_coord/y_coord/pixel_x/pixel_y),由 coords 接收
pass
# 光谱列 = 纯数字列(WQI 已被排除)
spectra = data.iloc[:, spectral_col_indices].copy() if spectral_col_indices else data.iloc[:, 4:].copy()
# WQI 列(用于追加到预测结果输出)
wqi_df = data.iloc[:, wqi_col_indices].copy() if wqi_col_indices else pd.DataFrame()
print(f" 经纬度数据形状: {coords.shape}")
print(f" 光谱数据形状: {spectra.shape} (自动识别波长列,排除 {len(wqi_col_indices)} 个WQI列)")
print(f" 经纬度范围: 经度[{coords['longitude'].min():.6f}, {coords['longitude'].max():.6f}], "
f"纬度[{coords['latitude'].min():.6f}, {coords['latitude'].max():.6f}]")
return coords, spectra, wqi_df
def random(self, data, label, test_ratio=0.2, random_state=123):
"""
随机划分数据集
Args:
data: shape (n_samples, n_features)
label: shape (n_sample, )
test_ratio: 测试集比例,默认: 0.2
random_state: 随机种子,默认: 123
Returns:
X_train: (n_samples, n_features)
X_test: (n_samples, n_features)
y_train: (n_sample, )
y_test: (n_sample, )
"""
X_train, X_test, y_train, y_test = train_test_split(
data, label, test_size=test_ratio, random_state=random_state
)
return X_train, X_test, y_train, y_test
def spxy(self, data, label, test_size=0.2):
"""SPXY算法划分数据集(委托至 src.core.utils.split_methods.spxy)"""
return spxy(data, label, test_size=test_size)
def ks(self, data, label, test_size=0.2):
"""Kennard-Stone算法划分数据集(委托至 src.core.utils.split_methods.ks)"""
return ks(data, label, test_size=test_size)
def split_data(self, X: np.ndarray, y: pd.Series, method: str = "random",
test_size: float = 0.2, random_state: int = 42) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
根据指定方法划分数据集
Args:
X: 特征数据
y: 目标值数据
method: 划分方法 ("random", "spxy", "ks")
test_size: 测试集比例
random_state: 随机种子(仅对random方法有效)
Returns:
X_train, X_test, y_train, y_test
"""
print(f"使用 {method} 方法划分数据集")
if method == "random":
return self.random(X, y, test_ratio=test_size, random_state=random_state)
elif method == "spxy":
return self.spxy(X, y, test_size=test_size)
elif method == "ks":
return self.ks(X, y, test_size=test_size)
else:
raise ValueError(f"不支持的划分方法: {method}. 支持的方法: ['random', 'spxy', 'ks']")
def get_best_model_from_summary(self, metric: str = 'test_r2') -> Tuple[str, str]:
"""
从训练摘要中获取最佳模型信息
Args:
metric: 评估指标(默认使用test_r2,回归任务的主要指标)
Returns:
preprocess_method: 预处理方法
model_name: 模型名称
"""
# 获取当前artifacts_dir的文件夹名称(用作目标列名)
folder_name = self.artifacts_dir.name
# 尝试加载详细结果文件(使用新的命名格式)
detailed_path = self.artifacts_dir / f"{folder_name}_detailed_results.csv"
summary_path = self.artifacts_dir / f"{folder_name}_training_summary.csv"
# 备用的旧格式文件路径
old_detailed_path = self.artifacts_dir / "detailed_results.csv"
old_summary_path = self.artifacts_dir / "training_summary.csv"
summary_df = None
# 优先使用新格式的详细结果文件
if detailed_path.exists():
print(f"使用详细结果文件: {detailed_path}")
summary_df = pd.read_csv(detailed_path)
# 将中文列名映射到英文
metric_mapping = {
'test_r2': '测试集R²',
'train_r2': '训练集R²',
'test_rmse': '测试集RMSE',
'train_rmse': '训练集RMSE',
'cv_mean': 'CV均值'
}
if metric in metric_mapping and metric_mapping[metric] in summary_df.columns:
metric_col = metric_mapping[metric]
else:
metric_col = metric
elif summary_path.exists():
print(f"使用训练摘要文件: {summary_path}")
summary_df = pd.read_csv(summary_path)
metric_col = metric
elif old_detailed_path.exists():
print(f"使用旧格式详细结果文件: {old_detailed_path}")
summary_df = pd.read_csv(old_detailed_path)
# 将中文列名映射到英文
metric_mapping = {
'test_r2': '测试集R²',
'train_r2': '训练集R²',
'test_rmse': '测试集RMSE',
'train_rmse': '训练集RMSE',
'cv_mean': 'CV均值'
}
if metric in metric_mapping and metric_mapping[metric] in summary_df.columns:
metric_col = metric_mapping[metric]
else:
metric_col = metric
elif old_summary_path.exists():
print(f"使用旧格式训练摘要文件: {old_summary_path}")
summary_df = pd.read_csv(old_summary_path)
metric_col = metric
else:
raise FileNotFoundError(f"训练摘要文件不存在,尝试的路径:\n"
f" - {detailed_path}\n"
f" - {summary_path}\n"
f" - {old_detailed_path}\n"
f" - {old_summary_path}")
if summary_df.empty:
raise ValueError("训练摘要为空")
# 检查指标列是否存在
if metric_col not in summary_df.columns:
available_cols = list(summary_df.columns)
raise ValueError(f"指标 '{metric_col}' 不存在。可用列: {available_cols}")
# 获取最佳模型(对于R²等指标,值越大越好)
if 'r2' in metric.lower() or 'score' in metric.lower():
best_idx = summary_df[metric_col].idxmax()
else: # 对于RMSE、MAE等,值越小越好
best_idx = summary_df[metric_col].idxmin()
best_row = summary_df.loc[best_idx]
# 根据文件类型解析模型信息
if (detailed_path.exists() or old_detailed_path.exists()) and '划分方法' in summary_df.columns:
# 详细结果文件格式
split_method = best_row['划分方法']
preprocess_method = best_row['预处理方法']
model_name = best_row['建模方法']
# 处理 nan/NaN/None 值,转换为 "None" 字符串
if pd.isna(preprocess_method) or str(preprocess_method).lower() in ['nan', 'none', '']:
preprocess_method = "None"
best_combination = f"{split_method}_{preprocess_method}_{model_name}"
else:
# 简化结果文件格式
best_combination = best_row['combination']
# 解析组合名称(格式: split_method_preprocess_method_model_name)
parts = best_combination.split('_')
if len(parts) < 3:
raise ValueError(f"无效的模型组合名称格式: {best_combination}")
split_method = parts[0]
preprocess_method = parts[1]
model_name = '_'.join(parts[2:])
# 处理 nan/NaN/None 值,转换为 "None" 字符串
if pd.isna(preprocess_method) or str(preprocess_method).lower() in ['nan', 'none', '']:
preprocess_method = "None"
print(f"最佳模型组合: {best_combination}")
print(f" 划分方法: {split_method}")
print(f" 预处理方法: {preprocess_method}")
print(f" 模型名称: {model_name}")
print(f" {metric_col}: {best_row[metric_col]:.4f}")
self.best_model_info = {
'combination': best_combination,
'split_method': split_method,
'preprocess_method': preprocess_method,
'model_name': model_name,
'metric_value': best_row[metric_col]
}
# 返回用于加载模型的文件名格式
model_file_prefix = f"{split_method}_{preprocess_method}"
return model_file_prefix, model_name
def load_best_model(self, metric: str = 'test_r2'):
"""
加载最佳模型
Args:
metric: 评估指标
"""
model_file_prefix, model_name = self.get_best_model_from_summary(metric)
# 获取当前artifacts_dir的文件夹名称(用作目标列名)
folder_name = self.artifacts_dir.name
# 构建模型文件路径(新格式:包含目标列名)
filename = f"{folder_name}_{model_file_prefix}_{model_name}.joblib"
filepath = self.artifacts_dir / filename
# 如果新格式文件不存在,尝试旧格式
if not filepath.exists():
old_filename = f"{model_file_prefix}_{model_name}.joblib"
old_filepath = self.artifacts_dir / old_filename
if old_filepath.exists():
filepath = old_filepath
filename = old_filename
print(f"使用旧格式模型文件: {filepath}")
else:
raise FileNotFoundError(f"模型文件不存在,尝试的路径:\n"
f" - {filepath}\n"
f" - {old_filepath}")
else:
print(f"使用新格式模型文件: {filepath}")
print(f"正在加载模型: {filepath}")
# 加载模型数据
self.loaded_model_data = joblib.load(filepath)
print("模型加载完成:")
print(f" 预处理方法: {self.loaded_model_data['preprocess_method']}")
print(f" 模型名称: {self.loaded_model_data['model_name']}")
print(f" 模型类型: {type(self.loaded_model_data['model'])}")
if 'metadata' in self.loaded_model_data:
metadata = self.loaded_model_data['metadata']
print(f" 数据形状: {metadata.get('data_shape', 'Unknown')}")
print(f" 目标范围: {metadata.get('target_range', 'Unknown')}")
if 'test_r2' in metadata:
print(f" 测试集R²: {metadata['test_r2']:.4f}")
if 'test_rmse' in metadata:
print(f" 测试集RMSE: {metadata['test_rmse']:.4f}")
# ★ 加载时打印 MNF 波段选择信息(从已训练的 Pipeline 中提取)
self._print_mnf_info_if_available()
def load_specific_model(self, model_file_path: str):
"""
加载指定的模型文件
Args:
model_file_path: 模型文件路径
"""
if not os.path.exists(model_file_path):
raise FileNotFoundError(f"模型文件不存在: {model_file_path}")
print(f"正在加载指定模型: {model_file_path}")
# 加载模型数据
self.loaded_model_data = joblib.load(model_file_path)
print("模型加载完成:")
print(f" 预处理方法: {self.loaded_model_data['preprocess_method']}")
print(f" 模型名称: {self.loaded_model_data['model_name']}")
print(f" 模型类型: {type(self.loaded_model_data['model'])}")
# ★ 加载时打印 MNF 波段选择信息
self._print_mnf_info_if_available()
def _auto_detect_train_wavelengths(self):
"""自动获取训练波长:优先级 json/txt > 工作目录 CSV > None"""
import os as _os, json as _json, glob as _glob
_dir = str(self.artifacts_dir)
# 搜集所有可能的搜索根目录
_roots = [_dir, _os.path.dirname(_dir), _os.getcwd()]
# 找所有 work_dir 级别的父目录
for _r in list(_roots):
_p = _os.path.dirname(_r)
if _p and _p not in _roots:
_roots.append(_p)
# 优先级 1: 显式 wavelength 文件(JSON/TXT)
for _root in _roots:
for _fname in ('train_wavelengths.json', 'train_wavelengths.txt'):
_p = _os.path.join(_root, _fname)
if not _os.path.isfile(_p):
continue
try:
if _fname.endswith('.json'):
wl = _json.load(open(_p))
if wl:
print(f"[波长回填] 来源: {_p}")
return wl
else:
wl = [float(x) for x in open(_p).read().strip().split()]
if wl:
print(f"[波长回填] 来源: {_p}")
return wl
except Exception:
pass
# 优先级 2: training_spectra.csv / sampling_spectra.csv
for _root in _roots:
for _sub in ('4_sampling', '6_Spectral_Feature_Extraction', ''):
_d = _os.path.join(_root, _sub) if _sub else _root
for _pat in ('sampling_spectra.csv', 'training_spectra.csv'):
_p = _os.path.join(_d, _pat)
if not _os.path.isfile(_p):
continue
try:
_df = pd.read_csv(_p, nrows=0)
_wl = [float(c) for c in _df.columns
if c.replace('.','').lstrip('-').isdigit()]
if _wl:
print(f"[波长回填] 来源: {_p} ({len(_wl)} 波长)")
return _wl
except Exception:
pass
return None
# ═══════════════════════════════════════════════════════════
# DualStream_MNF 专用预处理:纯光谱重采样 → Pipeline 全自动
# ═══════════════════════════════════════════════════════════
@staticmethod
def _check_spectral_coverage(train_wl, infer_wl):
"""光谱覆盖率智能预警:检测预测数据波长范围是否充分覆盖训练波长。
若预测波长的起止端与训练波长差距超过 15nm,说明边缘波段缺失,
np.interp 会依赖 left/right 恒定外推补齐,可能导致特征丢失。
"""
train_min, train_max = np.min(train_wl), np.max(train_wl)
infer_min, infer_max = np.min(infer_wl), np.max(infer_wl)
gap_left = infer_min - train_min
gap_right = train_max - infer_max
if gap_left > 15:
print(f"\033[93m[WARN] 预测数据起始波长 ({infer_min:.1f}nm) 晚于"
f" 训练波长 ({train_min:.1f}nm) 达 {gap_left:.0f}nm!"
f"系统将自动向左横推补齐,这可能会导致蓝端/紫外特征丢失。\033[0m")
if gap_right > 15:
print(f"\033[93m[WARN] 预测数据截止波长 ({infer_max:.1f}nm) 短于"
f" 训练波长 ({train_max:.1f}nm) 达 {gap_right:.0f}nm!"
f"系统将自动向右横推补齐,这可能会导致近红外特征丢失,影响预测精度。\033[0m")
def _preprocess_dual_stream(self, spectra: pd.DataFrame,
metadata: dict) -> np.ndarray:
"""纯光谱重采样到训练波长网格,返回后由 pipeline.predict() 全自动处理。
Pipeline 内置 FeatureUnion[PhysicalExtractor + MNFTransformer],
会自动完成物理指数计算和 MNF 降维,无需外部补 WQI。
"""
train_wl = metadata.get('train_wavelengths', None)
if train_wl is None or len(train_wl) == 0:
raise ValueError(
"推理失败:metadata 中缺失 train_wavelengths。"
"DualStream_MNF 必须对齐训练波段以完成光谱重采样。"
"请使用包含 train_wavelengths 元数据的模型文件。"
)
# 提取纯光谱列
spec_cols = []
for c in spectra.columns:
try:
float(str(c)); spec_cols.append(c)
except (ValueError, TypeError):
pass
# np.interp 重采样:308/113/任意波段 → 模型训练波长
# ★ 边缘填充:left/right 使用当前行首尾有效值,杜绝 NaN→0.0 断崖
spec_data = spectra[spec_cols].values.astype(np.float64)
src_wl = np.array([float(c) for c in spec_cols], dtype=np.float64)
dst_wl = np.array(train_wl, dtype=np.float64)
# ★ 光谱覆盖率预警
self._check_spectral_coverage(train_wl, src_wl)
resampled = np.zeros((spec_data.shape[0], len(dst_wl)), dtype=np.float64)
for i in range(spec_data.shape[0]):
y_vals = spec_data[i]
resampled[i] = np.interp(dst_wl, src_wl, y_vals,
left=y_vals[0], right=y_vals[-1])
result = pd.DataFrame(resampled,
columns=[f'{wl:.6f}' for wl in train_wl])
print(f"[DualStream_MNF] 纯光谱重采样: {len(spec_cols)} → {len(train_wl)} 列")
print(f"[DualStream_MNF] pipeline.predict() 将自动完成 Physical+MNF 变换")
print(f"[特征对齐] 最终输入维度: {result.shape}")
return result.values
def preprocess_spectra(self, spectra: pd.DataFrame) -> np.ndarray:
"""
对光谱数据进行预处理 + 跨传感器光谱重采样。
改造要点(v2 — 光谱重采样):
- 废除原有的「列位置截断」和「零值填充」hack
- 从 model metadata 中读取训练时的波长列表
- 如果输入传感器与训练传感器波长网格不同,使用 np.interp()
将输入光谱重采样到训练波长网格
- 非光谱列(WQI 指数等)保持不变
- 兼容旧模型(无 train_wavelengths 时回退到旧逻辑)
Args:
spectra: 原始光谱数据(DataFrame,列名为波长字符串)
Returns:
预处理后的光谱数据 (numpy ndarray)
"""
if self.loaded_model_data is None:
raise ValueError("请先加载模型")
preprocess_method = self.loaded_model_data['preprocess_method']
# 处理 nan/NaN/None 值
if pd.isna(preprocess_method) or str(preprocess_method).lower() in ['nan', 'none', '']:
preprocess_method = "None"
# 解析预处理方法(可能包含划分方法前缀)
if '_' in str(preprocess_method):
parts = str(preprocess_method).split('_')
actual_preprocess_method = '_'.join(parts[1:]) if len(parts) > 1 else parts[-1]
else:
actual_preprocess_method = str(preprocess_method)
if actual_preprocess_method.lower() in ['nan', 'none', '']:
actual_preprocess_method = "None"
model = self.loaded_model_data['model']
metadata = self.loaded_model_data.get('metadata', {})
# ★ 根据模型类型确定预处理归属
if isinstance(model, Pipeline):
print(f"[模型信息] 训练预处理方法: {actual_preprocess_method}"
f" — 由 Pipeline 内部自动执行,推理端仅做光谱重采样+列对齐")
else:
print(f"[模型信息] 训练预处理方法: {actual_preprocess_method}"
f" — 旧裸模型,推理端手动应用外部 Preprocessing")
print(f"原始光谱数据形状: {spectra.shape}")
# ═══════════════════════════════════════════════════════════
# ★ DualStream_MNF:纯光谱重采样 → pipeline.predict() 全自动
# ═══════════════════════════════════════════════════════════
if actual_preprocess_method == "DualStream_MNF" and isinstance(model, Pipeline):
return self._preprocess_dual_stream(spectra, metadata)
train_wavelengths = metadata.get('train_wavelengths', None)
# 旧模型无 train_wavelengths → 不做波长匹配,走下方分支 B 的 linspace 路径
train_columns = metadata.get('train_columns', None)
# ═══════════════════════════════════════════════════════════
# ★ 核心:光谱重采样(跨传感器适配)
# ═══════════════════════════════════════════════════════════
if train_wavelengths is not None and len(train_wavelengths) > 0:
print(f"[光谱重采样] 模型训练波长: {len(train_wavelengths)} 个, "
f"范围 {train_wavelengths[0]:.2f} ~ {train_wavelengths[-1]:.2f} nm")
# 1) 从当前 spectra 中分离光谱列和 WQI 列
target_wavelengths = []
spectral_col_indices = []
wqi_col_indices = []
wqi_col_names = []
for i, col in enumerate(spectra.columns):
col_str = str(col)
try:
wl = float(col_str)
target_wavelengths.append(wl)
spectral_col_indices.append(i)
except (ValueError, TypeError):
if col_str.startswith('WQI_'):
wqi_col_indices.append(i)
wqi_col_names.append(col_str)
print(f"[光谱重采样] 当前传感器: {len(target_wavelengths)} 个波长列, "
f"{len(wqi_col_indices)} 个 WQI 列")
if len(target_wavelengths) == 0:
print("[光谱重采样] ⚠ 未检测到波长列,跳过重采样")
else:
# 2) 提取光谱数据矩阵
spectral_data = spectra.iloc[:, spectral_col_indices].values.astype(np.float64)
# 3) 逐行 np.interp 重采样
train_wl_arr = np.array(train_wavelengths, dtype=np.float64)
target_wl_arr = np.array(target_wavelengths, dtype=np.float64)
# ★ 光谱覆盖率预警
self._check_spectral_coverage(train_wavelengths, target_wavelengths)
print(f"[光谱重采样] 执行重采样: {len(target_wavelengths)} → "
f"{len(train_wavelengths)} 个波长点 ...")
resampled = np.zeros((spectral_data.shape[0], len(train_wavelengths)),
dtype=np.float64)
for i in range(spectral_data.shape[0]):
y_vals = spectral_data[i]
resampled[i] = np.interp(
train_wl_arr, target_wl_arr, y_vals,
left=y_vals[0], right=y_vals[-1]
)
# 4) 重组为 DataFrame(列名 = 训练波长字符串)
wl_col_names = [f"{wl:.6f}" for wl in train_wavelengths]
resampled_df = pd.DataFrame(resampled, columns=wl_col_names,
index=spectra.index)
# 5) 拼接 WQI 列(如有)
if wqi_col_indices:
wqi_df = spectra.iloc[:, wqi_col_indices].copy()
wqi_df.columns = wqi_col_names
spectra = pd.concat([resampled_df, wqi_df], axis=1)
else:
spectra = resampled_df
print(f"[光谱重采样] 完成: {spectra.shape[1]} 列 "
f"(光谱 {len(train_wavelengths)} + WQI {len(wqi_col_indices)})")
else:
# ── 兼容旧模型(无 train_wavelengths)──
print("[光谱重采样] 模型无 train_wavelengths 元数据(旧模型),"
"跳过光谱重采样,进入兼容路径...")
# 旧兼容逻辑:WQI 自动补全(仅对旧裸模型生效;Pipeline 走 train_columns 对齐)
if isinstance(model, Pipeline):
train_cols = metadata.get('train_columns', None)
if train_cols is not None and len(train_cols) > 0:
print(f"[兼容+Pipeline 列对齐] 训练列数: {len(train_cols)}, 推理当前列数: {spectra.shape[1]}")
spectra_cols = [str(c) for c in spectra.columns]
mapping = {}
unmatched = []
for tc in train_cols:
tc_str = str(tc)
if tc_str in spectra_cols:
mapping[tc_str] = tc_str
else:
unmatched.append(tc_str)
still_unmatched = []
for tc_str in unmatched:
tc_float = None
try: tc_float = float(tc_str)
except (ValueError, TypeError): pass
if tc_float is not None:
found = None
for sc in spectra_cols:
if sc in mapping: continue
try:
if math.isclose(tc_float, float(sc), rel_tol=1e-4):
found = sc; break
except (ValueError, TypeError): pass
if found is not None:
mapping[found] = tc_str; continue
still_unmatched.append(tc_str)
aligned_parts = []
for sc in spectra_cols:
if sc in mapping:
col_data = spectra[sc].copy()
col_data.name = mapping[sc]
aligned_parts.append(col_data)
spectra_aligned = pd.concat(aligned_parts, axis=1) if aligned_parts else pd.DataFrame(index=spectra.index)
for tc_str in still_unmatched:
spectra_aligned[tc_str] = 0.0
spectra = spectra_aligned[[str(c) for c in train_cols]]
print(f"[兼容+Pipeline 列对齐] 完成 → {spectra.shape[1]} 列")
else:
print("[兼容+Pipeline] 无 train_columns,跳过 WQI 补全")
else:
expected_features = getattr(model, 'n_features_in_', None)
if expected_features is not None and spectra.shape[1] < expected_features:
print(f"[特征补全] 检测到特征缺口:当前 {spectra.shape[1]} 列 "
f"< 模型期望 {expected_features} 列,正在计算 WQI 指数...")
try:
from src.utils.water_index import WaterQualityIndexCalculator
calc = WaterQualityIndexCalculator()
formulas = calc.list_available()
if formulas:
results_df = calc.calculate_many(formulas, spectra, fast=True)
if isinstance(results_df, pd.DataFrame) and not results_df.empty:
original_col_count = spectra.shape[1]
spectra = pd.concat([spectra, results_df], axis=1)
print(f"[特征补全] 完成!扩充至 {spectra.shape[1]} 列 "
f"(+{spectra.shape[1] - original_col_count} WQI)")
except Exception as e:
print(f"[特征补全] 失败: {e}")
# ★ v4: 无 train_wavelengths 时,基于物理波长的经验重采样
# 从 spectra 列名中解析真实波长 → np.interp 到 400-800nm 标准网格
# 取代原基于列索引的硬截断(不同传感器列索引对应的波长完全不同)
if expected_features is not None:
n_current = spectra.shape[1]
# ── 第1步:从列名中提取波长列和非波长列 ──
_target_wl: List[float] = []
_wqi_names: List[str] = []
_other_names: List[str] = []
for _col in spectra.columns:
try:
_target_wl.append(float(str(_col)))
except (ValueError, TypeError):
if str(_col).startswith('WQI_'):
_wqi_names.append(str(_col))
else:
_other_names.append(str(_col))
# 分离出 WQI 列(保存以便后续拼接回去)
_wqi_df = spectra[_wqi_names].copy() if _wqi_names else pd.DataFrame()
if len(_target_wl) >= 2 and expected_features > 0:
# ── 第2步:有真实波长 → np.interp 重采样 ──
fallback_wl = np.linspace(400.0, 800.0, expected_features,
dtype=np.float64)
_target_arr = np.array(_target_wl, dtype=np.float64)
_spec_data = spectra.iloc[:, :len(_target_wl)].values.astype(np.float64)
# 按波长排序(以防列顺序不是严格递增)
_sort_idx = np.argsort(_target_arr)
_target_arr = _target_arr[_sort_idx]
_spec_data = _spec_data[:, _sort_idx]
_resampled = np.zeros((_spec_data.shape[0], expected_features),
dtype=np.float64)
for i in range(_spec_data.shape[0]):
_y_vals = _spec_data[i]
_resampled[i] = np.interp(
fallback_wl, _target_arr, _y_vals,
left=_y_vals[0], right=_y_vals[-1]
)
# 重组 DataFrame
_wl_cols = [f"{w:.1f}" for w in fallback_wl]
spectra = pd.DataFrame(_resampled, columns=_wl_cols,
index=spectra.index)
if not _wqi_df.empty:
spectra = pd.concat([spectra, _wqi_df.reset_index(drop=True)],
axis=1)
print(f"[兼容重采样] 列名解析到 {len(_target_wl)} 个波长 "
f"({_target_arr[0]:.1f}~{_target_arr[-1]:.1f}nm) → "
f"np.interp → {expected_features} 列 (400~800nm 标准网格)"
f"{' + ' + str(len(_wqi_names)) + ' WQI' if _wqi_names else ''}")
elif n_current > expected_features:
# ── 第3步:无法解析波长(纯字符串列名)→ 硬截断兜底 ──
spectra = spectra.iloc[:, :expected_features]
print(f"[兼容截断] 无法解析波长列名,硬截断 "
f"{n_current} → {expected_features} 列")
elif n_current < expected_features:
# ── 第4步:特征不够 → 补零 ──
for i in range(expected_features - n_current):
spectra[f'_padding_{i}'] = 0.0
print(f"[兼容填充] 特征不足,补零 "
f"{n_current} → {expected_features} 列")
# ═══════════════════════════════════════════════════════════
# ★ 特征列精确对齐:训练端可能包含 WQI 指数等衍生特征,
# 推理端必须精确匹配训练时的列集合,不能盲目补全。
# ═══════════════════════════════════════════════════════════
if isinstance(model, Pipeline):
# ── Pipeline 模型:用 train_columns 做精确列对齐 ──
train_cols = metadata.get('train_columns', None)
if train_cols is not None and len(train_cols) > 0:
print(f"[Pipeline 列对齐] 训练列数: {len(train_cols)}, 推理当前列数: {spectra.shape[1]}")
# 构建 推理列名 → 训练列名 的映射
spectra_cols = [str(c) for c in spectra.columns]
mapping: Dict[str, str] = {} # spectra_col_name → train_col_name
train_set = set(str(tc) for tc in train_cols)
# 第一遍:精确字符串匹配
unmatched_train = []
for tc in train_cols:
tc_str = str(tc)
if tc_str in spectra_cols:
mapping[tc_str] = tc_str
else:
unmatched_train.append(tc_str)
# 第二遍:对未匹配的训练列,尝试浮点数近似匹配
still_unmatched = []
for tc_str in unmatched_train:
tc_float = None
try:
tc_float = float(tc_str)
except (ValueError, TypeError):
pass
if tc_float is not None:
found = None
for sc in spectra_cols:
if sc in mapping: # 已经被匹配过了
continue
try:
sc_float = float(sc)
if math.isclose(tc_float, sc_float, rel_tol=1e-4):
found = sc
break
except (ValueError, TypeError):
pass
if found is not None:
mapping[found] = tc_str
continue
still_unmatched.append(tc_str)
# 从 spectra 中挑选已匹配的列,并重命名为训练列名
aligned_parts = []
matched_train = set(mapping.values())
for sc in spectra_cols:
if sc in mapping:
col_data = spectra[sc].copy()
col_data.name = mapping[sc] # 重命名为训练列名
aligned_parts.append(col_data)
spectra_aligned = pd.concat(aligned_parts, axis=1) if aligned_parts else pd.DataFrame(index=spectra.index)
# 对仍未匹配的训练列:WQI_ 补零,其他警告
for tc_str in still_unmatched:
spectra_aligned[tc_str] = 0.0
# 最终按 train_cols 顺序排列输出
spectra = spectra_aligned[[str(c) for c in train_cols]]
matched_count = len(matched_train)
missing_count = len(still_unmatched)
print(f"[Pipeline 列对齐] 精确匹配 {matched_count - missing_count} 列, "
f"浮点近似匹配 {len(mapping) - (matched_count - missing_count)} 列, "
f"补零填充 {missing_count} 列 → 最终 {spectra.shape[1]} 列")
else:
print("[Pipeline 列对齐] 无 train_columns 元数据,跳过列对齐")
else:
# ── 旧裸模型:保留原有 WQI 自动补全逻辑 ──
expected_features = getattr(model, 'n_features_in_', None)
if expected_features is not None and spectra.shape[1] < expected_features:
print(f"[特征补全] 检测到特征缺口:当前 {spectra.shape[1]} 列 "
f"< 模型期望 {expected_features} 列,正在计算 WQI 指数...")
try:
from src.utils.water_index import WaterQualityIndexCalculator
calc = WaterQualityIndexCalculator()
formulas = calc.list_available()
if formulas:
results_df = calc.calculate_many(formulas, spectra, fast=True)
if isinstance(results_df, pd.DataFrame) and not results_df.empty:
original_col_count = spectra.shape[1]
spectra = pd.concat([spectra, results_df], axis=1)
print(f"[特征补全] 完成!扩充至 {spectra.shape[1]} 列 "
f"(+{spectra.shape[1] - original_col_count} WQI)")
except Exception as e:
print(f"[特征补全] 失败: {e}")
# ═══════════════════════════════════════════════════════════
# 通用清洗
# ═══════════════════════════════════════════════════════════
spectra = spectra.replace([np.inf, -np.inf], np.nan)
spectra = spectra.fillna(0)
print(f"[特征对齐] 最终输入维度: {spectra.shape}")
# ── Pipeline 化分支:模型内置 scaler → 跳过手动 Preprocessing ──
if isinstance(model, Pipeline):
print(f"[Pipeline] 模型是 sklearn Pipeline,内置预处理步骤,"
f"无需外部 Preprocessing")
return spectra.values
# ── 兼容路径:旧裸模型 + 手动 Preprocessing ──
try:
spectra_processed = Preprocessing(actual_preprocess_method, spectra)
if isinstance(spectra_processed, pd.DataFrame):
spectra_processed = spectra_processed.values
print(f" [Legacy] 旧裸模型 + 手动 Preprocessing({actual_preprocess_method}) 完成,"
f"数据形状: {spectra_processed.shape}")
return spectra_processed
except Exception as e:
print(f"预处理失败: {e}")
print("使用原始数据")
return spectra.values
def predict(self, spectra_processed: np.ndarray) -> np.ndarray:
"""
使用加载的模型进行预测
Args:
spectra_processed: 预处理后的光谱数据
Returns:
预测结果
"""
if self.loaded_model_data is None:
raise ValueError("请先加载模型")
model = self.loaded_model_data['model']
print(f"正在进行预测...")
print(f"输入数据形状: {spectra_processed.shape}")
try:
# 清洗 NaN / Inf,防止 SVR 等模型报错
spectra_clean = np.nan_to_num(spectra_processed, nan=0.0, posinf=0.0, neginf=0.0)
if np.any(np.isnan(spectra_clean)) or np.any(np.isinf(spectra_clean)):
print("警告: 清洗后数据中仍存在 NaN/Inf,已重置为 0")
spectra_clean = np.nan_to_num(spectra_clean, nan=0.0, posinf=0.0, neginf=0.0)
predictions = model.predict(spectra_clean)
print(f"预测完成,结果形状: {predictions.shape}")
print(f"预测值范围: [{np.min(predictions):.4f}, {np.max(predictions):.4f}]")
print(f"预测值统计: 均值={np.mean(predictions):.4f}, 标准差={np.std(predictions):.4f}")
# ★ 百分位裁剪:去除极端异常值,避免专题图色阶被拉爆
predictions = self._clip_outliers(predictions)
return predictions
except Exception as e:
print(f"预测失败: {e}")
raise
@staticmethod
def _clip_outliers(predictions: np.ndarray, lower_pct: float = 2.0,
upper_pct: float = 98.0) -> np.ndarray:
"""百分位裁剪:将极端异常值裁剪到合理范围。
水体边界/零值区域的光谱异常会导致模型外推到极端值
(如 BGA 预测 -86 ~ 7026),若不处理,专题图的克里金
插值色阶会被拉爆,正常空间变化完全不可见。
Parameters
----------
predictions : np.ndarray
原始预测值
lower_pct : float
下百分位(默认 2%,低于此分位数的值被裁剪)
upper_pct : float
上百分位(默认 98%,高于此分位数的值被裁剪)
Returns
-------
np.ndarray
裁剪后的预测值(副本)
"""
lo = np.percentile(predictions, lower_pct)
hi = np.percentile(predictions, upper_pct)
# 只在实际有异常值时才裁剪
if lo >= hi:
return predictions
n_clipped_lo = int(np.sum(predictions < lo))
n_clipped_hi = int(np.sum(predictions > hi))
if n_clipped_lo == 0 and n_clipped_hi == 0:
return predictions
print(f"[异常值裁剪] P{lower_pct:.0f}={lo:.4f}, P{upper_pct:.0f}={hi:.4f}, "
f"裁剪低端 {n_clipped_lo} 个, 高端 {n_clipped_hi} 个 "
f"({(n_clipped_lo + n_clipped_hi) / len(predictions) * 100:.1f}%)")
return np.clip(predictions, lo, hi)
def _print_mnf_info_if_available(self):
"""从已加载的 Pipeline 模型中提取 MNFTransformer 并打印波段选择信息。"""
if self.loaded_model_data is None:
return
model = self.loaded_model_data.get('model')
if model is None:
return
from sklearn.pipeline import Pipeline, FeatureUnion
if not isinstance(model, Pipeline):
return
# 递归查找 MNFTransformer
def _find_mnf(step):
if step.__class__.__name__ == 'MNFTransformer':
return step
if isinstance(step, FeatureUnion):
for _name, _trans in step.transformer_list:
found = _find_mnf(_trans)
if found is not None:
return found
if isinstance(step, Pipeline):
for _sub_name, _sub_step in step.steps:
found = _find_mnf(_sub_step)
if found is not None:
return found
return None
mnf = _find_mnf(model)
if mnf is not None and hasattr(mnf, 'eigvals_w_'):
target_name = self.loaded_model_data.get('target_column_name', '')
if not target_name and 'metadata' in self.loaded_model_data:
target_name = self.loaded_model_data['metadata'].get('target_column_name', '')
if target_name:
print(f"\n [MNF 波段信息] 目标指数: {target_name}")
mnf._print_band_selection()
@staticmethod
def _mask_zero_spectra_pixels(spectra: pd.DataFrame,
predictions: np.ndarray) -> np.ndarray:
"""将全零光谱行(NDWI 掩膜外的陆地像素)的预测值强制设为 NaN。
GUI 制图 / Kriging / IDW 插值天然忽略 NaN,这是最安全的掩膜继承方式。
Parameters
----------
spectra : pd.DataFrame
原始光谱 DataFrame(shape: n_pixels × n_bands)
predictions : np.ndarray
模型预测值数组(shape: n_pixels)
Returns
-------
np.ndarray
处理后的预测值数组
"""
all_zero = (spectra == 0).all(axis=1)
n_zero = int(all_zero.sum())
if n_zero == 0:
return predictions
predictions = predictions.astype(np.float64, copy=True)
predictions[all_zero.values] = np.nan
print(f"[全零拦截] 检测到 {n_zero} 个全零光谱像素 "
f"({n_zero / len(predictions) * 100:.1f}%),预测值已设为 NaN")
return predictions
def save_predictions(self, coords: pd.DataFrame, predictions: np.ndarray,
output_path: str, prediction_column: str = 'prediction',
wqi_columns: Optional[pd.DataFrame] = None):
"""
保存预测结果
Args:
coords: 经纬度数据
predictions: 预测结果
output_path: 输出文件路径
prediction_column: 预测列名称
wqi_columns: Optional[pd.DataFrame] = None
"""
print(f"正在保存预测结果到: {output_path}")
# 创建结果DataFrame
result_df = coords.copy()
# 追加 WQI 水质指数列(如 sampling_spectra.csv 注入了 45 列指数)
if wqi_columns is not None and not wqi_columns.empty:
result_df = pd.concat([result_df, wqi_columns.reset_index(drop=True)], axis=1)
result_df[prediction_column] = predictions
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# 根据文件扩展名选择保存格式
file_ext = Path(output_path).suffix.lower()
if file_ext == '.xls':
# 保存为Excel 97-2003格式
try:
result_df.to_excel(output_path, index=False, engine='xlwt')
print(f" 格式: Excel 97-2003 (.xls)")
except ImportError:
print("警告: xlwt库未安装,无法保存为.xls格式,改为保存CSV格式")
csv_path = output_path.replace('.xls', '.csv')
result_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
output_path = csv_path
elif file_ext == '.xlsx':
# 保存为Excel 2007+格式
try:
result_df.to_excel(output_path, index=False, engine='openpyxl')
print(f" 格式: Excel 2007+ (.xlsx)")
except ImportError:
print("警告: openpyxl库未安装,无法保存为.xlsx格式,改为保存CSV格式")
csv_path = output_path.replace('.xlsx', '.csv')
result_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
output_path = csv_path
else:
# 默认保存为CSV格式
result_df.to_csv(output_path, index=False, encoding='utf-8-sig')
print(f" 格式: CSV (.csv)")
print(f"预测结果保存完成:")
print(f" 输出文件: {output_path}")
print(f" 数据形状: {result_df.shape}")
print(f" 列名: {list(result_df.columns)}")
# 显示预测结果统计
print(f"\n预测结果统计:")
print(result_df[prediction_column].describe())
return result_df
def inference_pipeline(self, sampling_csv_path: str, output_csv_path: str,
metric: str = 'test_r2', prediction_column: str = 'prediction',
model_file_path: str = None):
"""
完整的推理流程
Args:
sampling_csv_path: 采样数据CSV路径
output_csv_path: 输出预测结果CSV路径
metric: 选择最佳模型的指标
prediction_column: 预测列名称
model_file_path: 指定模型文件路径(可选)
"""
print("=" * 80)
print("开始水质参数反演推理流程")
print("=" * 80)
try:
# 1. 加载模型
print("\n步骤1: 加载模型")
print("-" * 40)
if self.external_model is not None:
# 已在 __init__ 中规范化,无需重复赋值
print(f" 使用外部预训练模型: type={type(self.external_model).__name__}")
elif model_file_path:
self.load_specific_model(model_file_path)
else:
self.load_best_model(metric=metric)
# 2. 加载采样数据(coords=坐标, spectra=纯光谱, wqi_df=45个WQI指数列)
print("\n步骤2: 加载采样数据")
print("-" * 40)
coords, spectra, wqi_df = self.load_sampling_data(sampling_csv_path)
# ═══════════════════════════════════════════════════════════════
# ★ 自适应反射率量级缩放 (Scale Alignment)
# ═══════════════════════════════════════════════════════════════
# 不同的高光谱传感器 / 处理流程产出的反射率量级可能不同:
# - float32 0-1 物理反射率(如 result3.bsq 抽样后写入的 CSV)
# - int16 0-10000 放大反射率(如 ref_mosaic 抽样后写入的 CSV)
# 若不经缩放直接喂入 SVR,量级差异会导致预测完全失效。
# 此处在光谱列上自动检测并统一到 0-1 区间。
spec_cols = []
for c in spectra.columns:
try:
float(str(c))
spec_cols.append(c)
except (ValueError, TypeError):
pass
if spec_cols:
max_val = spectra[spec_cols].max().max()
if max_val > 10:
print(f"\n[量级检测] 输入反射率疑似放大格式 (max={max_val:.2f})")
print("[量级检测] 自动除以 10000,缩放至 0-1 标准物理反射率区间...")
spectra[spec_cols] = spectra[spec_cols].astype(float) / 10000.0
print(f"[量级检测] 缩放完成!缩放后 max={spectra[spec_cols].max().max():.4f}")
else:
print(f"[量级检测] 输入反射率量级正常 (max={max_val:.4f}),无需缩放")
# ═══════════════════════════════════════════════════════════════
# 3. 数据预处理
print("\n步骤3: 数据预处理")
print("-" * 40)
spectra_processed = self.preprocess_spectra(spectra)
# 4. 模型预测
print("\n步骤4: 模型预测")
print("-" * 40)
predictions = self.predict(spectra_processed)
# ★ 全零光谱拦截:NDWI 掩膜外的陆地像素 → NaN
predictions = self._mask_zero_spectra_pixels(spectra, predictions)
# 5. 保存预测结果(透传 WQI 列至最终输出文件)
print("\n步骤5: 保存预测结果")
print("-" * 40)
result_df = self.save_predictions(coords, predictions, output_csv_path,
prediction_column, wqi_df)
print("\n" + "=" * 80)
print("推理流程完成!")
print("=" * 80)
return predictions, result_df
except Exception as e:
print(f"\n推理流程失败: {e}")
raise
def get_model_info(self) -> Dict:
"""
获取当前加载模型的信息
Returns:
模型信息字典
"""
if self.loaded_model_data is None:
return {"status": "no_model_loaded"}
info = {
"status": "model_loaded",
"preprocess_method": self.loaded_model_data.get('preprocess_method', 'Unknown'),
"model_name": self.loaded_model_data.get('model_name', type(self.external_model).__name__ if self.external_model else 'Unknown'),
"model_type": str(type(self.loaded_model_data['model'])),
"metadata": self.loaded_model_data.get('metadata', {})
}
if self.best_model_info:
info.update(self.best_model_info)
return info
def batch_inference(self, input_dir: str, output_dir: str,
metric: str = 'test_r2', prediction_column: str = 'prediction'):
"""
批量推理多个采样文件
Args:
input_dir: 输入目录,包含多个采样CSV文件
output_dir: 输出目录
metric: 选择最佳模型的指标
prediction_column: 预测列名称
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# 查找所有CSV文件
csv_files = list(input_path.glob("*.csv"))
if not csv_files:
print(f"在目录 {input_dir} 中未找到CSV文件")
return
print(f"找到 {len(csv_files)} 个CSV文件进行批量推理")
# 加载模型(只需加载一次)
self.load_best_model(metric=metric)
results = {}
for csv_file in csv_files:
try:
print(f"\n处理文件: {csv_file.name}")
output_file = output_path / f"prediction_{csv_file.name}"
# 执行推理
coords, spectra, wqi_df = self.load_sampling_data(str(csv_file))
# 自适应反射率量级缩放
_s_cols = []
for _c in spectra.columns:
try: float(str(_c)); _s_cols.append(_c)
except (ValueError, TypeError): pass
if _s_cols:
_mv = spectra[_s_cols].max().max()
if _mv > 10:
spectra[_s_cols] = spectra[_s_cols].astype(float) / 10000.0
spectra_processed = self.preprocess_spectra(spectra)
predictions = self.predict(spectra_processed)
predictions = self._mask_zero_spectra_pixels(spectra, predictions)
result_df = self.save_predictions(coords, predictions, str(output_file),
prediction_column, wqi_df)
results[csv_file.name] = {
'output_file': str(output_file),
'sample_count': len(predictions),
'prediction_stats': {
'mean': np.mean(predictions),
'std': np.std(predictions),
'min': np.min(predictions),
'max': np.max(predictions)
}
}
except Exception as e:
print(f"处理文件 {csv_file.name} 失败: {e}")
results[csv_file.name] = {'error': str(e)}
print(f"\n批量推理完成,共处理 {len(csv_files)} 个文件")
return results
def batch_inference_multi_models(self, models_root_dir: str, sampling_csv_path: str,
output_dir: str, metric: str = 'test_r2',
prediction_column: str = 'prediction',
output_format: str = 'csv',
external_model=None,
external_model_path=None,
external_models_dict=None):
"""
使用多个子文件夹中的模型进行批量推理
Args:
models_root_dir: 包含多个子文件夹的根目录,每个子文件夹作为artifacts_dir
sampling_csv_path: 采样数据CSV路径
output_dir: 输出目录
metric: 选择最佳模型的指标
prediction_column: 预测列名称
output_format: 输出文件格式 ('csv', 'xls', 'xlsx')
"""
models_root = Path(models_root_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
all_results = {}
# 优先级 1:_external_models_dict 非空 → 直接用字典的 keys 作为 targets,不扫描磁盘
print(f"[BatchInference] 终于收到字典啦!包含模型: {list(external_models_dict.keys()) if external_models_dict else 'None'}")
if external_models_dict is not None and len(external_models_dict) > 0:
targets = list(external_models_dict.keys())
print(f"\n使用外部导入模型字典({len(targets)} 个模型)")
print(f"检测到外部导入模型,将预测以下参数: {targets}")
elif external_model is not None:
print(f"\n使用外部预训练模型: {external_model_path or 'unknown'}")
subdirs = [d for d in models_root.iterdir() if d.is_dir()]
if not subdirs:
print(f"在目录 {models_root_dir} 中未找到子文件夹")
return {}
print(f"找到 {len(subdirs)} 个模型子文件夹进行批量推理")
targets = [d.name for d in subdirs]
else:
subdirs = [d for d in models_root.iterdir() if d.is_dir()]
if not subdirs:
print(f"在目录 {models_root_dir} 中未找到子文件夹")
return {}
print(f"找到 {len(subdirs)} 个模型子文件夹进行批量推理")
targets = [d.name for d in subdirs]
print(f"输出格式: {output_format.upper()}")
for subdir_name in targets:
try:
print(f"\n{'='*60}")
print(f"处理模型: {subdir_name}")
print(f"{'='*60}")
# 优先级:字典中该 target 的模型 > 共享单模型 > 磁盘加载
effective_model = None
if external_models_dict and subdir_name in external_models_dict:
effective_model = external_models_dict[subdir_name]
print(f" → 使用字典中模型: {type(effective_model).__name__}")
elif external_model is not None:
effective_model = external_model
print(f" → 使用共享外部模型: {type(effective_model).__name__}")
# artifacts_dir:字典模式优先用 placeholder "./",否则用真实子目录
artifacts_dir = (
str(models_root / subdir_name)
if (models_root / subdir_name).is_dir()
else str(models_root)
)
if effective_model is not None:
model_inferencer = WaterQualityInference(
artifacts_dir,
external_model=effective_model,
external_model_path=external_model_path or "",
)
else:
model_inferencer = WaterQualityInference(artifacts_dir)
# 根据输出格式设置文件扩展名
file_ext = f".{output_format}"
output_file = output_path / f"{subdir_name}{file_ext}"
# 执行推理流程
predictions, result_df = model_inferencer.inference_pipeline(
sampling_csv_path=sampling_csv_path,
output_csv_path=str(output_file),
metric=metric,
prediction_column=prediction_column
)
# 收集结果信息
model_info = model_inferencer.get_model_info()
all_results[subdir_name] = {
'status': 'success',
'output_file': str(output_file),
'sample_count': len(predictions),
'model_info': model_info,
'prediction_stats': {
'mean': np.mean(predictions),
'std': np.std(predictions),
'min': np.min(predictions),
'max': np.max(predictions)
}
}
print(f"模型 {subdir_name} 处理完成")
except Exception as e:
print(f"处理模型 {subdir_name} 失败: {e}")
all_results[subdir_name] = {
'status': 'error',
'error': str(e)
}
print(f"\n{'='*80}")
print(f"批量推理完成,共处理 {len(subdirs)} 个模型文件夹")
print(f"{'='*80}")
# 打印汇总信息
print("\n汇总结果:")
for folder_name, result in all_results.items():
if result['status'] == 'success':
print(f" ✓ {folder_name}: {result['sample_count']} 个预测值,"
f"均值={result['prediction_stats']['mean']:.4f}")
else:
print(f" ✗ {folder_name}: 失败 - {result['error']}")
return all_results
def batch_inference_multi_data(self, artifacts_dir: str, input_dir: str,
output_dir: str, metric: str = 'test_r2',
prediction_column: str = 'prediction',
output_format: str = 'csv'):
"""
使用一个模型对多个数据文件进行批量推理,输出文件名为数据文件名(不含扩展名)
Args:
artifacts_dir: 模型目录
input_dir: 输入目录,包含多个采样CSV文件
output_dir: 输出目录
metric: 选择最佳模型的指标
prediction_column: 预测列名称
output_format: 输出文件格式 ('csv', 'xls', 'xlsx')
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# 查找所有CSV文件
csv_files = list(input_path.glob("*.csv"))
if not csv_files:
print(f"在目录 {input_dir} 中未找到CSV文件")
return
print(f"找到 {len(csv_files)} 个CSV文件进行批量推理")
print(f"输出格式: {output_format.upper()}")
# 初始化推理器并加载模型(只需加载一次)
self.artifacts_dir = Path(artifacts_dir)
self.load_best_model(metric=metric)
results = {}
for csv_file in csv_files:
try:
# 获取不含扩展名的文件名
file_stem = csv_file.stem
print(f"\n处理文件: {csv_file.name}")
# 根据输出格式设置文件扩展名
file_ext = f".{output_format}"
output_file = output_path / f"{file_stem}{file_ext}"
# 执行推理
coords, spectra, wqi_df = self.load_sampling_data(str(csv_file))
# 自适应反射率量级缩放
_s_cols = []
for _c in spectra.columns:
try: float(str(_c)); _s_cols.append(_c)
except (ValueError, TypeError): pass
if _s_cols:
_mv = spectra[_s_cols].max().max()
if _mv > 10:
spectra[_s_cols] = spectra[_s_cols].astype(float) / 10000.0
spectra_processed = self.preprocess_spectra(spectra)
predictions = self.predict(spectra_processed)
predictions = self._mask_zero_spectra_pixels(spectra, predictions)
result_df = self.save_predictions(coords, predictions, str(output_file),
prediction_column, wqi_df)
results[file_stem] = {
'input_file': str(csv_file),
'output_file': str(output_file),
'sample_count': len(predictions),
'prediction_stats': {
'mean': np.mean(predictions),
'std': np.std(predictions),
'min': np.min(predictions),
'max': np.max(predictions)
}
}
except Exception as e:
print(f"处理文件 {csv_file.name} 失败: {e}")
results[csv_file.stem] = {'error': str(e)}
print(f"\n批量推理完成,共处理 {len(csv_files)} 个文件")
return results
def evaluate_with_split(self, data_csv_path: str, split_method: str = "random",
test_size: float = 0.2, random_state: int = 42,
target_column: int = 11, feature_start_column: int = 13,
metric: str = 'test_r2', prediction_column: str = 'prediction'):
"""
使用训练时相同的数据分割方法进行模型评估
Args:
data_csv_path: 包含目标值的完整数据集CSV路径
split_method: 数据分割方法 ("random", "spxy", "ks")
test_size: 测试集比例
random_state: 随机种子
target_column: 目标值列索引
feature_start_column: 特征开始列索引
metric: 选择模型的评估指标
prediction_column: 预测结果列名
Returns:
评估结果字典
"""
print("=" * 80)
print("开始数据分割评估流程")
print("=" * 80)
try:
# 1. 加载完整数据集
print("\n步骤1: 加载完整数据集")
print("-" * 40)
data = pd.read_csv(data_csv_path)
# 提取目标值和特征
y = data.iloc[:, target_column]
X = data.iloc[:, feature_start_column:]
# 去除目标值为空的行
mask = ~y.isna()
data_cleaned = data[mask]
y_cleaned = data_cleaned.iloc[:, target_column]
X_cleaned = data_cleaned.iloc[:, feature_start_column:]
print(f"数据加载完成:")
print(f" 原始样本数: {len(data)}")
print(f" 清理后样本数: {len(X_cleaned)}")
print(f" 特征数量: {X_cleaned.shape[1]}")
print(f" 目标值范围: {y_cleaned.min():.4f} ~ {y_cleaned.max():.4f}")
# 2. 加载最佳模型
print("\n步骤2: 加载最佳模型")
print("-" * 40)
self.load_best_model(metric=metric)
# 3. 数据预处理
print("\n步骤3: 数据预处理")
print("-" * 40)
X_processed = self.preprocess_spectra(X_cleaned)
# 4. 数据分割
print("\n步骤4: 数据分割")
print("-" * 40)
X_train, X_test, y_train, y_test = self.split_data(
X_processed, y_cleaned, method=split_method,
test_size=test_size, random_state=random_state
)
print(f"数据分割完成:")
print(f" 训练集样本数: {X_train.shape[0]}")
print(f" 测试集样本数: {X_test.shape[0]}")
# 5. 模型预测
print("\n步骤5: 模型预测")
print("-" * 40)
# 训练集预测
y_train_pred = self.loaded_model_data['model'].predict(X_train)
# 测试集预测
y_test_pred = self.loaded_model_data['model'].predict(X_test)
# 6. 计算评估指标
print("\n步骤6: 计算评估指标")
print("-" * 40)
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
# 训练集指标
train_mse = mean_squared_error(y_train, y_train_pred)
train_mae = mean_absolute_error(y_train, y_train_pred)
train_r2 = r2_score(y_train, y_train_pred)
train_rmse = np.sqrt(train_mse)
# 测试集指标
test_mse = mean_squared_error(y_test, y_test_pred)
test_mae = mean_absolute_error(y_test, y_test_pred)
test_r2 = r2_score(y_test, y_test_pred)
test_rmse = np.sqrt(test_mse)
results = {
'split_method': split_method,
'test_size': test_size,
'train_size': len(y_train),
'test_size_actual': len(y_test),
'train_metrics': {
'mse': train_mse,
'mae': train_mae,
'rmse': train_rmse,
'r2': train_r2
},
'test_metrics': {
'mse': test_mse,
'mae': test_mae,
'rmse': test_rmse,
'r2': test_r2
},
'predictions': {
'y_train_true': y_train,
'y_train_pred': y_train_pred,
'y_test_true': y_test,
'y_test_pred': y_test_pred
}
}
print(f"评估完成:")
print(f" 训练集指标:")
print(f" R²: {train_r2:.4f}")
print(f" RMSE: {train_rmse:.4f}")
print(f" MAE: {train_mae:.4f}")
print(f" 测试集指标:")
print(f" R²: {test_r2:.4f}")
print(f" RMSE: {test_rmse:.4f}")
print(f" MAE: {test_mae:.4f}")
print("\n" + "=" * 80)
print("数据分割评估流程完成!")
print("=" * 80)
return results
except Exception as e:
print(f"\n数据分割评估失败: {e}")
raise
def main():
"""主函数示例"""
# 创建推理实例
artifacts_dir = r"E:\code\WQ\yaobao925\qvchuyaoban"
inferencer = WaterQualityInference(artifacts_dir)
# 配置文件路径
sampling_csv = r"E:\code\WQ\xiaogujia\使用腰堡模型\spectral_sampling_results.csv"
# output_csv = r"E:\code\WQ\laodao\output"
try:
# # 示例1: 单个模型单个数据文件的推理
# print("示例1: 单个模型单个数据文件的推理")
# predictions, result_df = inferencer.inference_pipeline(
# sampling_csv_path=sampling_csv,
# output_csv_path=output_csv,
# metric='test_r2', # 使用测试集R²作为选择最佳模型的指标
# prediction_column='water_quality_prediction'
# )
#
# print(f"\n推理完成,共生成 {len(predictions)} 个预测值")
#
# # 显示模型信息
# model_info = inferencer.get_model_info()
# print(f"\n使用的模型信息:")
# print(f" 组合: {model_info.get('combination', 'Unknown')}")
# print(f" 预处理: {model_info.get('preprocess_method', 'Unknown')}")
# print(f" 算法: {model_info.get('model_name', 'Unknown')}")
# 示例2: 批量推理多个模型(每个子文件夹作为不同的artifacts_dir)
print(f"\n{'='*80}")
print("示例2: 批量推理多个模型")
models_root_dir = r"E:\code\WQ\yaobao925\qvchuyaoban" # 包含多个子文件夹的根目录
output_dir = r"E:\code\WQ\xiaogujia\使用腰堡模型\predict"
all_results = inferencer.batch_inference_multi_models(
models_root_dir=models_root_dir,
sampling_csv_path=sampling_csv,
output_dir=output_dir,
metric='test_r2',
prediction_column='water_quality_prediction'
)
# 示例3: 使用数据分割方法进行模型评估(可选)
# print(f"\n{'='*80}")
# print("示例3: 数据分割评估")
# complete_data_csv = r"E:\code\WQ\laodao\data\捞刀河-浏阳河-圭塘河.csv" # 包含目标值的完整数据集
#
# # 使用SPXY方法进行数据分割评估
# eval_results = inferencer.evaluate_with_split(
# data_csv_path=complete_data_csv,
# split_method="spxy", # 可选: "random", "spxy", "ks"
# test_size=0.2,
# random_state=42,
# target_column=11, # 目标值列索引
# feature_start_column=13, # 特征开始列索引
# metric='test_r2'
# )
#
# print(f"\n数据分割评估结果:")
# print(f" 分割方法: {eval_results['split_method']}")
# print(f" 训练集R²: {eval_results['train_metrics']['r2']:.4f}")
# print(f" 测试集R²: {eval_results['test_metrics']['r2']:.4f}")
# print(f" 训练集RMSE: {eval_results['train_metrics']['rmse']:.4f}")
# print(f" 测试集RMSE: {eval_results['test_metrics']['rmse']:.4f}")
except Exception as e:
print(f"推理失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()