Spaces:
Running
Running
Download tool.py from iialonso/data-quality-profiler: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/iialonso/data-quality-profiler/resolve/main/tool.py
- Command line
-
hf download hf://spaces/iialonso/data-quality-profiler/tool.py
-
curl -L -o tool.py https://huggingface.co/spaces/iialonso/data-quality-profiler/resolve/main/tool.py
10.8 kB
| from typing import Any, Optional | |
| from smolagents.tools import Tool | |
| import os | |
| import json | |
| import io | |
| import pandas | |
| class DataQualityProfilerTool(Tool): | |
| name = "data_quality_profiler" | |
| description = "Profiles tabular datasets (CSV, TSV, JSON lines, Markdown tables, or file paths) according to enterprise Data Governance (CDMP\u00ae / DAMA-DMBOK) standards. Computes overall data quality scores, completeness, uniqueness, primary key candidates, statistical distributions, outlier flags, and actionable anomalies for downstream agent tasks." | |
| inputs = {'data': {'type': 'string', 'description': 'Raw tabular string content (CSV, TSV, JSON, Markdown table) or an existing file path to inspect.'}, 'file_type': {'type': 'string', 'description': "Optional format hint: 'csv', 'tsv', 'json', 'markdown', or 'auto' (default: 'auto').", 'nullable': True}, 'max_sample_rows': {'type': 'integer', 'description': 'Maximum number of rows to sample for profiling if dataset is huge (default: 10000).', 'nullable': True}} | |
| output_type = "string" | |
| def _parse_input(self, data_str: str, file_type: Optional[str] = None): | |
| import io | |
| import json | |
| import os | |
| import pandas as pd | |
| clean_str = data_str.strip() | |
| fmt = (file_type or "auto").lower() | |
| # Check if input is a valid existing file path | |
| if os.path.exists(clean_str) and os.path.isfile(clean_str): | |
| ext = os.path.splitext(clean_str)[1].lower() | |
| if ext in [".csv", ".txt"] or fmt == "csv": | |
| return pd.read_csv(clean_str) | |
| elif ext in [".tsv", ".tab"] or fmt == "tsv": | |
| return pd.read_csv(clean_str, sep="\t") | |
| elif ext == ".json" or fmt == "json": | |
| try: | |
| return pd.read_json(clean_str) | |
| except Exception: | |
| return pd.read_json(clean_str, lines=True) | |
| elif ext == ".parquet": | |
| return pd.read_parquet(clean_str) | |
| # Handle Markdown table format | |
| if fmt == "markdown" or ("|" in clean_str and "\n|-" in clean_str): | |
| lines = [line.strip() for line in clean_str.splitlines() if line.strip().startswith("|")] | |
| if len(lines) >= 3: | |
| header = [c.strip() for c in lines[0].split("|")[1:-1]] | |
| data_rows = [] | |
| for row_line in lines[2:]: | |
| cols = [c.strip() for c in row_line.split("|")[1:-1]] | |
| if len(cols) == len(header): | |
| data_rows.append(cols) | |
| if data_rows: | |
| return pd.DataFrame(data_rows, columns=header) | |
| # Handle JSON format (array or jsonl) | |
| if fmt == "json" or clean_str.startswith("[") or clean_str.startswith("{"): | |
| try: | |
| data_obj = json.loads(clean_str) | |
| if isinstance(data_obj, list): | |
| return pd.DataFrame(data_obj) | |
| elif isinstance(data_obj, dict): | |
| return pd.DataFrame([data_obj]) | |
| except Exception: | |
| try: | |
| return pd.read_json(io.StringIO(clean_str), lines=True) | |
| except Exception: | |
| pass | |
| # Handle Delimited (CSV / TSV) | |
| if fmt == "tsv" or ("\t" in clean_str and "," not in clean_str): | |
| return pd.read_csv(io.StringIO(clean_str), sep="\t") | |
| else: | |
| try: | |
| return pd.read_csv(io.StringIO(clean_str)) | |
| except Exception: | |
| return pd.read_csv(io.StringIO(clean_str), sep=None, engine="python") | |
| def forward( | |
| self, | |
| data: str, | |
| file_type: Optional[str] = "auto", | |
| max_sample_rows: Optional[int] = 10000, | |
| ) -> str: | |
| """Profiles the provided dataset and returns a comprehensive Markdown report.""" | |
| import pandas as pd | |
| if not data or not data.strip(): | |
| return "Error: Empty dataset provided. Please provide non-empty tabular data." | |
| try: | |
| df = self._parse_input(data, file_type) | |
| except Exception as err: | |
| return f"Error parsing input data: {str(err)}. Ensure valid CSV, TSV, JSON, or Markdown table format." | |
| if df.empty: | |
| return "Error: Parsed dataframe contains zero rows or columns." | |
| total_rows = len(df) | |
| total_cols = len(df.columns) | |
| is_sampled = False | |
| if max_sample_rows and total_rows > max_sample_rows: | |
| df = df.sample(n=max_sample_rows, random_state=42) | |
| is_sampled = True | |
| analyzed_rows = len(df) | |
| # 1. Uniqueness & Row Duplication | |
| duplicate_rows_count = int(df.duplicated().sum()) | |
| duplicate_rate = (duplicate_rows_count / analyzed_rows) if analyzed_rows > 0 else 0.0 | |
| # 2. Completeness Dimension | |
| total_cells = analyzed_rows * total_cols | |
| missing_cells = int(df.isna().sum().sum()) | |
| completeness_rate = 1.0 - (missing_cells / total_cells) if total_cells > 0 else 1.0 | |
| # Primary Key Candidates | |
| pk_candidates = [ | |
| str(col) for col in df.columns | |
| if df[col].isna().sum() == 0 and df[col].nunique() == analyzed_rows and analyzed_rows > 1 | |
| ] | |
| # 3. Column Profiling & Anomaly Detection | |
| anomalies = [] | |
| col_profiles = [] | |
| if duplicate_rows_count > 0: | |
| anomalies.append( | |
| f"**High Row Duplication**: Found {duplicate_rows_count} exact duplicate rows ({duplicate_rate:.1%})." | |
| ) | |
| for col in df.columns: | |
| series = df[col] | |
| col_name = str(col) | |
| null_count = int(series.isna().sum()) | |
| null_pct = (null_count / analyzed_rows) * 100.0 | |
| n_unique = int(series.nunique(dropna=True)) | |
| if null_pct > 50.0: | |
| anomalies.append(f"**Critical Missing Values**: Column `{col_name}` has {null_pct:.1f}% nulls.") | |
| elif null_pct > 20.0: | |
| anomalies.append(f"**Elevated Missing Values**: Column `{col_name}` has {null_pct:.1f}% nulls.") | |
| if n_unique == 1 and analyzed_rows > 1: | |
| anomalies.append(f"**Constant Column**: Column `{col_name}` has only 1 distinct value (zero variance).") | |
| # Logical Type Inference | |
| non_null_series = series.dropna() | |
| is_numeric = pd.api.types.is_numeric_dtype(series) | |
| if is_numeric and len(non_null_series) > 0: | |
| min_val = float(non_null_series.min()) | |
| max_val = float(non_null_series.max()) | |
| mean_val = float(non_null_series.mean()) | |
| q25 = float(non_null_series.quantile(0.25)) | |
| q75 = float(non_null_series.quantile(0.75)) | |
| iqr = q75 - q25 | |
| outliers_count = 0 | |
| if iqr > 0: | |
| lower_bound = q25 - 1.5 * iqr | |
| upper_bound = q75 + 1.5 * iqr | |
| outliers_count = int(((non_null_series < lower_bound) | (non_null_series > upper_bound)).sum()) | |
| if outliers_count > 0: | |
| outlier_pct = (outliers_count / len(non_null_series)) * 100.0 | |
| if outlier_pct > 5.0: | |
| anomalies.append( | |
| f"**Significant Outliers**: Column `{col_name}` has {outliers_count} statistical outliers ({outlier_pct:.1f}%)." | |
| ) | |
| col_profiles.append({ | |
| "name": col_name, | |
| "type": "Numeric", | |
| "null_pct": f"{null_pct:.1f}%", | |
| "distinct": n_unique, | |
| "stats": f"min: {min_val:.2f}, max: {max_val:.2f}, mean: {mean_val:.2f}, outliers: {outliers_count}", | |
| }) | |
| else: | |
| top_vals = non_null_series.value_counts().head(2).to_dict() | |
| top_str = ", ".join([f"'{k}': {v}" for k, v in top_vals.items()]) if top_vals else "N/A" | |
| col_profiles.append({ | |
| "name": col_name, | |
| "type": "Categorical/Text", | |
| "null_pct": f"{null_pct:.1f}%", | |
| "distinct": n_unique, | |
| "stats": f"top values: [{top_str}]", | |
| }) | |
| # Calculate Overall Health Score | |
| completeness_score = completeness_rate * 50.0 | |
| uniqueness_score = (1.0 - duplicate_rate) * 30.0 | |
| anomaly_penalty = min(20.0, len(anomalies) * 4.0) | |
| validity_score = 20.0 - anomaly_penalty | |
| overall_score = round(completeness_score + uniqueness_score + validity_score, 1) | |
| if overall_score >= 90.0: | |
| verdict = "EXCELLENT (Production Ready)" | |
| elif overall_score >= 75.0: | |
| verdict = "GOOD (Minor Cleaning Needed)" | |
| elif overall_score >= 50.0: | |
| verdict = "NEEDS ATTENTION (Notable Quality Issues)" | |
| else: | |
| verdict = "CRITICAL (Severe Defects Detected)" | |
| # Build Markdown Report | |
| lines = [ | |
| "# Data Quality & Governance Profile Report", | |
| f"**Overall Quality Score:** `{overall_score} / 100` — **{verdict}**", | |
| "", | |
| "## 1. Executive Summary", | |
| f"- **Total Rows:** {total_rows:,}" + (f" *(Profiled sample: {analyzed_rows:,})*" if is_sampled else ""), | |
| f"- **Total Columns:** {total_cols}", | |
| f"- **Completeness Rate:** `{completeness_rate:.1%}` ({missing_cells:,} missing cells)", | |
| f"- **Row Duplication Rate:** `{duplicate_rate:.1%}` ({duplicate_rows_count:,} duplicate rows)", | |
| f"- **Primary Key Candidates:** {', '.join([f'`{k}`' for k in pk_candidates]) if pk_candidates else 'None detected (No single unique non-null column)'}", | |
| "", | |
| "## 2. Key Actionable Anomalies", | |
| ] | |
| if anomalies: | |
| for anomaly in anomalies: | |
| lines.append(f"- {anomaly}") | |
| else: | |
| lines.append("- No critical quality anomalies detected! Data conforms to expected bounds.") | |
| lines.extend([ | |
| "", | |
| "## 3. Column-by-Column Health Breakdown", | |
| "| Column Name | Inferred Type | Missing % | Unique Count | Distribution / Top Values |", | |
| "| :--- | :--- | :---: | :---: | :--- |", | |
| ]) | |
| for cp in col_profiles: | |
| lines.append(f"| `{cp['name']}` | {cp['type']} | {cp['null_pct']} | {cp['distinct']:,} | {cp['stats']} |") | |
| lines.extend([ | |
| "", | |
| "---", | |
| "*Report generated deterministically according to DAMA-DMBOK / CDMP® standards via `smolagents` Data Quality Profiler.*", | |
| ]) | |
| return "\n".join(lines) | |
| def __init__(self, *args, **kwargs): | |
| self.is_initialized = False | |