Spaces:
Running on Zero
Running on Zero
| """ | |
| Data cleaning utilities. | |
| Single Responsibility: Clean and preprocess hydrological/meteorological data. | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| from typing import Optional, List | |
| class DataCleaner: | |
| """ | |
| Cleans hydrological and meteorological datasets. | |
| Handles missing values, outliers, and data quality issues. | |
| """ | |
| def __init__( | |
| self, | |
| missing_threshold: float = 0.3, | |
| outlier_method: str = "iqr" | |
| ): | |
| """ | |
| Initialize data cleaner. | |
| Args: | |
| missing_threshold: Max fraction of missing values allowed per column | |
| outlier_method: Method for outlier detection ("iqr", "zscore", "none") | |
| """ | |
| self.missing_threshold = missing_threshold | |
| self.outlier_method = outlier_method | |
| def clean( | |
| self, | |
| df: pd.DataFrame, | |
| date_col: str = "date", | |
| station_col: str = "station_id" | |
| ) -> pd.DataFrame: | |
| """ | |
| Clean dataframe with multiple steps. | |
| Args: | |
| df: Input dataframe | |
| date_col: Name of date column | |
| station_col: Name of station ID column | |
| Returns: | |
| Cleaned dataframe | |
| """ | |
| df = df.copy() | |
| # Remove duplicates | |
| df = self._remove_duplicates(df, date_col, station_col) | |
| # Handle missing values | |
| df = self._handle_missing(df, date_col, station_col) | |
| # Remove outliers if requested | |
| if self.outlier_method != "none": | |
| df = self._remove_outliers(df, date_col, station_col) | |
| # Sort by date and station | |
| if date_col in df.columns and station_col in df.columns: | |
| df = df.sort_values([station_col, date_col]).reset_index(drop=True) | |
| return df | |
| def _remove_duplicates( | |
| self, | |
| df: pd.DataFrame, | |
| date_col: str, | |
| station_col: str | |
| ) -> pd.DataFrame: | |
| """Remove duplicate rows based on date and station.""" | |
| if date_col in df.columns and station_col in df.columns: | |
| df = df.drop_duplicates(subset=[date_col, station_col], keep="first") | |
| return df | |
| def _handle_missing( | |
| self, | |
| df: pd.DataFrame, | |
| date_col: str, | |
| station_col: str | |
| ) -> pd.DataFrame: | |
| """ | |
| Handle missing values. | |
| - Drop columns with >threshold missing | |
| - Forward fill small gaps in time series | |
| """ | |
| # Drop columns with too many missing values | |
| missing_frac = df.isnull().mean() | |
| cols_to_keep = missing_frac[missing_frac <= self.missing_threshold].index | |
| df = df[cols_to_keep] | |
| # Forward fill small gaps (max 3 days) within each station | |
| if station_col in df.columns: | |
| numeric_cols = df.select_dtypes(include=[np.number]).columns | |
| df[numeric_cols] = df.groupby(station_col)[numeric_cols].transform( | |
| lambda x: x.fillna(method='ffill', limit=3) | |
| ) | |
| return df | |
| def _remove_outliers( | |
| self, | |
| df: pd.DataFrame, | |
| date_col: str, | |
| station_col: str | |
| ) -> pd.DataFrame: | |
| """ | |
| Remove outliers using IQR or Z-score method. | |
| Only applies to numeric columns. | |
| """ | |
| numeric_cols = df.select_dtypes(include=[np.number]).columns | |
| numeric_cols = [c for c in numeric_cols if c != station_col] | |
| if self.outlier_method == "iqr": | |
| df = self._remove_outliers_iqr(df, numeric_cols, station_col) | |
| elif self.outlier_method == "zscore": | |
| df = self._remove_outliers_zscore(df, numeric_cols, station_col) | |
| return df | |
| def _remove_outliers_iqr( | |
| self, | |
| df: pd.DataFrame, | |
| cols: List[str], | |
| station_col: str | |
| ) -> pd.DataFrame: | |
| """Remove outliers using IQR method (per station).""" | |
| for col in cols: | |
| if col in df.columns: | |
| # Calculate IQR per station | |
| Q1 = df.groupby(station_col)[col].transform(lambda x: x.quantile(0.25)) | |
| Q3 = df.groupby(station_col)[col].transform(lambda x: x.quantile(0.75)) | |
| IQR = Q3 - Q1 | |
| lower_bound = Q1 - 3 * IQR | |
| upper_bound = Q3 + 3 * IQR | |
| # Set outliers to NaN | |
| df.loc[(df[col] < lower_bound) | (df[col] > upper_bound), col] = np.nan | |
| return df | |
| def _remove_outliers_zscore( | |
| self, | |
| df: pd.DataFrame, | |
| cols: List[str], | |
| station_col: str, | |
| threshold: float = 4.0 | |
| ) -> pd.DataFrame: | |
| """Remove outliers using Z-score method (per station).""" | |
| for col in cols: | |
| if col in df.columns: | |
| # Calculate Z-scores per station | |
| mean = df.groupby(station_col)[col].transform('mean') | |
| std = df.groupby(station_col)[col].transform('std') | |
| z_scores = np.abs((df[col] - mean) / std) | |
| # Set outliers to NaN | |
| df.loc[z_scores > threshold, col] = np.nan | |
| return df | |
| def get_cleaning_report(self, df_before: pd.DataFrame, df_after: pd.DataFrame) -> dict: | |
| """ | |
| Generate report of cleaning operations. | |
| Args: | |
| df_before: DataFrame before cleaning | |
| df_after: DataFrame after cleaning | |
| Returns: | |
| Dictionary with cleaning statistics | |
| """ | |
| return { | |
| "rows_before": len(df_before), | |
| "rows_after": len(df_after), | |
| "rows_removed": len(df_before) - len(df_after), | |
| "cols_before": len(df_before.columns), | |
| "cols_after": len(df_after.columns), | |
| "cols_removed": len(df_before.columns) - len(df_after.columns) | |
| } | |