""" Adaptive scaling module - Feature-specific optimal scalers Each feature uses the most appropriate scaling method based on its distribution """ import os, sys import numpy as np import pandas as pd from sklearn.preprocessing import PowerTransformer, StandardScaler, RobustScaler as SklearnRobustScaler # 프로젝트 루트 경로 추가 _current_dir = os.path.dirname(os.path.abspath(__file__)) _project_root = os.path.dirname(os.path.dirname(_current_dir)) if _project_root not in sys.path: sys.path.insert(0, _project_root) from admet_ft._modules.utils import DIDB_FILTER_COLS # 화학적 도메인 기반 feature 범위 정의 FEATURE_RANGES = { "logP": (-10, 10), # Octanol-water partition coefficient "pKa": (0, 14), # Acid dissociation constant (pH scale) "solubility": (0, 10000), # mg/L "permeability": (0, 1000), # 10^-6 cm/s (Caco-2) "plasma_protein_binding": (0, 100), # percentage "fu_in_vitro": (0, 1), # fraction unbound } SCALER_FILE_NAME = { 'zscore': 'scaler_config.csv', 'power': 'scaler_power_config.csv', 'minmax': 'scaler_minmax_config.csv', 'adapt': 'scaler_adapt.csv' } # Feature-specific scaler configuration ADAPTIVE_SCALER_CONFIG = { 'logP': { 'type': 'standard', 'reason': 'Symmetric distribution (skew=-0.25), no transformation needed', 'transform': None }, 'pKa': { 'type': 'standard', 'reason': 'Symmetric distribution (skew=0.29), no transformation needed', 'transform': None }, 'solubility': { 'type': 'log_standard', 'reason': 'Extreme skewness (10.35), mean/median=142x, needs log transformation', 'transform': 'log1p' }, 'permeability': { 'type': 'log_standard', 'reason': 'High skewness (4.79), positive values, log transformation optimal', 'transform': 'log1p' }, 'plasma_protein_binding': { 'type': 'minmax', 'reason': 'Percentage [0-100], bounded range, physical meaning preserved', 'transform': None, 'range': [0, 100] }, 'fu_in_vitro': { 'type': 'none', 'reason': 'Fraction [0-1], already normalized', 'transform': None } } def _build_power_transformer(lmbda: float) -> PowerTransformer: pt = PowerTransformer(method='yeo-johnson', standardize=False) pt.lambdas_ = np.array([lmbda], dtype=np.float64) pt.n_features_in_ = 1 pt.n_samples_seen_ = None return pt def save_scaler(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ 기존 표준 스케일러 (Z-score)를 유지하기 위해 이전 구현과 동일한 기능을 제공합니다. """ scaled_df = df.copy() scaler_data = [] for col in DIDB_FILTER_COLS: if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce') mean = float(df[col].mean(skipna=True)) std = float(df[col].std(skipna=True)) if pd.isna(std) or std == 0: std = 1.0 scaled_df[col] = (df[col] - mean) / std scaler_data.append({'feature': col, 'mean': mean, 'std': std}) scaler_df = pd.DataFrame(scaler_data) os.makedirs(os.path.dirname(scaler_path), exist_ok=True) scaler_df.to_csv(scaler_path, index=False) return scaled_df def reverse_scaling(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: scaler_df = pd.read_csv(scaler_path) for col in DIDB_FILTER_COLS: if col in df.columns and col in scaler_df['feature'].values: mean = float(scaler_df.loc[scaler_df['feature'] == col, 'mean'].values[0]) std = float(scaler_df.loc[scaler_df['feature'] == col, 'std'].values[0]) df[col] = (df[col] * std) + mean return df def apply_scaler(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler file not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) scaled_df = df.copy() for col in DIDB_FILTER_COLS: if col not in scaled_df.columns: continue if col not in scaler_df['feature'].values: continue mean = float(scaler_df.loc[scaler_df['feature'] == col, 'mean'].values[0]) std = float(scaler_df.loc[scaler_df['feature'] == col, 'std'].values[0]) scaled_df[col] = pd.to_numeric(scaled_df[col], errors='coerce') if pd.isna(std) or std == 0: scaled_df[col] = scaled_df[col] - mean else: scaled_df[col] = (scaled_df[col] - mean) / std return scaled_df def save_scaler_power(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ 학습 데이터프레임을 scikit-learn PowerTransformer(Yeo-Johnson) 기반으로 스케일링하고, 변환 파라미터(lambda, mean, std)를 CSV로 저장합니다. """ scaled_df = df.copy() scaler_data = [] for col in DIDB_FILTER_COLS: if col not in df.columns: continue series = pd.to_numeric(df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.loc[valid_mask].to_numpy(dtype=np.float64).reshape(-1, 1) pt = PowerTransformer(method='yeo-johnson', standardize=False) transformed = pt.fit_transform(values).reshape(-1) lmbda = float(pt.lambdas_[0]) mean = float(np.mean(transformed)) std = float(np.std(transformed)) if np.isnan(std) or std == 0.0: std = 1.0 scaled_values = np.empty_like(series, dtype=np.float64) scaled_values[:] = np.nan scaled_values[valid_mask] = (transformed - mean) / std scaled_df[col] = scaled_values scaler_data.append({ 'feature': col, 'lambda': lmbda, 'mean': mean, 'std': std }) scaler_df = pd.DataFrame(scaler_data) os.makedirs(os.path.dirname(scaler_path), exist_ok=True) scaler_df.to_csv(scaler_path, index=False) return scaled_df def reverse_scaling_power(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: scaler_df = pd.read_csv(scaler_path) result_df = df.copy() for col in DIDB_FILTER_COLS: if col not in result_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] lmbda = float(row['lambda']) mean = float(row['mean']) std = float(row['std']) series = pd.to_numeric(result_df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.to_numpy(dtype=np.float64) w = values[valid_mask] * std + mean pt = _build_power_transformer(lmbda) original = pt.inverse_transform(w.reshape(-1, 1)).reshape(-1) values[valid_mask] = original result_df[col] = values return result_df def apply_scaler_power(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler file not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) scaled_df = df.copy() for col in DIDB_FILTER_COLS: if col not in scaled_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] lmbda = float(row['lambda']) mean = float(row['mean']) std = float(row['std']) series = pd.to_numeric(scaled_df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.to_numpy(dtype=np.float64) pt = _build_power_transformer(lmbda) transformed = pt.transform(values[valid_mask].reshape(-1, 1)).reshape(-1) standardized = (transformed - mean) / std values[valid_mask] = standardized scaled_df[col] = values return scaled_df def save_scaler_robust(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ RobustScaler를 사용하여 데이터 스케일링 (median, IQR 기반 - outlier에 강건) Args: df: 입력 데이터프레임 scaler_path: 스케일러 파라미터 저장 경로 Returns: 스케일링된 데이터프레임 """ scaled_df = df.copy() scaler_data = [] for col in DIDB_FILTER_COLS: if col not in df.columns: continue series = pd.to_numeric(df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series[valid_mask].to_numpy(dtype=np.float64).reshape(-1, 1) # RobustScaler 적용 (median, IQR 사용) scaler = SklearnRobustScaler() scaled_values = scaler.fit_transform(values).reshape(-1) # 스케일링된 값을 데이터프레임에 저장 scaled_array = np.empty_like(series, dtype=np.float64) scaled_array[:] = np.nan scaled_array[valid_mask] = scaled_values scaled_df[col] = scaled_array # 스케일러 파라미터 저장 scaler_data.append({ 'feature': col, 'median': float(scaler.center_[0]), 'scale': float(scaler.scale_[0]) # IQR / 1.35 }) scaler_df = pd.DataFrame(scaler_data) os.makedirs(os.path.dirname(scaler_path), exist_ok=True) scaler_df.to_csv(scaler_path, index=False) return scaled_df def reverse_scaling_robust(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ RobustScaler로 스케일링된 데이터를 원본 스케일로 복원 Args: df: 스케일링된 데이터프레임 scaler_path: 스케일러 파라미터 경로 Returns: 원본 스케일로 복원된 데이터프레임 """ scaler_df = pd.read_csv(scaler_path) result_df = df.copy() for col in DIDB_FILTER_COLS: if col not in result_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] median = float(row['median']) scale = float(row['scale']) series = pd.to_numeric(result_df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.to_numpy(dtype=np.float64) # 역변환: (scaled_value * scale) + median values[valid_mask] = values[valid_mask] * scale + median result_df[col] = values return result_df def apply_scaler_robust(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ 저장된 RobustScaler 파라미터를 사용하여 새 데이터 스케일링 Args: df: 입력 데이터프레임 scaler_path: 스케일러 파라미터 경로 Returns: 스케일링된 데이터프레임 """ if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler file not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) scaled_df = df.copy() for col in DIDB_FILTER_COLS: if col not in scaled_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] median = float(row['median']) scale = float(row['scale']) series = pd.to_numeric(scaled_df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.to_numpy(dtype=np.float64) # 변환: (value - median) / scale values[valid_mask] = (values[valid_mask] - median) / scale scaled_df[col] = values return scaled_df def save_scaler_minmax(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ 도메인 지식 기반 MinMaxScaler (화학적 특성의 물리적 범위 고려) Args: df: 입력 데이터프레임 scaler_path: 스케일러 파라미터 저장 경로 Returns: 스케일링된 데이터프레임 [0, 1] 범위 """ scaled_df = df.copy() scaler_data = [] for col in DIDB_FILTER_COLS: if col not in df.columns: continue if col not in FEATURE_RANGES: print(f"Warning: {col} not in FEATURE_RANGES. Skipping.") continue min_val, max_val = FEATURE_RANGES[col] series = pd.to_numeric(df[col], errors='coerce') # MinMax 스케일링: (x - min) / (max - min) scaled_df[col] = (series - min_val) / (max_val - min_val) # 범위를 [0, 1]로 클리핑 (도메인 범위 밖의 값 처리) scaled_df[col] = scaled_df[col].clip(0, 1) scaler_data.append({ 'feature': col, 'min': min_val, 'max': max_val }) scaler_df = pd.DataFrame(scaler_data) os.makedirs(os.path.dirname(scaler_path), exist_ok=True) scaler_df.to_csv(scaler_path, index=False) return scaled_df def reverse_scaling_minmax(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ MinMaxScaler로 스케일링된 데이터를 원본 스케일로 복원 Args: df: 스케일링된 데이터프레임 scaler_path: 스케일러 파라미터 경로 Returns: 원본 스케일로 복원된 데이터프레임 """ scaler_df = pd.read_csv(scaler_path) result_df = df.copy() for col in DIDB_FILTER_COLS: if col not in result_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] min_val = float(row['min']) max_val = float(row['max']) series = pd.to_numeric(result_df[col], errors='coerce') # 역변환: scaled_value * (max - min) + min result_df[col] = series * (max_val - min_val) + min_val return result_df def apply_scaler_minmax(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ 저장된 MinMaxScaler 파라미터를 사용하여 새 데이터 스케일링 Args: df: 입력 데이터프레임 scaler_path: 스케일러 파라미터 경로 Returns: 스케일링된 데이터프레임 """ if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler file not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) scaled_df = df.copy() for col in DIDB_FILTER_COLS: if col not in scaled_df.columns: continue if col not in scaler_df['feature'].values: continue row = scaler_df.loc[scaler_df['feature'] == col].iloc[0] min_val = float(row['min']) max_val = float(row['max']) series = pd.to_numeric(scaled_df[col], errors='coerce') # MinMax 스케일링 scaled_df[col] = (series - min_val) / (max_val - min_val) # 클리핑 scaled_df[col] = scaled_df[col].clip(0, 1) return scaled_df def save_scaler_adaptive(df: pd.DataFrame, scaler_path: str, feature_cols: list) -> pd.DataFrame: """ Apply adaptive scaling - feature-specific optimal scalers Args: df: Input dataframe scaler_path: Path to save scaler config feature_cols: List of feature columns to scale Returns: Scaled dataframe """ os.makedirs(os.path.dirname(scaler_path) or '.', exist_ok=True) df_scaled = df.copy() scaler_info = [] for col in feature_cols: if col not in df.columns: continue config = ADAPTIVE_SCALER_CONFIG.get(col, {'type': 'standard', 'transform': None}) scaler_type = config['type'] transform = config.get('transform') # Get non-null values mask = df[col].notna() values = df.loc[mask, col].values.reshape(-1, 1) if len(values) == 0: continue # Step 1: Apply pre-transformation if needed transformed_values = values.copy() if transform == 'log1p': transformed_values = np.log1p(values) # Don't append to scaler_info yet - will be done in Step 2 with complete info # Step 2: Apply scaler if scaler_type == 'none': # No scaling scaled_values = transformed_values scaler_info.append({ 'feature': col, 'transform': transform or 'none', 'scaler_type': 'none', 'mean': float(np.mean(values)), 'std': float(np.std(values)), 'reason': config.get('reason', '') }) elif scaler_type == 'minmax' or scaler_type == 'log_minmax': # MinMaxScaler # Use predefined range (e.g., [0, 100] for PPB) instead of data min/max range_min, range_max = config.get('range', [values.min(), values.max()]) # Scale using predefined range scaled_values = (transformed_values - range_min) / (range_max - range_min) scaler_info.append({ 'feature': col, 'transform': transform or 'none', 'scaler_type': 'minmax', 'min': float(range_min), # Use predefined range, not data min/max 'max': float(range_max), # Use predefined range, not data min/max 'range_min': float(range_min), 'range_max': float(range_max), 'reason': config.get('reason', '') }) elif scaler_type == 'standard' or scaler_type == 'log_standard': # StandardScaler (Z-score) scaler = StandardScaler() scaled_values = scaler.fit_transform(transformed_values) scaler_info.append({ 'feature': col, 'transform': transform or 'none', 'scaler_type': 'standard', 'mean': float(scaler.mean_[0]), 'std': float(np.sqrt(scaler.var_[0])), 'reason': config.get('reason', '') }) elif scaler_type == 'power': # PowerTransformer scaler = PowerTransformer(method='yeo-johnson', standardize=True) scaled_values = scaler.fit_transform(transformed_values) scaler_info.append({ 'feature': col, 'transform': transform or 'none', 'scaler_type': 'power', 'lambda': float(scaler.lambdas_[0]), 'mean': float(np.mean(scaled_values)), 'std': float(np.std(scaled_values)), 'reason': config.get('reason', '') }) else: raise ValueError(f"Unknown scaler type: {scaler_type}") # Update scaled values df_scaled.loc[mask, col] = scaled_values.flatten() # Save scaler config scaler_df = pd.DataFrame(scaler_info) scaler_df.to_csv(scaler_path, index=False) print(f"📊 Adaptive scaler config saved to: {scaler_path}") return df_scaled def apply_scaler_adaptive(df: pd.DataFrame, scaler_path: str, feature_cols: list) -> pd.DataFrame: """ Apply saved adaptive scaling configuration Args: df: Input dataframe scaler_path: Path to scaler config feature_cols: List of feature columns to scale Returns: Scaled dataframe """ if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler config not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) df_scaled = df.copy() for _, row in scaler_df.iterrows(): col = row['feature'] if col not in df.columns or col not in feature_cols: continue # Get non-null values mask = df[col].notna() values = df.loc[mask, col].values.reshape(-1, 1) if len(values) == 0: continue # Step 1: Apply pre-transformation transformed_values = values.copy() if row['transform'] == 'log1p': transformed_values = np.log1p(values) # Step 2: Apply scaler if row['scaler_type'] == 'none': scaled_values = transformed_values elif row['scaler_type'] == 'minmax' or row['scaler_type'] == 'log_minmax': min_val = row['min'] max_val = row['max'] scaled_values = (transformed_values - min_val) / (max_val - min_val) elif row['scaler_type'] == 'standard' or row['scaler_type'] == 'log_standard': mean = row['mean'] std = row['std'] scaled_values = (transformed_values - mean) / std elif row['scaler_type'] == 'power': # For PowerTransformer, we need to use sklearn (complex transformation) # For now, approximate with standardization mean = row['mean'] std = row['std'] scaled_values = (transformed_values - mean) / std else: raise ValueError(f"Unknown scaler type: {row['scaler_type']}") df_scaled.loc[mask, col] = scaled_values.flatten() return df_scaled def reverse_scaling_adaptive(df: pd.DataFrame, scaler_path: str) -> pd.DataFrame: """ Reverse adaptive scaling for predictions (DataFrame version) Args: df: DataFrame with scaled predictions scaler_path: Path to scaler config Returns: DataFrame with original scale values """ if not os.path.exists(scaler_path): raise FileNotFoundError(f"Scaler config not found: {scaler_path}") scaler_df = pd.read_csv(scaler_path) result_df = df.copy() for col in DIDB_FILTER_COLS: if col not in result_df.columns: continue scaler_row = scaler_df[scaler_df['feature'] == col] if len(scaler_row) == 0: # No scaling applied for this feature continue row = scaler_row.iloc[0] series = pd.to_numeric(result_df[col], errors='coerce') valid_mask = series.notna() if valid_mask.sum() == 0: continue values = series.to_numpy(dtype=np.float64) reversed_values = values[valid_mask].copy() # Step 1: Reverse scaler if row['scaler_type'] == 'none': pass elif row['scaler_type'] == 'minmax' or row['scaler_type'] == 'log_minmax': min_val = row['min'] max_val = row['max'] reversed_values = reversed_values * (max_val - min_val) + min_val elif row['scaler_type'] == 'standard' or row['scaler_type'] == 'log_standard': mean = row['mean'] std = row['std'] reversed_values = reversed_values * std + mean elif row['scaler_type'] == 'power': mean = row['mean'] std = row['std'] reversed_values = reversed_values * std + mean # Step 2: Reverse pre-transformation if row['transform'] == 'log1p': reversed_values = np.expm1(reversed_values) # inverse of log1p # Safety: Handle invalid values from numerical issues reversed_values = np.nan_to_num( reversed_values, nan=0.01, # NaN → 0.01 (minimum valid value) posinf=10000.0, # +inf → reasonable max neginf=0.01 # -inf → 0.01 (minimum valid value) ) # Clip to ensure minimum valid value (0.01 for solubility/permeability) reversed_values = np.maximum(reversed_values, 0.01) values[valid_mask] = reversed_values result_df[col] = values return result_df