River_Network / src /data /processors /normalizer.py
ageraustine's picture
Upload folder using huggingface_hub
a74054f verified
Raw
History Blame Contribute Delete
8.3 kB
"""
Data normalization strategies.
Single Responsibility: Normalize hydrological/meteorological features.
Open/Closed Principle: Easy to add new normalization methods.
"""
import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Tuple
from abc import ABC, abstractmethod
import pickle
class BaseNormalizer(ABC):
"""
Abstract base class for normalization strategies.
Dependency Inversion: Depend on abstraction, not concrete normalizers.
"""
def __init__(self):
"""Initialize normalizer."""
self.stats: Dict[str, Dict] = {}
self.is_fitted = False
@abstractmethod
def fit(self, df: pd.DataFrame, columns: Optional[List[str]] = None) -> 'BaseNormalizer':
"""
Fit normalizer to data (compute statistics).
Args:
df: Input dataframe
columns: Columns to normalize (None = all numeric)
Returns:
self (for chaining)
"""
pass
@abstractmethod
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Transform data using fitted statistics.
Args:
df: Input dataframe
Returns:
Normalized dataframe
"""
pass
def fit_transform(self, df: pd.DataFrame, columns: Optional[List[str]] = None) -> pd.DataFrame:
"""
Fit and transform in one step.
Args:
df: Input dataframe
columns: Columns to normalize
Returns:
Normalized dataframe
"""
self.fit(df, columns)
return self.transform(df)
def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Reverse normalization.
Args:
df: Normalized dataframe
Returns:
Original scale dataframe
"""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before inverse transform")
return df.copy()
def save(self, filepath: str) -> None:
"""Save normalizer statistics to file."""
with open(filepath, 'wb') as f:
pickle.dump(self.stats, f)
def load(self, filepath: str) -> None:
"""Load normalizer statistics from file."""
with open(filepath, 'rb') as f:
self.stats = pickle.load(f)
self.is_fitted = True
class StandardNormalizer(BaseNormalizer):
"""
Z-score normalization: (x - mean) / std
Best for normally distributed data.
"""
def fit(self, df: pd.DataFrame, columns: Optional[List[str]] = None) -> 'StandardNormalizer':
"""Fit by computing mean and std for each column."""
if columns is None:
columns = df.select_dtypes(include=[np.number]).columns.tolist()
for col in columns:
if col in df.columns:
self.stats[col] = {
'mean': df[col].mean(),
'std': df[col].std()
}
self.is_fitted = True
return self
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Apply z-score normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before transform")
df_norm = df.copy()
for col, stats in self.stats.items():
if col in df_norm.columns:
df_norm[col] = (df_norm[col] - stats['mean']) / stats['std']
return df_norm
def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Reverse z-score normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before inverse transform")
df_orig = df.copy()
for col, stats in self.stats.items():
if col in df_orig.columns:
df_orig[col] = df_orig[col] * stats['std'] + stats['mean']
return df_orig
class MinMaxNormalizer(BaseNormalizer):
"""
Min-max normalization: (x - min) / (max - min)
Scales to [0, 1] range.
"""
def fit(self, df: pd.DataFrame, columns: Optional[List[str]] = None) -> 'MinMaxNormalizer':
"""Fit by computing min and max for each column."""
if columns is None:
columns = df.select_dtypes(include=[np.number]).columns.tolist()
for col in columns:
if col in df.columns:
self.stats[col] = {
'min': df[col].min(),
'max': df[col].max()
}
self.is_fitted = True
return self
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Apply min-max normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before transform")
df_norm = df.copy()
for col, stats in self.stats.items():
if col in df_norm.columns:
denominator = stats['max'] - stats['min']
if denominator == 0:
df_norm[col] = 0 # Constant column
else:
df_norm[col] = (df_norm[col] - stats['min']) / denominator
return df_norm
def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Reverse min-max normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before inverse transform")
df_orig = df.copy()
for col, stats in self.stats.items():
if col in df_orig.columns:
df_orig[col] = df_orig[col] * (stats['max'] - stats['min']) + stats['min']
return df_orig
class RobustNormalizer(BaseNormalizer):
"""
Robust normalization using median and IQR: (x - median) / IQR
Best for data with outliers (recommended for hydrological data).
"""
def fit(self, df: pd.DataFrame, columns: Optional[List[str]] = None) -> 'RobustNormalizer':
"""Fit by computing median and IQR for each column."""
if columns is None:
columns = df.select_dtypes(include=[np.number]).columns.tolist()
for col in columns:
if col in df.columns:
q1 = df[col].quantile(0.25)
q3 = df[col].quantile(0.75)
self.stats[col] = {
'median': df[col].median(),
'iqr': q3 - q1
}
self.is_fitted = True
return self
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Apply robust normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before transform")
df_norm = df.copy()
for col, stats in self.stats.items():
if col in df_norm.columns:
if stats['iqr'] == 0:
df_norm[col] = 0 # Constant column
else:
df_norm[col] = (df_norm[col] - stats['median']) / stats['iqr']
return df_norm
def inverse_transform(self, df: pd.DataFrame) -> pd.DataFrame:
"""Reverse robust normalization."""
if not self.is_fitted:
raise RuntimeError("Normalizer must be fitted before inverse transform")
df_orig = df.copy()
for col, stats in self.stats.items():
if col in df_orig.columns:
df_orig[col] = df_orig[col] * stats['iqr'] + stats['median']
return df_orig
def get_normalizer(method: str = "robust") -> BaseNormalizer:
"""
Factory function to get normalizer by name.
Open/Closed Principle: Easy to extend with new normalizers.
Args:
method: Normalization method ("standard", "minmax", "robust")
Returns:
Normalizer instance
"""
normalizers = {
"standard": StandardNormalizer,
"minmax": MinMaxNormalizer,
"robust": RobustNormalizer
}
if method not in normalizers:
raise ValueError(f"Unknown normalization method: {method}. "
f"Choose from {list(normalizers.keys())}")
return normalizers[method]()