datapilot-polars 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. datapilot/__init__.py +119 -0
  2. datapilot/ai/__init__.py +3 -0
  3. datapilot/ai/base.py +57 -0
  4. datapilot/ai/factory.py +70 -0
  5. datapilot/ai/insights.py +46 -0
  6. datapilot/ai/providers/__init__.py +0 -0
  7. datapilot/ai/providers/claude_provider.py +45 -0
  8. datapilot/ai/providers/gemini_provider.py +46 -0
  9. datapilot/ai/providers/groq_provider.py +52 -0
  10. datapilot/ai/providers/ollama_provider.py +35 -0
  11. datapilot/ai/providers/openai_provider.py +43 -0
  12. datapilot/analysis/__init__.py +0 -0
  13. datapilot/analysis/auto_clean.py +131 -0
  14. datapilot/analysis/benchmark.py +137 -0
  15. datapilot/analysis/compare.py +129 -0
  16. datapilot/analysis/correlations.py +41 -0
  17. datapilot/analysis/duplicates.py +22 -0
  18. datapilot/analysis/eda.py +96 -0
  19. datapilot/analysis/missing.py +32 -0
  20. datapilot/analysis/outliers.py +102 -0
  21. datapilot/analysis/suggest.py +126 -0
  22. datapilot/analysis/summary.py +22 -0
  23. datapilot/dashboard/__init__.py +3 -0
  24. datapilot/dashboard/dashboard.py +326 -0
  25. datapilot/ml/__init__.py +6 -0
  26. datapilot/ml/classification.py +40 -0
  27. datapilot/ml/diagnostics.py +28 -0
  28. datapilot/ml/regression.py +67 -0
  29. datapilot/utils/__init__.py +0 -0
  30. datapilot/utils/validation.py +19 -0
  31. datapilot/visualization/__init__.py +5 -0
  32. datapilot/visualization/boxplot.py +23 -0
  33. datapilot/visualization/heatmap.py +26 -0
  34. datapilot/visualization/histogram.py +25 -0
  35. datapilot_polars-0.3.0.dist-info/METADATA +618 -0
  36. datapilot_polars-0.3.0.dist-info/RECORD +38 -0
  37. datapilot_polars-0.3.0.dist-info/WHEEL +5 -0
  38. datapilot_polars-0.3.0.dist-info/top_level.txt +1 -0
datapilot/__init__.py ADDED
@@ -0,0 +1,119 @@
1
+ __version__ = "0.3.0"
2
+
3
+ # ── Analysis ──────────────────────────────────────────────────────────────────
4
+
5
+ def summary(df):
6
+ """Calculates lightning fast structural overview metrics of the dataset."""
7
+ from .analysis.summary import summary as _summary
8
+ return _summary(df)
9
+
10
+ def missing(df):
11
+ """Generates a report detailing missing count and percentage per column."""
12
+ from .analysis.missing import missing as _missing
13
+ return _missing(df)
14
+
15
+ def duplicates(df):
16
+ """Calculates duplicate row counts and percentages using multi-threaded hashing."""
17
+ from .analysis.duplicates import duplicates as _duplicates
18
+ return _duplicates(df)
19
+
20
+ def correlation(df, threshold=0.6):
21
+ """Calculates a high-speed Pearson correlation matrix and flags strong pairs."""
22
+ from .analysis.correlations import correlation as _correlation
23
+ return _correlation(df, threshold)
24
+
25
+ def analyze(df, use_ai=False, ai_provider="ollama", ai_model=None, api_key=None):
26
+ """Runs automated EDA with optional AI insights from local or cloud providers.
27
+
28
+ ai_provider options: 'ollama' (local/default), 'openai', 'gemini', 'claude', 'groq'
29
+ """
30
+ from .analysis.eda import analyze as _analyze
31
+ return _analyze(df, use_ai=use_ai, ai_provider=ai_provider,
32
+ ai_model=ai_model, api_key=api_key)
33
+
34
+ def suggest(df):
35
+ """Analyses dataset structure and returns actionable preprocessing suggestions."""
36
+ from .analysis.suggest import suggest as _suggest
37
+ return _suggest(df)
38
+
39
+ def compare(df_train, df_test, threshold=0.1):
40
+ """Detects distribution drift between a training and test/production DataFrame."""
41
+ from .analysis.compare import compare as _compare
42
+ return _compare(df_train, df_test, threshold)
43
+
44
+ def outliers(df, method="both", z_threshold=3.0, iqr_multiplier=1.5):
45
+ """Detects outliers in all numeric columns using IQR and/or Z-score methods."""
46
+ from .analysis.outliers import outliers as _outliers
47
+ return _outliers(df, method=method, z_threshold=z_threshold, iqr_multiplier=iqr_multiplier)
48
+
49
+ def auto_clean(df, drop_null_threshold=0.6, impute_strategy="auto",
50
+ drop_id_columns=True, drop_constant_columns=True):
51
+ """Automatically cleans a dataset — drops bad columns, imputes nulls."""
52
+ from .analysis.auto_clean import auto_clean as _auto_clean
53
+ return _auto_clean(
54
+ df,
55
+ drop_null_threshold=drop_null_threshold,
56
+ impute_strategy=impute_strategy,
57
+ drop_id_columns=drop_id_columns,
58
+ drop_constant_columns=drop_constant_columns,
59
+ )
60
+
61
+ def benchmark(df):
62
+ """Benchmarks DataPilot (Polars) operations vs equivalent Pandas operations."""
63
+ from .analysis.benchmark import benchmark as _benchmark
64
+ return _benchmark(df)
65
+
66
+ # ── Visualization ─────────────────────────────────────────────────────────────
67
+
68
+ def hist(df, column, bins=10):
69
+ """Generates a clean distribution histogram for a selected numerical feature."""
70
+ from .visualization.histogram import hist as _hist
71
+ return _hist(df, column, bins)
72
+
73
+ def box(df, column):
74
+ """Generates a clean box plot to showcase distribution quartiles and outliers."""
75
+ from .visualization.boxplot import box as _box
76
+ return _box(df, column)
77
+
78
+ def heatmap(df):
79
+ """Generates a visual correlation matrix heatmap for all numerical features."""
80
+ from .visualization.heatmap import heatmap as _heatmap
81
+ return _heatmap(df)
82
+
83
+ # ── Machine Learning ──────────────────────────────────────────────────────────
84
+
85
+ def classification_report(y_true, y_pred, average="auto"):
86
+ """Calculates key classification performance metrics (binary or multi-class)."""
87
+ from .ml.classification import classification_report as _cr
88
+ return _cr(y_true, y_pred, average=average)
89
+
90
+ def regression_report(y_true, y_pred):
91
+ """Calculates comprehensive regression evaluation metrics (MAE, RMSE, R², MAPE)."""
92
+ from .ml.regression import regression_report as _rr
93
+ return _rr(y_true, y_pred)
94
+
95
+ def diagnose(train_score, test_score, metric_name="Accuracy"):
96
+ """Diagnoses model health, highlighting overfitting, underfitting, or optimal states."""
97
+ from .ml.diagnostics import diagnose as _diagnose
98
+ return _diagnose(train_score, test_score, metric_name)
99
+
100
+ # ── Dashboard ─────────────────────────────────────────────────────────────────
101
+
102
+ def dashboard(df, output_path="datapilot_report.html"):
103
+ """Generates a standalone, beautifully styled HTML dashboard report from the dataset."""
104
+ from .dashboard.dashboard import dashboard as _dashboard
105
+ return _dashboard(df, output_path)
106
+
107
+
108
+ __all__ = [
109
+ "__version__",
110
+ # Analysis
111
+ "summary", "missing", "duplicates", "correlation", "analyze",
112
+ "suggest", "compare", "outliers", "auto_clean", "benchmark",
113
+ # Visualization
114
+ "hist", "box", "heatmap",
115
+ # ML
116
+ "classification_report", "regression_report", "diagnose",
117
+ # Dashboard
118
+ "dashboard",
119
+ ]
@@ -0,0 +1,3 @@
1
+ from .insights import generate_insights
2
+
3
+ __all__ = ["generate_insights"]
datapilot/ai/base.py ADDED
@@ -0,0 +1,57 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class BaseAIProvider(ABC):
6
+ """Abstract base class for all DataPilot AI provider backends.
7
+
8
+ Every provider must implement a single `generate` method that accepts
9
+ the dataset metadata and returns a plain-text insight string.
10
+ Raw data rows are NEVER passed to any provider — only lightweight
11
+ statistical summaries are transmitted (metadata-only pattern).
12
+ """
13
+
14
+ def __init__(self, model: str, api_key: str = None, **kwargs):
15
+ self.model = model
16
+ self.api_key = api_key
17
+
18
+ @abstractmethod
19
+ def generate(
20
+ self,
21
+ meta: Dict[str, Any],
22
+ missing_list: List[Dict[str, Any]],
23
+ strong_relations: List[str],
24
+ ) -> str:
25
+ """Generate AI insights from dataset metadata.
26
+
27
+ Args:
28
+ meta: Structural stats dict (rows, columns, dtypes, null counts).
29
+ missing_list: List of dicts with column null stats.
30
+ strong_relations: List of strong correlation pair strings.
31
+
32
+ Returns:
33
+ A plain-text string with actionable recommendations.
34
+ """
35
+
36
+ def _build_system_prompt(self) -> str:
37
+ return (
38
+ "You are DataPilot, an elite data science assistant. "
39
+ "Analyse the structural statistics provided and write exactly 3 brief, "
40
+ "impactful bullet points giving expert data preprocessing, cleaning, or "
41
+ "modelling advice. Be direct and conversational. "
42
+ "Do not include introductory text or markdown headers."
43
+ )
44
+
45
+ def _build_user_prompt(
46
+ self,
47
+ meta: Dict[str, Any],
48
+ missing_list: List[Dict[str, Any]],
49
+ strong_relations: List[str],
50
+ ) -> str:
51
+ return (
52
+ f"Dataset Profile:\n"
53
+ f"- Dimensions: {meta['rows']} rows × {meta['columns']} columns\n"
54
+ f"- Duplicate Rows: {meta.get('duplicates_count', 'N/A')}\n"
55
+ f"- Missing Data: {missing_list}\n"
56
+ f"- Strong Correlations: {strong_relations}\n"
57
+ )
@@ -0,0 +1,70 @@
1
+ from .base import BaseAIProvider
2
+ from typing import Optional
3
+
4
+
5
+ # Registry mapping provider name → class (lazy import avoids hard dependencies)
6
+ _PROVIDER_REGISTRY = {
7
+ "ollama": "datapilot.ai.providers.ollama_provider.OllamaProvider",
8
+ "openai": "datapilot.ai.providers.openai_provider.OpenAIProvider",
9
+ "gemini": "datapilot.ai.providers.gemini_provider.GeminiProvider",
10
+ "claude": "datapilot.ai.providers.claude_provider.ClaudeProvider",
11
+ "groq": "datapilot.ai.providers.groq_provider.GroqProvider",
12
+ }
13
+
14
+ # Human-readable default models per provider
15
+ DEFAULT_MODELS = {
16
+ "ollama": "llama3",
17
+ "openai": "gpt-4o-mini",
18
+ "gemini": "gemini-1.5-flash",
19
+ "claude": "claude-3-haiku-20240307",
20
+ "groq": "llama3-70b-8192",
21
+ }
22
+
23
+
24
+ def get_provider(
25
+ ai_provider: str,
26
+ ai_model: Optional[str] = None,
27
+ api_key: Optional[str] = None,
28
+ ) -> BaseAIProvider:
29
+ """Resolves and instantiates the correct AI provider from a string name.
30
+
31
+ Args:
32
+ ai_provider: Name of the AI backend. One of:
33
+ 'ollama' (local, default), 'openai', 'gemini', 'claude', 'groq'.
34
+ ai_model: Model name within the provider. If None, uses the provider's
35
+ sensible default (e.g. 'gpt-4o-mini' for OpenAI).
36
+ api_key: API key required for cloud providers. Not needed for Ollama.
37
+
38
+ Returns:
39
+ An instantiated BaseAIProvider subclass ready to call .generate().
40
+
41
+ Raises:
42
+ ValueError: If the provider name is not recognised.
43
+ """
44
+ provider_name = ai_provider.lower().strip()
45
+
46
+ if provider_name not in _PROVIDER_REGISTRY:
47
+ supported = ", ".join(f"'{k}'" for k in _PROVIDER_REGISTRY)
48
+ raise ValueError(
49
+ f"Unknown AI provider: '{ai_provider}'. "
50
+ f"Supported providers: {supported}."
51
+ )
52
+
53
+ # Validate API key requirement for cloud providers
54
+ if provider_name != "ollama" and not api_key:
55
+ raise ValueError(
56
+ f"'{ai_provider}' is a cloud provider and requires an API key. "
57
+ f"Pass it via: dp.analyze(df, use_ai=True, ai_provider='{ai_provider}', "
58
+ f"api_key='your-key-here')"
59
+ )
60
+
61
+ # Resolve default model
62
+ model = ai_model or DEFAULT_MODELS[provider_name]
63
+
64
+ # Lazy import of the provider class
65
+ module_path, class_name = _PROVIDER_REGISTRY[provider_name].rsplit(".", 1)
66
+ import importlib
67
+ module = importlib.import_module(module_path)
68
+ provider_class = getattr(module, class_name)
69
+
70
+ return provider_class(model=model, api_key=api_key)
@@ -0,0 +1,46 @@
1
+ from .factory import get_provider
2
+ from typing import Dict, Any, List, Optional
3
+
4
+
5
+ def generate_insights(
6
+ meta: Dict[str, Any],
7
+ missing_list: List[Dict[str, Any]],
8
+ strong_relations: List[str],
9
+ model_name: str = "llama3",
10
+ ai_provider: str = "ollama",
11
+ api_key: Optional[str] = None,
12
+ ) -> str:
13
+ """Sends lightweight dataset metadata to the configured AI provider and
14
+ returns conversational, context-aware engineering recommendations.
15
+
16
+ DataPilot uses a Metadata-Only AI Pattern — raw data rows are NEVER
17
+ transmitted to any provider. Only concise statistical summaries are sent.
18
+
19
+ Args:
20
+ meta: Structural stats dict from dp.summary().
21
+ missing_list: List of column null-stat dicts from dp.missing().
22
+ strong_relations: List of correlation pair strings from dp.correlation().
23
+ model_name: Model to use within the chosen provider.
24
+ Defaults vary per provider if not specified.
25
+ ai_provider: AI backend to use. Options:
26
+ 'ollama' — local, free, private (default)
27
+ 'openai' — GPT-4o, GPT-4, GPT-3.5
28
+ 'gemini' — Google Gemini 1.5 Pro/Flash
29
+ 'claude' — Anthropic Claude 3.5 Sonnet/Haiku
30
+ 'groq' — Ultra-fast free-tier (Llama3, Mixtral)
31
+ api_key: API key for cloud providers. Not needed for Ollama.
32
+
33
+ Returns:
34
+ Plain-text string with 3 actionable data science recommendations.
35
+ """
36
+ try:
37
+ provider = get_provider(
38
+ ai_provider=ai_provider,
39
+ ai_model=model_name,
40
+ api_key=api_key,
41
+ )
42
+ return provider.generate(meta, missing_list, strong_relations)
43
+ except ValueError as e:
44
+ return f"⚠️ Configuration error: {e}"
45
+ except Exception as e:
46
+ return f"⚠️ Could not generate AI Insights. Error: {e}"
File without changes
@@ -0,0 +1,45 @@
1
+ from ..base import BaseAIProvider
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class ClaudeProvider(BaseAIProvider):
6
+ """Anthropic Claude cloud AI provider (claude-3-5-sonnet, claude-3-haiku, etc.)
7
+
8
+ Requires the `anthropic` package: `pip install anthropic`
9
+ Only dataset metadata (stats, null counts) is sent — never raw data rows.
10
+
11
+ Usage:
12
+ dp.analyze(df, use_ai=True, ai_provider="claude",
13
+ ai_model="claude-3-5-sonnet-20241022", api_key="sk-ant-...")
14
+ """
15
+
16
+ def generate(
17
+ self,
18
+ meta: Dict[str, Any],
19
+ missing_list: List[Dict[str, Any]],
20
+ strong_relations: List[str],
21
+ ) -> str:
22
+ try:
23
+ import anthropic
24
+ except ImportError:
25
+ return (
26
+ "⚠️ Anthropic package not installed. "
27
+ "Run: pip install anthropic"
28
+ )
29
+
30
+ try:
31
+ client = anthropic.Anthropic(api_key=self.api_key)
32
+ response = client.messages.create(
33
+ model=self.model,
34
+ max_tokens=300,
35
+ system=self._build_system_prompt(),
36
+ messages=[
37
+ {
38
+ "role": "user",
39
+ "content": self._build_user_prompt(meta, missing_list, strong_relations),
40
+ }
41
+ ],
42
+ )
43
+ return response.content[0].text.strip()
44
+ except Exception as e:
45
+ return f"⚠️ Anthropic Claude API error: {e}"
@@ -0,0 +1,46 @@
1
+ from ..base import BaseAIProvider
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class GeminiProvider(BaseAIProvider):
6
+ """Google Gemini cloud AI provider (gemini-1.5-pro, gemini-1.5-flash, etc.)
7
+
8
+ Requires the `google-generativeai` package: `pip install google-generativeai`
9
+ Only dataset metadata (stats, null counts) is sent — never raw data rows.
10
+
11
+ Usage:
12
+ dp.analyze(df, use_ai=True, ai_provider="gemini",
13
+ ai_model="gemini-1.5-flash", api_key="AIza...")
14
+ """
15
+
16
+ def generate(
17
+ self,
18
+ meta: Dict[str, Any],
19
+ missing_list: List[Dict[str, Any]],
20
+ strong_relations: List[str],
21
+ ) -> str:
22
+ try:
23
+ import google.generativeai as genai
24
+ except ImportError:
25
+ return (
26
+ "⚠️ Google Generative AI package not installed. "
27
+ "Run: pip install google-generativeai"
28
+ )
29
+
30
+ try:
31
+ genai.configure(api_key=self.api_key)
32
+ model = genai.GenerativeModel(
33
+ model_name=self.model,
34
+ system_instruction=self._build_system_prompt(),
35
+ )
36
+ user_prompt = self._build_user_prompt(meta, missing_list, strong_relations)
37
+ response = model.generate_content(
38
+ user_prompt,
39
+ generation_config=genai.GenerationConfig(
40
+ max_output_tokens=300,
41
+ temperature=0.4,
42
+ ),
43
+ )
44
+ return response.text.strip()
45
+ except Exception as e:
46
+ return f"⚠️ Google Gemini API error: {e}"
@@ -0,0 +1,52 @@
1
+ from ..base import BaseAIProvider
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class GroqProvider(BaseAIProvider):
6
+ """Groq cloud AI provider — ultra-fast inference (llama3-70b, mixtral, gemma2, etc.)
7
+
8
+ Groq offers a generous free tier and is significantly faster than OpenAI
9
+ for real-time use cases. Uses an OpenAI-compatible API.
10
+
11
+ Requires the `groq` package: `pip install groq`
12
+ Only dataset metadata (stats, null counts) is sent — never raw data rows.
13
+
14
+ Usage:
15
+ dp.analyze(df, use_ai=True, ai_provider="groq",
16
+ ai_model="llama3-70b-8192", api_key="gsk_...")
17
+
18
+ Popular free models:
19
+ - llama3-70b-8192 (recommended — best quality)
20
+ - llama3-8b-8192 (fastest)
21
+ - mixtral-8x7b-32768 (long context)
22
+ - gemma2-9b-it (Google's Gemma via Groq)
23
+ """
24
+
25
+ def generate(
26
+ self,
27
+ meta: Dict[str, Any],
28
+ missing_list: List[Dict[str, Any]],
29
+ strong_relations: List[str],
30
+ ) -> str:
31
+ try:
32
+ from groq import Groq
33
+ except ImportError:
34
+ return (
35
+ "⚠️ Groq package not installed. "
36
+ "Run: pip install groq"
37
+ )
38
+
39
+ try:
40
+ client = Groq(api_key=self.api_key)
41
+ response = client.chat.completions.create(
42
+ model=self.model,
43
+ messages=[
44
+ {"role": "system", "content": self._build_system_prompt()},
45
+ {"role": "user", "content": self._build_user_prompt(meta, missing_list, strong_relations)},
46
+ ],
47
+ max_tokens=300,
48
+ temperature=0.4,
49
+ )
50
+ return response.choices[0].message.content.strip()
51
+ except Exception as e:
52
+ return f"⚠️ Groq API error: {e}"
@@ -0,0 +1,35 @@
1
+ from ..base import BaseAIProvider
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class OllamaProvider(BaseAIProvider):
6
+ """Local Ollama AI provider — fully private, no internet required.
7
+
8
+ Communicates with a locally running Ollama daemon. Raw data rows
9
+ are never transmitted; only metadata summaries are sent.
10
+
11
+ Usage:
12
+ dp.analyze(df, use_ai=True, ai_provider="ollama", ai_model="llama3")
13
+ """
14
+
15
+ def generate(
16
+ self,
17
+ meta: Dict[str, Any],
18
+ missing_list: List[Dict[str, Any]],
19
+ strong_relations: List[str],
20
+ ) -> str:
21
+ try:
22
+ import ollama
23
+ response = ollama.chat(
24
+ model=self.model,
25
+ messages=[
26
+ {"role": "system", "content": self._build_system_prompt()},
27
+ {"role": "user", "content": self._build_user_prompt(meta, missing_list, strong_relations)},
28
+ ],
29
+ )
30
+ return response["message"]["content"].strip()
31
+ except Exception as e:
32
+ return (
33
+ f"⚠️ Could not reach Ollama. Ensure it is running (`ollama serve`). "
34
+ f"Error: {e}"
35
+ )
@@ -0,0 +1,43 @@
1
+ from ..base import BaseAIProvider
2
+ from typing import Dict, Any, List
3
+
4
+
5
+ class OpenAIProvider(BaseAIProvider):
6
+ """OpenAI cloud AI provider (GPT-4o, GPT-4, GPT-3.5-turbo, etc.)
7
+
8
+ Requires the `openai` package: `pip install openai`
9
+ Only dataset metadata (stats, null counts) is sent — never raw data rows.
10
+
11
+ Usage:
12
+ dp.analyze(df, use_ai=True, ai_provider="openai",
13
+ ai_model="gpt-4o", api_key="sk-...")
14
+ """
15
+
16
+ def generate(
17
+ self,
18
+ meta: Dict[str, Any],
19
+ missing_list: List[Dict[str, Any]],
20
+ strong_relations: List[str],
21
+ ) -> str:
22
+ try:
23
+ from openai import OpenAI
24
+ except ImportError:
25
+ return (
26
+ "⚠️ OpenAI package not installed. "
27
+ "Run: pip install openai"
28
+ )
29
+
30
+ try:
31
+ client = OpenAI(api_key=self.api_key)
32
+ response = client.chat.completions.create(
33
+ model=self.model,
34
+ messages=[
35
+ {"role": "system", "content": self._build_system_prompt()},
36
+ {"role": "user", "content": self._build_user_prompt(meta, missing_list, strong_relations)},
37
+ ],
38
+ max_tokens=300,
39
+ temperature=0.4,
40
+ )
41
+ return response.choices[0].message.content.strip()
42
+ except Exception as e:
43
+ return f"⚠️ OpenAI API error: {e}"
File without changes
@@ -0,0 +1,131 @@
1
+ from ..utils.validation import ensure_polars
2
+ import polars as pl
3
+ import pandas as pd
4
+ from typing import Union, Tuple, Dict, Any, List
5
+
6
+
7
+ def auto_clean(
8
+ df: Union[pd.DataFrame, pl.DataFrame],
9
+ drop_null_threshold: float = 0.6,
10
+ impute_strategy: str = "auto",
11
+ drop_id_columns: bool = True,
12
+ drop_constant_columns: bool = True,
13
+ ) -> Tuple[Union[pd.DataFrame, pl.DataFrame], List[Dict[str, Any]]]:
14
+ """Automatically cleans a dataset by applying common preprocessing steps.
15
+
16
+ Performs the following operations (in order):
17
+ 1. Drops constant columns (zero variance)
18
+ 2. Drops ID-like columns (100% unique values)
19
+ 3. Drops columns exceeding the null threshold
20
+ 4. Imputes remaining numeric nulls with median
21
+ 5. Imputes remaining categorical/string nulls with mode
22
+
23
+ Args:
24
+ df: Input DataFrame (Pandas or Polars).
25
+ drop_null_threshold: Columns with null % above this are dropped (default: 0.60 = 60%).
26
+ impute_strategy: 'auto' (median for numeric, mode for categorical), 'median', or 'mode'.
27
+ drop_id_columns: Whether to drop columns where all values are unique (default: True).
28
+ drop_constant_columns: Whether to drop columns with a single unique value (default: True).
29
+
30
+ Returns:
31
+ Tuple of (cleaned_df, change_log) where change_log is a list of dicts
32
+ describing every action taken. The returned DataFrame matches the input type.
33
+ """
34
+ local_df, original_engine = ensure_polars(df)
35
+ total_rows = local_df.height
36
+ change_log: List[Dict[str, Any]] = []
37
+ cols_to_drop: List[str] = []
38
+
39
+ print("=" * 58)
40
+ print(" DATAPILOT AUTO-CLEAN ENGINE ")
41
+ print("=" * 58)
42
+ print(f" Input: {total_rows:,} rows × {local_df.width} columns")
43
+
44
+ null_counts = local_df.null_count()
45
+
46
+ for col in local_df.columns:
47
+ series = local_df[col]
48
+ n_unique = series.n_unique()
49
+ null_ct = null_counts[col][0]
50
+ null_pct = null_ct / total_rows if total_rows > 0 else 0
51
+
52
+ # ── 1. Constant columns ──────────────────────────────────────────────
53
+ if drop_constant_columns and n_unique == 1:
54
+ cols_to_drop.append(col)
55
+ change_log.append({
56
+ "action": "DROPPED",
57
+ "column": col,
58
+ "reason": "Constant column — all values are identical (zero variance)",
59
+ })
60
+ print(f"\n 🗑️ DROP '{col}' → constant column (1 unique value)")
61
+ continue
62
+
63
+ # ── 2. ID-like columns ───────────────────────────────────────────────
64
+ if drop_id_columns and n_unique == total_rows and total_rows > 10:
65
+ cols_to_drop.append(col)
66
+ change_log.append({
67
+ "action": "DROPPED",
68
+ "column": col,
69
+ "reason": "ID/key column — 100% unique values carry no predictive signal",
70
+ })
71
+ print(f"\n 🗑️ DROP '{col}' → ID-like column (all {n_unique} values unique)")
72
+ continue
73
+
74
+ # ── 3. High-null columns ──────────────────────────────────────────────
75
+ if null_pct >= drop_null_threshold:
76
+ cols_to_drop.append(col)
77
+ change_log.append({
78
+ "action": "DROPPED",
79
+ "column": col,
80
+ "reason": f"High null rate ({null_pct*100:.1f}%) exceeds threshold ({drop_null_threshold*100:.0f}%)",
81
+ })
82
+ print(f"\n 🗑️ DROP '{col}' → {null_pct*100:.1f}% nulls (above {drop_null_threshold*100:.0f}% threshold)")
83
+
84
+ # Apply drops
85
+ clean_df = local_df.drop(cols_to_drop) if cols_to_drop else local_df.clone()
86
+
87
+ # ── 4 & 5. Impute remaining nulls ─────────────────────────────────────────
88
+ impute_exprs = []
89
+ for col in clean_df.columns:
90
+ dtype = clean_df[col].dtype
91
+ null_ct = clean_df[col].null_count()
92
+ if null_ct == 0:
93
+ continue
94
+
95
+ if dtype.is_numeric():
96
+ if impute_strategy in ("auto", "median"):
97
+ median_val = clean_df[col].median()
98
+ if median_val is not None:
99
+ impute_exprs.append(pl.col(col).fill_null(median_val))
100
+ change_log.append({
101
+ "action": "IMPUTED",
102
+ "column": col,
103
+ "reason": f"Filled {null_ct} null(s) with median ({round(median_val, 4)})",
104
+ })
105
+ print(f"\n 🔧 IMPUTE '{col}' → filled {null_ct} null(s) with median={round(median_val, 4)}")
106
+ else:
107
+ if impute_strategy in ("auto", "mode"):
108
+ mode_series = clean_df[col].drop_nulls().value_counts().sort("count", descending=True)
109
+ if mode_series.height > 0:
110
+ mode_val = str(mode_series["value"][0])
111
+ impute_exprs.append(pl.col(col).fill_null(mode_val))
112
+ change_log.append({
113
+ "action": "IMPUTED",
114
+ "column": col,
115
+ "reason": f"Filled {null_ct} null(s) with mode ('{mode_val}')",
116
+ })
117
+ print(f"\n 🔧 IMPUTE '{col}' → filled {null_ct} null(s) with mode='{mode_val}'")
118
+
119
+ if impute_exprs:
120
+ clean_df = clean_df.with_columns(impute_exprs)
121
+
122
+ print("\n" + "-" * 58)
123
+ print(f" Output: {clean_df.height:,} rows × {clean_df.width} columns")
124
+ dropped = local_df.width - clean_df.width
125
+ imputed = sum(1 for c in change_log if c["action"] == "IMPUTED")
126
+ print(f" ✅ {dropped} column(s) dropped | {imputed} column(s) imputed")
127
+ print("=" * 58)
128
+
129
+ # Return in original format
130
+ result_df = clean_df.to_pandas() if original_engine == "pandas" else clean_df
131
+ return result_df, change_log