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()