datapruning 1.0.6__tar.gz → 1.0.7__tar.gz
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.
- {datapruning-1.0.6/datapruning.egg-info → datapruning-1.0.7}/PKG-INFO +4 -1
- {datapruning-1.0.6 → datapruning-1.0.7}/datapruning/__init__.py +21 -1
- datapruning-1.0.7/datapruning/algorithms/__init__.py +7 -0
- datapruning-1.0.7/datapruning/algorithms/base.py +98 -0
- datapruning-1.0.7/datapruning/algorithms/cluster_centroids.py +71 -0
- datapruning-1.0.7/datapruning/algorithms/density_pruner.py +61 -0
- datapruning-1.0.7/datapruning/algorithms/duplicate_aware.py +50 -0
- datapruning-1.0.7/datapruning/algorithms/instance_hardness.py +74 -0
- datapruning-1.0.7/datapruning/algorithms/random_undersampler.py +57 -0
- datapruning-1.0.7/datapruning/algorithms/registry.py +45 -0
- datapruning-1.0.7/datapruning/engine/__init__.py +0 -0
- datapruning-1.0.7/datapruning/engine/benchmark.py +70 -0
- datapruning-1.0.7/datapruning/engine/explainability.py +26 -0
- datapruning-1.0.7/datapruning/engine/intelligence.py +119 -0
- datapruning-1.0.7/datapruning/engine/optimizer.py +8 -0
- datapruning-1.0.7/datapruning/engine/scanner.py +48 -0
- datapruning-1.0.7/datapruning/engine/strategy_selector.py +35 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/datapruning/pipeline.py +0 -1
- datapruning-1.0.7/datapruning/reports/__init__.py +0 -0
- datapruning-1.0.7/datapruning/reports/exporter.py +24 -0
- datapruning-1.0.7/datapruning/sdk.py +80 -0
- {datapruning-1.0.6 → datapruning-1.0.7/datapruning.egg-info}/PKG-INFO +4 -1
- datapruning-1.0.7/datapruning.egg-info/SOURCES.txt +31 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/datapruning.egg-info/requires.txt +1 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/pyproject.toml +2 -4
- datapruning-1.0.6/datapruning.egg-info/SOURCES.txt +0 -13
- {datapruning-1.0.6 → datapruning-1.0.7}/LICENSE +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/MANIFEST.in +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/README.md +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/build_src/_core.c +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/datapruning.egg-info/dependency_links.txt +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/datapruning.egg-info/top_level.txt +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/setup.cfg +0 -0
- {datapruning-1.0.6 → datapruning-1.0.7}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datapruning
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
4
4
|
Summary: Intelligent data pruning for ML datasets
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Project-URL: Homepage, https://www.datapruning.com
|
|
@@ -16,9 +16,12 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
19
20
|
Requires-Dist: numpy>=1.24.0
|
|
20
21
|
Requires-Dist: pandas>=2.0.0
|
|
22
|
+
Requires-Dist: scikit-learn>=1.3.0
|
|
21
23
|
Requires-Dist: torch>=2.0.0
|
|
24
|
+
Dynamic: license-file
|
|
22
25
|
|
|
23
26
|
# DataPruning
|
|
24
27
|
|
|
@@ -3,15 +3,29 @@ import numpy as np
|
|
|
3
3
|
import pandas as pd
|
|
4
4
|
|
|
5
5
|
from . import pipeline
|
|
6
|
-
from .
|
|
6
|
+
from .sdk import DatasetEngine
|
|
7
7
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
MIN_ROWS = 1000
|
|
11
11
|
MAX_ROWS = 300_000
|
|
12
12
|
|
|
13
|
+
try:
|
|
14
|
+
from ._core import analyze_data as _analyze_data
|
|
15
|
+
HAS_CORE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
_analyze_data = None
|
|
18
|
+
HAS_CORE = False
|
|
19
|
+
logger.warning("Compiled _core module not available — legacy prune_dataframe/Pruner disabled")
|
|
20
|
+
|
|
13
21
|
|
|
14
22
|
def prune_dataframe(df, target_col=None, keep_ratio=0.5, num_steps=400, verbose=False):
|
|
23
|
+
if not HAS_CORE:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"prune_dataframe requires the compiled _core module, "
|
|
26
|
+
"which is not available on this platform. "
|
|
27
|
+
"Use DatasetEngine instead (pure Python, all platforms)."
|
|
28
|
+
)
|
|
15
29
|
if verbose:
|
|
16
30
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
17
31
|
|
|
@@ -53,6 +67,12 @@ class Pruner:
|
|
|
53
67
|
def __init__(self, df):
|
|
54
68
|
if not isinstance(df, pd.DataFrame):
|
|
55
69
|
raise TypeError("Expected a pandas DataFrame")
|
|
70
|
+
if not HAS_CORE:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Pruner requires the compiled _core module, "
|
|
73
|
+
"which is not available on this platform. "
|
|
74
|
+
"Use DatasetEngine instead (pure Python, all platforms)."
|
|
75
|
+
)
|
|
56
76
|
self._df = df
|
|
57
77
|
|
|
58
78
|
def prune(self, target_col=None, keep_ratio=0.5, num_steps=400, verbose=False):
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from datapruning.algorithms import random_undersampler
|
|
2
|
+
from datapruning.algorithms import cluster_centroids
|
|
3
|
+
from datapruning.algorithms import instance_hardness
|
|
4
|
+
from datapruning.algorithms import duplicate_aware
|
|
5
|
+
from datapruning.algorithms import density_pruner
|
|
6
|
+
|
|
7
|
+
from datapruning.algorithms.registry import registry
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OptimizationResult:
|
|
10
|
+
optimized_df: pd.DataFrame
|
|
11
|
+
rows_before: int
|
|
12
|
+
rows_after: int
|
|
13
|
+
cols_before: int
|
|
14
|
+
cols_after: int
|
|
15
|
+
minority_count_before: Optional[int]
|
|
16
|
+
minority_count_after: Optional[int]
|
|
17
|
+
runtime_seconds: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Explanation:
|
|
22
|
+
algorithm: str
|
|
23
|
+
why_selected: str
|
|
24
|
+
rows_removed: int
|
|
25
|
+
reduction_pct: float
|
|
26
|
+
minority_preserved_pct: Optional[float]
|
|
27
|
+
estimated_training_gain_pct: float
|
|
28
|
+
memory_saved_pct: float
|
|
29
|
+
limitations: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseOptimizer(ABC):
|
|
33
|
+
key: str = ""
|
|
34
|
+
display_name: str = ""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "BaseOptimizer":
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def transform(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float):
|
|
45
|
+
keep_idx = self._select_rows(X, y, keep_ratio)
|
|
46
|
+
return X.loc[keep_idx], y.loc[keep_idx]
|
|
47
|
+
|
|
48
|
+
def optimize(self, df: pd.DataFrame, target_col: str, keep_ratio: float = 0.5) -> OptimizationResult:
|
|
49
|
+
import time
|
|
50
|
+
X, y = df.drop(columns=[target_col]), df[target_col]
|
|
51
|
+
minority_before = int(y.value_counts().min()) if y.nunique() > 1 else None
|
|
52
|
+
t0 = time.time()
|
|
53
|
+
self.fit(X, y)
|
|
54
|
+
X_res, y_res = self.transform(X, y, keep_ratio)
|
|
55
|
+
runtime = time.time() - t0
|
|
56
|
+
minority_after = int(y_res.value_counts().min()) if y_res.nunique() > 1 else None
|
|
57
|
+
optimized_df = X_res.copy()
|
|
58
|
+
optimized_df[target_col] = y_res.values
|
|
59
|
+
return OptimizationResult(
|
|
60
|
+
optimized_df=optimized_df,
|
|
61
|
+
rows_before=len(df),
|
|
62
|
+
rows_after=len(optimized_df),
|
|
63
|
+
cols_before=df.shape[1],
|
|
64
|
+
cols_after=optimized_df.shape[1],
|
|
65
|
+
minority_count_before=minority_before,
|
|
66
|
+
minority_count_after=minority_after,
|
|
67
|
+
runtime_seconds=runtime,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def explain(self, result: OptimizationResult) -> Explanation:
|
|
71
|
+
rows_removed = result.rows_before - result.rows_after
|
|
72
|
+
reduction_pct = round(rows_removed / result.rows_before * 100, 1) if result.rows_before else 0.0
|
|
73
|
+
minority_preserved_pct = None
|
|
74
|
+
if result.minority_count_before:
|
|
75
|
+
minority_preserved_pct = round(
|
|
76
|
+
(result.minority_count_after or 0) / result.minority_count_before * 100, 1
|
|
77
|
+
)
|
|
78
|
+
return Explanation(
|
|
79
|
+
algorithm=self.display_name,
|
|
80
|
+
why_selected=self.why_selected(),
|
|
81
|
+
rows_removed=rows_removed,
|
|
82
|
+
reduction_pct=reduction_pct,
|
|
83
|
+
minority_preserved_pct=minority_preserved_pct,
|
|
84
|
+
estimated_training_gain_pct=reduction_pct,
|
|
85
|
+
memory_saved_pct=reduction_pct,
|
|
86
|
+
limitations=self.limitations(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def fit_score(analysis: dict) -> float:
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
def why_selected(self) -> str:
|
|
95
|
+
return f"{self.display_name} was selected as the best fit for this dataset."
|
|
96
|
+
|
|
97
|
+
def limitations(self) -> list[str]:
|
|
98
|
+
return []
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from sklearn.cluster import KMeans
|
|
5
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
6
|
+
from datapruning.algorithms.registry import register
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register
|
|
10
|
+
class ClusterCentroidsOptimizer(BaseOptimizer):
|
|
11
|
+
key = "cluster"
|
|
12
|
+
display_name = "Cluster Centroids"
|
|
13
|
+
|
|
14
|
+
def __init__(self, random_state: int = 42):
|
|
15
|
+
self.random_state = random_state
|
|
16
|
+
self._centroid_rows = None
|
|
17
|
+
|
|
18
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "ClusterCentroidsOptimizer":
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
22
|
+
minority_class = y.value_counts().idxmin()
|
|
23
|
+
minority_idx = y[y == minority_class].index
|
|
24
|
+
majority_idx = y[y != minority_class].index
|
|
25
|
+
n_total_target = int(len(y) * keep_ratio)
|
|
26
|
+
n_majority_target = max(n_total_target - len(minority_idx), 0)
|
|
27
|
+
n_majority_target = min(n_majority_target, len(majority_idx))
|
|
28
|
+
if n_majority_target == 0:
|
|
29
|
+
return minority_idx
|
|
30
|
+
numeric_X = X.loc[majority_idx].select_dtypes(include=[np.number]).fillna(0)
|
|
31
|
+
k = max(min(n_majority_target, len(majority_idx), 3000), 1)
|
|
32
|
+
km = KMeans(n_clusters=k, random_state=self.random_state, n_init=3)
|
|
33
|
+
km.fit(numeric_X)
|
|
34
|
+
labels = km.labels_
|
|
35
|
+
per_cluster_quota = max(n_majority_target // k, 1)
|
|
36
|
+
kept_rows = []
|
|
37
|
+
numeric_vals = numeric_X.values
|
|
38
|
+
for cluster_id in range(k):
|
|
39
|
+
cluster_positions = np.where(labels == cluster_id)[0]
|
|
40
|
+
if len(cluster_positions) == 0:
|
|
41
|
+
continue
|
|
42
|
+
centroid = km.cluster_centers_[cluster_id]
|
|
43
|
+
dists = np.linalg.norm(numeric_vals[cluster_positions] - centroid, axis=1)
|
|
44
|
+
order = np.argsort(dists)
|
|
45
|
+
chosen_positions = cluster_positions[order[:per_cluster_quota]]
|
|
46
|
+
kept_rows.extend(numeric_X.index[chosen_positions].tolist())
|
|
47
|
+
keep_idx = pd.Index(kept_rows).append(minority_idx)
|
|
48
|
+
return keep_idx
|
|
49
|
+
|
|
50
|
+
def why_selected(self) -> str:
|
|
51
|
+
return ("Large balanced-to-moderately-imbalanced dataset where structural "
|
|
52
|
+
"(density-based) reduction outperforms blind random sampling.")
|
|
53
|
+
|
|
54
|
+
def limitations(self) -> list[str]:
|
|
55
|
+
return ["O(n*k) clustering cost — slow on very large datasets (100K+ rows).",
|
|
56
|
+
"Less effective than Instance Hardness Threshold on severe imbalance."]
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def fit_score(analysis: dict) -> float:
|
|
60
|
+
scan = analysis.get("scan", analysis)
|
|
61
|
+
task_type = scan.get("task_type", "unknown")
|
|
62
|
+
imbalance_ratio = scan.get("imbalance_ratio", 1.0)
|
|
63
|
+
n_rows = scan.get("rows", 0)
|
|
64
|
+
|
|
65
|
+
if n_rows < 1000 or task_type == "regression":
|
|
66
|
+
return 0.0
|
|
67
|
+
if n_rows > 200_000:
|
|
68
|
+
return 0.1
|
|
69
|
+
if 3 <= imbalance_ratio < 10:
|
|
70
|
+
return 0.6
|
|
71
|
+
return 0.3
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
5
|
+
from datapruning.algorithms.registry import register
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register
|
|
9
|
+
class DensityPrunerOptimizer(BaseOptimizer):
|
|
10
|
+
key = "density"
|
|
11
|
+
display_name = "Density-Based Pruner (Legacy)"
|
|
12
|
+
|
|
13
|
+
def __init__(self, random_state: int = 42):
|
|
14
|
+
self.random_state = random_state
|
|
15
|
+
|
|
16
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "DensityPrunerOptimizer":
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
20
|
+
try:
|
|
21
|
+
from datapruning._core import analyze_data as core_prune
|
|
22
|
+
n = len(y)
|
|
23
|
+
n_keep = int(n * keep_ratio)
|
|
24
|
+
X_arr = np.ascontiguousarray(X.values.astype(np.float32))
|
|
25
|
+
result = core_prune(X_arr, y.values.copy(), 200, keep_ratio)
|
|
26
|
+
if result is not None and len(result) > 0:
|
|
27
|
+
kept = result[:n_keep]
|
|
28
|
+
return pd.Index(kept)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
rng = np.random.RandomState(self.random_state)
|
|
32
|
+
minority_class = y.value_counts().idxmin()
|
|
33
|
+
minority_idx = y[y == minority_class].index
|
|
34
|
+
majority_idx = y[y != minority_class].index
|
|
35
|
+
n_total_target = int(len(y) * keep_ratio)
|
|
36
|
+
n_majority_target = max(n_total_target - len(minority_idx), 0)
|
|
37
|
+
chosen_majority = rng.choice(majority_idx.to_numpy(),
|
|
38
|
+
size=min(n_majority_target, len(majority_idx)),
|
|
39
|
+
replace=False)
|
|
40
|
+
return minority_idx.append(pd.Index(chosen_majority))
|
|
41
|
+
|
|
42
|
+
def why_selected(self) -> str:
|
|
43
|
+
return "Dataset has structural density patterns that the legacy pruner can exploit."
|
|
44
|
+
|
|
45
|
+
def limitations(self) -> list[str]:
|
|
46
|
+
return ["Legacy algorithm — may not perform as well as newer methods.",
|
|
47
|
+
"Falls back to random undersampling if the compiled core is unavailable."]
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def fit_score(analysis: dict) -> float:
|
|
51
|
+
scan = analysis.get("scan", analysis)
|
|
52
|
+
n_rows = scan.get("rows", 0)
|
|
53
|
+
if n_rows < 1000:
|
|
54
|
+
return 0.0
|
|
55
|
+
task_type = scan.get("task_type", "unknown")
|
|
56
|
+
if task_type == "regression":
|
|
57
|
+
return 0.0
|
|
58
|
+
imbalance_ratio = scan.get("imbalance_ratio", 1.0)
|
|
59
|
+
if 1.5 <= imbalance_ratio < 5:
|
|
60
|
+
return 0.65
|
|
61
|
+
return 0.35
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
5
|
+
from datapruning.algorithms.registry import register
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register
|
|
9
|
+
class DuplicateAwareOptimizer(BaseOptimizer):
|
|
10
|
+
key = "duplicate_aware"
|
|
11
|
+
display_name = "Duplicate-Aware Reduction"
|
|
12
|
+
|
|
13
|
+
def __init__(self, random_state: int = 42):
|
|
14
|
+
self.random_state = random_state
|
|
15
|
+
|
|
16
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "DuplicateAwareOptimizer":
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
20
|
+
rng = np.random.RandomState(self.random_state)
|
|
21
|
+
dedup_mask = ~X.duplicated()
|
|
22
|
+
deduped_idx = X.index[dedup_mask]
|
|
23
|
+
n_target = int(len(y) * keep_ratio)
|
|
24
|
+
if len(deduped_idx) <= n_target:
|
|
25
|
+
return deduped_idx
|
|
26
|
+
minority_class = y.value_counts().idxmin()
|
|
27
|
+
minority_idx = deduped_idx.intersection(y[y == minority_class].index)
|
|
28
|
+
majority_idx = deduped_idx.difference(minority_idx)
|
|
29
|
+
n_majority_target = max(n_target - len(minority_idx), 0)
|
|
30
|
+
chosen_majority = rng.choice(majority_idx.to_numpy(),
|
|
31
|
+
size=min(n_majority_target, len(majority_idx)),
|
|
32
|
+
replace=False)
|
|
33
|
+
return minority_idx.append(pd.Index(chosen_majority))
|
|
34
|
+
|
|
35
|
+
def why_selected(self) -> str:
|
|
36
|
+
return "Dataset has a high duplicate-row percentage; deduplication is a safe first reduction."
|
|
37
|
+
|
|
38
|
+
def limitations(self) -> list[str]:
|
|
39
|
+
return ["Only effective when duplicate percentage is meaningfully high."]
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def fit_score(analysis: dict) -> float:
|
|
43
|
+
scan = analysis.get("scan", analysis)
|
|
44
|
+
dup_pct = scan.get("duplicate_pct", 0)
|
|
45
|
+
n_rows = scan.get("rows", 0)
|
|
46
|
+
if n_rows < 1000:
|
|
47
|
+
return 0.0
|
|
48
|
+
if dup_pct >= 5:
|
|
49
|
+
return 0.9
|
|
50
|
+
return 0.05
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from sklearn.model_selection import StratifiedKFold
|
|
5
|
+
from sklearn.base import clone
|
|
6
|
+
from sklearn.linear_model import LogisticRegression
|
|
7
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
8
|
+
from datapruning.algorithms.registry import register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register
|
|
12
|
+
class InstanceHardnessOptimizer(BaseOptimizer):
|
|
13
|
+
key = "iht"
|
|
14
|
+
display_name = "Instance Hardness Threshold"
|
|
15
|
+
|
|
16
|
+
def __init__(self, estimator=None, n_folds: int = 5, random_state: int = 42):
|
|
17
|
+
self.estimator = estimator if estimator is not None else LogisticRegression(max_iter=1000)
|
|
18
|
+
self.n_folds = n_folds
|
|
19
|
+
self.random_state = random_state
|
|
20
|
+
self._hardness = None
|
|
21
|
+
|
|
22
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "InstanceHardnessOptimizer":
|
|
23
|
+
skf = StratifiedKFold(n_splits=self.n_folds, shuffle=True, random_state=self.random_state)
|
|
24
|
+
X_arr, y_arr = X.values, y.values
|
|
25
|
+
hardness = np.zeros(len(y_arr))
|
|
26
|
+
for train_idx, val_idx in skf.split(X_arr, y_arr):
|
|
27
|
+
model = clone(self.estimator)
|
|
28
|
+
model.fit(X_arr[train_idx], y_arr[train_idx])
|
|
29
|
+
probs = model.predict_proba(X_arr[val_idx])
|
|
30
|
+
class_to_col = {c: i for i, c in enumerate(model.classes_)}
|
|
31
|
+
true_probs = np.array(
|
|
32
|
+
[probs[i, class_to_col[y_arr[val_idx][i]]] for i in range(len(val_idx))]
|
|
33
|
+
)
|
|
34
|
+
hardness[val_idx] = 1.0 - true_probs
|
|
35
|
+
self._hardness = pd.Series(hardness, index=y.index)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
39
|
+
if self._hardness is None:
|
|
40
|
+
self.fit(X, y)
|
|
41
|
+
minority_class = y.value_counts().idxmin()
|
|
42
|
+
minority_idx = y[y == minority_class].index
|
|
43
|
+
majority_idx = y[y != minority_class].index
|
|
44
|
+
n_total_target = int(len(y) * keep_ratio)
|
|
45
|
+
n_majority_target = max(n_total_target - len(minority_idx), 0)
|
|
46
|
+
majority_hardness = self._hardness.loc[majority_idx].sort_values(ascending=False)
|
|
47
|
+
kept_majority_idx = majority_hardness.index[:n_majority_target]
|
|
48
|
+
return minority_idx.append(kept_majority_idx)
|
|
49
|
+
|
|
50
|
+
def why_selected(self) -> str:
|
|
51
|
+
return ("Severe class imbalance detected. Instance Hardness Threshold removes "
|
|
52
|
+
"only the majority-class rows the model already classifies confidently, "
|
|
53
|
+
"preserving 100% of the minority class and all decision-boundary rows.")
|
|
54
|
+
|
|
55
|
+
def limitations(self) -> list[str]:
|
|
56
|
+
return ["Requires training a quick cross-validated model — slower than pure random.",
|
|
57
|
+
"Quality depends on the chosen base estimator; weak estimators give a noisier hardness signal."]
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def fit_score(analysis: dict) -> float:
|
|
61
|
+
scan = analysis.get("scan", analysis)
|
|
62
|
+
task_type = scan.get("task_type", "unknown")
|
|
63
|
+
imbalance_ratio = scan.get("imbalance_ratio", 1.0)
|
|
64
|
+
n_rows = scan.get("rows", 0)
|
|
65
|
+
|
|
66
|
+
if n_rows < 3000 or task_type == "regression":
|
|
67
|
+
return 0.0
|
|
68
|
+
if n_rows > 200_000:
|
|
69
|
+
return 0.3
|
|
70
|
+
if imbalance_ratio >= 10:
|
|
71
|
+
return 0.95
|
|
72
|
+
if imbalance_ratio >= 3:
|
|
73
|
+
return 0.7
|
|
74
|
+
return 0.4
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
5
|
+
from datapruning.algorithms.registry import register
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register
|
|
9
|
+
class RandomUnderSamplerOptimizer(BaseOptimizer):
|
|
10
|
+
key = "random"
|
|
11
|
+
display_name = "Random UnderSampler"
|
|
12
|
+
|
|
13
|
+
def __init__(self, random_state: int = 42):
|
|
14
|
+
self.random_state = random_state
|
|
15
|
+
|
|
16
|
+
def fit(self, X: pd.DataFrame, y: pd.Series) -> "RandomUnderSamplerOptimizer":
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def _select_rows(self, X: pd.DataFrame, y: pd.Series, keep_ratio: float) -> pd.Index:
|
|
20
|
+
rng = np.random.RandomState(self.random_state)
|
|
21
|
+
classes = y.unique()
|
|
22
|
+
minority_class = y.value_counts().idxmin()
|
|
23
|
+
minority_idx = y[y == minority_class].index
|
|
24
|
+
n_total_target = int(len(y) * keep_ratio)
|
|
25
|
+
n_majority_target = max(n_total_target - len(minority_idx), 0)
|
|
26
|
+
keep_idx = list(minority_idx)
|
|
27
|
+
for c in classes:
|
|
28
|
+
if c == minority_class:
|
|
29
|
+
continue
|
|
30
|
+
class_idx = y[y == c].index.to_numpy()
|
|
31
|
+
n_c_target = min(n_majority_target, len(class_idx))
|
|
32
|
+
chosen = rng.choice(class_idx, size=n_c_target, replace=False)
|
|
33
|
+
keep_idx.extend(chosen.tolist())
|
|
34
|
+
return pd.Index(keep_idx)
|
|
35
|
+
|
|
36
|
+
def why_selected(self) -> str:
|
|
37
|
+
return ("Large, balanced or mildly imbalanced dataset where blind random "
|
|
38
|
+
"reduction is acceptable and the fastest option.")
|
|
39
|
+
|
|
40
|
+
def limitations(self) -> list[str]:
|
|
41
|
+
return ["Removes rows with no regard for how informative they are.",
|
|
42
|
+
"Can discard rare-but-important patterns in the majority class."]
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def fit_score(analysis: dict) -> float:
|
|
46
|
+
scan = analysis.get("scan", analysis)
|
|
47
|
+
task_type = scan.get("task_type", "unknown")
|
|
48
|
+
imbalance_ratio = scan.get("imbalance_ratio", 1.0)
|
|
49
|
+
n_rows = scan.get("rows", 0)
|
|
50
|
+
|
|
51
|
+
if n_rows < 1000:
|
|
52
|
+
return 0.0
|
|
53
|
+
if task_type == "regression":
|
|
54
|
+
return 0.0
|
|
55
|
+
if imbalance_ratio < 3:
|
|
56
|
+
return 0.55
|
|
57
|
+
return 0.25
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Type, Dict
|
|
3
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OptimizerRegistry:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._registry: Dict[str, Type[BaseOptimizer]] = {}
|
|
9
|
+
|
|
10
|
+
def register(self, optimizer_cls: Type[BaseOptimizer]) -> Type[BaseOptimizer]:
|
|
11
|
+
if not optimizer_cls.key:
|
|
12
|
+
raise ValueError(f"{optimizer_cls.__name__} must set a non-empty `key`.")
|
|
13
|
+
self._registry[optimizer_cls.key] = optimizer_cls
|
|
14
|
+
return optimizer_cls
|
|
15
|
+
|
|
16
|
+
def get(self, key: str) -> BaseOptimizer:
|
|
17
|
+
if key not in self._registry:
|
|
18
|
+
raise KeyError(f"No optimizer registered under key '{key}'. "
|
|
19
|
+
f"Available: {list(self._registry.keys())}")
|
|
20
|
+
return self._registry[key]()
|
|
21
|
+
|
|
22
|
+
def all(self) -> Dict[str, Type[BaseOptimizer]]:
|
|
23
|
+
return dict(self._registry)
|
|
24
|
+
|
|
25
|
+
def get_best(self, analysis: dict) -> tuple[BaseOptimizer, str, float, list[dict]]:
|
|
26
|
+
if not self._registry:
|
|
27
|
+
raise RuntimeError("No optimizers registered.")
|
|
28
|
+
scanned = []
|
|
29
|
+
for key, cls in self._registry.items():
|
|
30
|
+
score = cls.fit_score(analysis)
|
|
31
|
+
scanned.append((key, cls, score))
|
|
32
|
+
scanned.sort(key=lambda t: -t[2])
|
|
33
|
+
best_key, best_cls, best_score = scanned[0]
|
|
34
|
+
alternatives = [
|
|
35
|
+
{"key": k, "display_name": cls.display_name, "score": round(s, 3)}
|
|
36
|
+
for k, cls, s in scanned[1:]
|
|
37
|
+
]
|
|
38
|
+
return best_cls(), best_key, best_score, alternatives
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
registry = OptimizerRegistry()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register(cls: Type[BaseOptimizer]) -> Type[BaseOptimizer]:
|
|
45
|
+
return registry.register(cls)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
5
|
+
from datapruning.algorithms.base import BaseOptimizer
|
|
6
|
+
from datapruning.algorithms.registry import registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def benchmark_algorithms(df: pd.DataFrame, target_col: str,
|
|
10
|
+
algorithms: list[str] = None,
|
|
11
|
+
keep_ratio: float = 0.5) -> dict:
|
|
12
|
+
if algorithms is None:
|
|
13
|
+
algorithms = list(registry.all().keys())
|
|
14
|
+
results = {}
|
|
15
|
+
for key in algorithms:
|
|
16
|
+
try:
|
|
17
|
+
optimizer = registry.get(key)
|
|
18
|
+
t0 = time.time()
|
|
19
|
+
result = optimizer.optimize(df, target_col=target_col, keep_ratio=keep_ratio)
|
|
20
|
+
elapsed = time.time() - t0
|
|
21
|
+
results[key] = {
|
|
22
|
+
"display_name": optimizer.display_name,
|
|
23
|
+
"rows_before": result.rows_before,
|
|
24
|
+
"rows_after": result.rows_after,
|
|
25
|
+
"reduction_pct": round((1 - result.rows_after / result.rows_before) * 100, 2) if result.rows_before else 0,
|
|
26
|
+
"runtime_seconds": round(elapsed, 3),
|
|
27
|
+
"minority_count_before": result.minority_count_before,
|
|
28
|
+
"minority_count_after": result.minority_count_after,
|
|
29
|
+
}
|
|
30
|
+
except Exception as e:
|
|
31
|
+
results[key] = {"display_name": key, "error": str(e)}
|
|
32
|
+
return results
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare_to_full(optimized_df: pd.DataFrame, original_df: pd.DataFrame,
|
|
36
|
+
target_col: str) -> dict:
|
|
37
|
+
from datapruning.engine.scanner import scan
|
|
38
|
+
full_scan = scan(original_df, target_col=target_col)
|
|
39
|
+
opt_scan = scan(optimized_df, target_col=target_col)
|
|
40
|
+
return {
|
|
41
|
+
"original": {
|
|
42
|
+
"rows": full_scan["rows"],
|
|
43
|
+
"memory_mb": full_scan["memory_mb"],
|
|
44
|
+
"missing_pct": full_scan["missing_pct"],
|
|
45
|
+
"duplicate_pct": full_scan["duplicate_pct"],
|
|
46
|
+
"estimated_training_cost": full_scan["estimated_training_cost"],
|
|
47
|
+
},
|
|
48
|
+
"optimized": {
|
|
49
|
+
"rows": opt_scan["rows"],
|
|
50
|
+
"memory_mb": opt_scan["memory_mb"],
|
|
51
|
+
"missing_pct": opt_scan["missing_pct"],
|
|
52
|
+
"duplicate_pct": opt_scan["duplicate_pct"],
|
|
53
|
+
"estimated_training_cost": opt_scan["estimated_training_cost"],
|
|
54
|
+
},
|
|
55
|
+
"savings": {
|
|
56
|
+
"row_reduction_pct": round((1 - opt_scan["rows"] / full_scan["rows"]) * 100, 2) if full_scan["rows"] else 0,
|
|
57
|
+
"memory_saved_mb": round(full_scan["memory_mb"] - opt_scan["memory_mb"], 2),
|
|
58
|
+
"cost_reduction": _cost_reduction(full_scan["estimated_training_cost"], opt_scan["estimated_training_cost"]),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cost_reduction(before: str, after: str) -> str:
|
|
64
|
+
rank = {"low": 0, "medium": 1, "high": 2, "very_high": 3}
|
|
65
|
+
diff = rank.get(before, 0) - rank.get(after, 0)
|
|
66
|
+
if diff >= 2:
|
|
67
|
+
return "significant"
|
|
68
|
+
elif diff == 1:
|
|
69
|
+
return "moderate"
|
|
70
|
+
return "minimal"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from datapruning.algorithms.base import BaseOptimizer, OptimizationResult, Explanation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_report(optimizer: BaseOptimizer, result: OptimizationResult,
|
|
7
|
+
df_before: pd.DataFrame) -> dict:
|
|
8
|
+
explanation: Explanation = optimizer.explain(result)
|
|
9
|
+
missing_pct_after = (result.optimized_df.isna().sum().sum() / result.optimized_df.size) * 100 \
|
|
10
|
+
if result.optimized_df.size else 0
|
|
11
|
+
dup_pct_after = (result.optimized_df.duplicated().sum() / len(result.optimized_df)) * 100 \
|
|
12
|
+
if len(result.optimized_df) else 0
|
|
13
|
+
quality = float(max(0, 100 - missing_pct_after * 2 - dup_pct_after * 2))
|
|
14
|
+
size_reduction = float(1 - (result.rows_after / result.rows_before)) if result.rows_before else 0.0
|
|
15
|
+
training_readiness = float(min(100, 70 + size_reduction * 60))
|
|
16
|
+
overall_health = round((quality + training_readiness) / 2, 1)
|
|
17
|
+
return {
|
|
18
|
+
"explanation": explanation.__dict__,
|
|
19
|
+
"dataset_score": {
|
|
20
|
+
"overall_health": overall_health,
|
|
21
|
+
"quality": round(quality, 1),
|
|
22
|
+
"training_readiness": round(training_readiness, 1),
|
|
23
|
+
"optimization_potential": round(size_reduction * 100, 1),
|
|
24
|
+
},
|
|
25
|
+
"runtime_seconds": round(result.runtime_seconds, 3),
|
|
26
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def analyze_intelligence(df: pd.DataFrame, target_col: str = None) -> dict:
|
|
6
|
+
findings = {
|
|
7
|
+
"id_columns": _detect_id_columns(df),
|
|
8
|
+
"date_columns": _detect_date_columns(df),
|
|
9
|
+
"constant_columns": _detect_constant_columns(df),
|
|
10
|
+
"near_constant_columns": _detect_near_constant_columns(df),
|
|
11
|
+
"high_cardinality_columns": _detect_high_cardinality(df),
|
|
12
|
+
"highly_correlated_pairs": _detect_correlated_features(df, target_col),
|
|
13
|
+
"outlier_summary": _detect_outliers(df),
|
|
14
|
+
"complexity_score": None,
|
|
15
|
+
"leakage_risk_columns": [],
|
|
16
|
+
}
|
|
17
|
+
if target_col and target_col in df.columns:
|
|
18
|
+
findings["leakage_risk_columns"] = _detect_leakage_risk(df, target_col)
|
|
19
|
+
findings["complexity_score"] = _complexity_score(df, findings)
|
|
20
|
+
return findings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _detect_id_columns(df, uniqueness_threshold=0.98):
|
|
24
|
+
out = []
|
|
25
|
+
for col in df.columns:
|
|
26
|
+
dtype = df[col].dtype
|
|
27
|
+
is_id_like_dtype = pd.api.types.is_integer_dtype(dtype) or dtype == object
|
|
28
|
+
if is_id_like_dtype and df[col].nunique() / len(df) >= uniqueness_threshold:
|
|
29
|
+
out.append(col)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_date_columns(df):
|
|
34
|
+
out = []
|
|
35
|
+
for col in df.select_dtypes(include=["object", "datetime64[ns]"]).columns:
|
|
36
|
+
if pd.api.types.is_datetime64_any_dtype(df[col]):
|
|
37
|
+
out.append(col)
|
|
38
|
+
continue
|
|
39
|
+
sample = df[col].dropna().head(50)
|
|
40
|
+
try:
|
|
41
|
+
parsed = pd.to_datetime(sample, errors="coerce")
|
|
42
|
+
if parsed.notna().mean() > 0.9:
|
|
43
|
+
out.append(col)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _detect_constant_columns(df):
|
|
50
|
+
return [c for c in df.columns if df[c].nunique(dropna=False) <= 1]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _detect_near_constant_columns(df, threshold=0.98):
|
|
54
|
+
out = []
|
|
55
|
+
for c in df.columns:
|
|
56
|
+
top_freq = df[c].value_counts(normalize=True, dropna=False).iloc[0]
|
|
57
|
+
if top_freq >= threshold and df[c].nunique() > 1:
|
|
58
|
+
out.append(c)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detect_high_cardinality(df, threshold=0.5):
|
|
63
|
+
out = []
|
|
64
|
+
for c in df.select_dtypes(include=["object", "category"]).columns:
|
|
65
|
+
if df[c].nunique() / len(df) >= threshold:
|
|
66
|
+
out.append(c)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_correlated_features(df, target_col=None, threshold=0.95):
|
|
71
|
+
numeric_df = df.select_dtypes(include=[np.number])
|
|
72
|
+
if target_col in numeric_df.columns:
|
|
73
|
+
numeric_df = numeric_df.drop(columns=[target_col])
|
|
74
|
+
if numeric_df.shape[1] < 2:
|
|
75
|
+
return []
|
|
76
|
+
corr = numeric_df.corr().abs()
|
|
77
|
+
pairs = []
|
|
78
|
+
cols = corr.columns
|
|
79
|
+
for i in range(len(cols)):
|
|
80
|
+
for j in range(i + 1, len(cols)):
|
|
81
|
+
val = corr.iloc[i, j]
|
|
82
|
+
if val >= threshold:
|
|
83
|
+
pairs.append({"col_a": cols[i], "col_b": cols[j], "correlation": round(float(val), 3)})
|
|
84
|
+
return sorted(pairs, key=lambda x: -x["correlation"])[:25]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_outliers(df, z_thresh=4.0):
|
|
88
|
+
numeric_df = df.select_dtypes(include=[np.number])
|
|
89
|
+
summary = {}
|
|
90
|
+
for c in numeric_df.columns:
|
|
91
|
+
col = numeric_df[c].dropna()
|
|
92
|
+
if col.std() == 0 or len(col) == 0:
|
|
93
|
+
continue
|
|
94
|
+
z = (col - col.mean()) / col.std()
|
|
95
|
+
pct = (z.abs() > z_thresh).mean() * 100
|
|
96
|
+
if pct > 0:
|
|
97
|
+
summary[c] = round(float(pct), 2)
|
|
98
|
+
return summary
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detect_leakage_risk(df, target_col, corr_threshold=0.98):
|
|
102
|
+
if target_col not in df.columns:
|
|
103
|
+
return []
|
|
104
|
+
numeric_df = df.select_dtypes(include=[np.number])
|
|
105
|
+
if target_col not in numeric_df.columns or numeric_df.shape[1] < 2:
|
|
106
|
+
return []
|
|
107
|
+
corrs = numeric_df.corr()[target_col].drop(target_col).abs()
|
|
108
|
+
return [c for c, v in corrs.items() if v >= corr_threshold]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _complexity_score(df, findings) -> int:
|
|
112
|
+
score = 0
|
|
113
|
+
n_cols = df.shape[1]
|
|
114
|
+
score += min(len(findings["constant_columns"]) / max(n_cols, 1) * 100, 20)
|
|
115
|
+
score += min(len(findings["near_constant_columns"]) / max(n_cols, 1) * 100, 15)
|
|
116
|
+
score += min(len(findings["highly_correlated_pairs"]) * 2, 20)
|
|
117
|
+
score += min(len(findings["high_cardinality_columns"]) / max(n_cols, 1) * 100, 15)
|
|
118
|
+
score += min(len(findings["leakage_risk_columns"]) * 15, 30)
|
|
119
|
+
return int(round(min(score, 100)))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from datapruning.algorithms.base import BaseOptimizer, OptimizationResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_optimization(df: pd.DataFrame, target_col: str, optimizer: BaseOptimizer,
|
|
7
|
+
keep_ratio: float = 0.5) -> OptimizationResult:
|
|
8
|
+
return optimizer.optimize(df, target_col=target_col, keep_ratio=keep_ratio)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def scan(df: pd.DataFrame, target_col: str = None) -> dict:
|
|
6
|
+
n_rows, n_cols = df.shape
|
|
7
|
+
memory_mb = df.memory_usage(deep=True).sum() / 1e6
|
|
8
|
+
missing_pct = (df.isna().sum().sum() / (n_rows * n_cols)) * 100
|
|
9
|
+
dup_pct = (df.duplicated().sum() / n_rows) * 100
|
|
10
|
+
numeric_cols = df.select_dtypes(include=[np.number]).columns
|
|
11
|
+
categorical_cols = df.select_dtypes(exclude=[np.number]).columns
|
|
12
|
+
result = {
|
|
13
|
+
"rows": n_rows,
|
|
14
|
+
"columns": n_cols,
|
|
15
|
+
"memory_mb": round(memory_mb, 2),
|
|
16
|
+
"missing_pct": round(missing_pct, 3),
|
|
17
|
+
"duplicate_pct": round(dup_pct, 3),
|
|
18
|
+
"numeric_pct": round(len(numeric_cols) / n_cols * 100, 1),
|
|
19
|
+
"categorical_pct": round(len(categorical_cols) / n_cols * 100, 1),
|
|
20
|
+
"estimated_training_cost": _estimate_cost(n_rows, n_cols),
|
|
21
|
+
}
|
|
22
|
+
if target_col and target_col in df.columns:
|
|
23
|
+
result["task_type"] = _infer_task_type(df[target_col])
|
|
24
|
+
if result["task_type"] in ("binary", "multiclass"):
|
|
25
|
+
vc = df[target_col].value_counts(normalize=True)
|
|
26
|
+
result["target_balance"] = {str(k): round(v * 100, 2) for k, v in vc.items()}
|
|
27
|
+
result["imbalance_ratio"] = round(vc.max() / vc.min(), 2) if len(vc) > 1 else 1.0
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _infer_task_type(y: pd.Series) -> str:
|
|
32
|
+
nunique = y.nunique()
|
|
33
|
+
if pd.api.types.is_numeric_dtype(y) and nunique > 20:
|
|
34
|
+
return "regression"
|
|
35
|
+
if nunique == 2:
|
|
36
|
+
return "binary"
|
|
37
|
+
return "multiclass"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _estimate_cost(n_rows: int, n_cols: int) -> str:
|
|
41
|
+
scale = n_rows * n_cols
|
|
42
|
+
if scale < 1e6:
|
|
43
|
+
return "low"
|
|
44
|
+
elif scale < 5e7:
|
|
45
|
+
return "medium"
|
|
46
|
+
elif scale < 5e8:
|
|
47
|
+
return "high"
|
|
48
|
+
return "very_high"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import datapruning.algorithms
|
|
4
|
+
from datapruning.algorithms.registry import registry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class StrategyRecommendation:
|
|
9
|
+
algorithm_key: str
|
|
10
|
+
algorithm_instance: object
|
|
11
|
+
display_name: str
|
|
12
|
+
confidence: float
|
|
13
|
+
alternatives: list[dict]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def select_strategy(analysis: dict) -> StrategyRecommendation:
|
|
17
|
+
best_instance, best_key, best_score, alternatives = registry.get_best(analysis)
|
|
18
|
+
return StrategyRecommendation(
|
|
19
|
+
algorithm_key=best_key,
|
|
20
|
+
algorithm_instance=best_instance,
|
|
21
|
+
display_name=best_instance.display_name,
|
|
22
|
+
confidence=round(best_score, 3),
|
|
23
|
+
alternatives=alternatives,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_algorithm(key: str):
|
|
28
|
+
return registry.get(key)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_available_algorithms() -> list[dict]:
|
|
32
|
+
return [
|
|
33
|
+
{"key": key, "display_name": cls.display_name}
|
|
34
|
+
for key, cls in registry.all().items()
|
|
35
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def export_csv(df: pd.DataFrame, path: str) -> str:
|
|
7
|
+
path = Path(path)
|
|
8
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
9
|
+
df.to_csv(path, index=False)
|
|
10
|
+
return str(path)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_parquet(df: pd.DataFrame, path: str) -> str:
|
|
14
|
+
path = Path(path)
|
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
df.to_parquet(path, index=False)
|
|
17
|
+
return str(path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def export_json(df: pd.DataFrame, path: str, orient: str = "records") -> str:
|
|
21
|
+
path = Path(path)
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
df.to_json(path, orient=orient, indent=2)
|
|
24
|
+
return str(path)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from datapruning.engine.scanner import scan
|
|
5
|
+
from datapruning.engine.intelligence import analyze_intelligence
|
|
6
|
+
from datapruning.engine.strategy_selector import select_strategy, get_algorithm, list_available_algorithms
|
|
7
|
+
from datapruning.engine.optimizer import run_optimization
|
|
8
|
+
from datapruning.engine.explainability import build_report
|
|
9
|
+
from datapruning.engine.benchmark import benchmark_algorithms, compare_to_full
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatasetEngine:
|
|
13
|
+
def __init__(self, df: pd.DataFrame, target_col: str = None):
|
|
14
|
+
self.df = df
|
|
15
|
+
self.target_col = target_col
|
|
16
|
+
self._analysis = None
|
|
17
|
+
self._last_result = None
|
|
18
|
+
self._last_optimizer = None
|
|
19
|
+
|
|
20
|
+
def analyze(self) -> dict:
|
|
21
|
+
scan_result = scan(self.df, target_col=self.target_col)
|
|
22
|
+
intelligence_result = analyze_intelligence(self.df, target_col=self.target_col)
|
|
23
|
+
self._analysis = {"scan": scan_result, "intelligence": intelligence_result}
|
|
24
|
+
return self._analysis
|
|
25
|
+
|
|
26
|
+
def report(self, as_json: bool = False):
|
|
27
|
+
if self._analysis is None:
|
|
28
|
+
self.analyze()
|
|
29
|
+
if as_json:
|
|
30
|
+
return json.dumps(self._analysis, indent=2, default=str)
|
|
31
|
+
s, i = self._analysis["scan"], self._analysis["intelligence"]
|
|
32
|
+
print(f"Rows: {s['rows']:,} | Columns: {s['columns']} | Memory: {s['memory_mb']} MB")
|
|
33
|
+
print(f"Missing: {s['missing_pct']}% | Duplicates: {s['duplicate_pct']}%")
|
|
34
|
+
if "task_type" in s:
|
|
35
|
+
print(f"Task type: {s['task_type']}")
|
|
36
|
+
if "imbalance_ratio" in s:
|
|
37
|
+
print(f"Imbalance ratio: {s['imbalance_ratio']}:1")
|
|
38
|
+
if i["leakage_risk_columns"]:
|
|
39
|
+
print(f"Possible leakage columns: {i['leakage_risk_columns']}")
|
|
40
|
+
print(f"Complexity score: {i['complexity_score']}/100")
|
|
41
|
+
return self._analysis
|
|
42
|
+
|
|
43
|
+
def recommend(self):
|
|
44
|
+
if self._analysis is None:
|
|
45
|
+
self.analyze()
|
|
46
|
+
return select_strategy(self._analysis)
|
|
47
|
+
|
|
48
|
+
def available_algorithms(self) -> list[dict]:
|
|
49
|
+
return list_available_algorithms()
|
|
50
|
+
|
|
51
|
+
def optimize(self, algorithm_key: str = None, keep_ratio: float = 0.5) -> dict:
|
|
52
|
+
if self.target_col is None:
|
|
53
|
+
raise ValueError("target_col is required to optimize.")
|
|
54
|
+
if algorithm_key is None:
|
|
55
|
+
rec = self.recommend()
|
|
56
|
+
optimizer = rec.algorithm_instance
|
|
57
|
+
else:
|
|
58
|
+
optimizer = get_algorithm(algorithm_key)
|
|
59
|
+
result = run_optimization(self.df, self.target_col, optimizer, keep_ratio=keep_ratio)
|
|
60
|
+
report = build_report(optimizer, result, self.df)
|
|
61
|
+
self._last_result = result
|
|
62
|
+
self._last_optimizer = optimizer
|
|
63
|
+
return {
|
|
64
|
+
"optimized_df": result.optimized_df,
|
|
65
|
+
"explainability": report["explanation"],
|
|
66
|
+
"dataset_score": report["dataset_score"],
|
|
67
|
+
"runtime_seconds": report["runtime_seconds"],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def export(self, path: str, result: dict = None):
|
|
71
|
+
result = result or {"optimized_df": self._last_result.optimized_df}
|
|
72
|
+
result["optimized_df"].to_csv(path, index=False)
|
|
73
|
+
print(f"Exported optimized dataset -> {path} ({len(result['optimized_df']):,} rows)")
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
def benchmark(self, algorithms: list[str] = None, keep_ratio: float = 0.5) -> dict:
|
|
77
|
+
return benchmark_algorithms(self.df, self.target_col, algorithms=algorithms, keep_ratio=keep_ratio)
|
|
78
|
+
|
|
79
|
+
def compare(self, optimized_df: pd.DataFrame) -> dict:
|
|
80
|
+
return compare_to_full(optimized_df, self.df, self.target_col)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datapruning
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
4
4
|
Summary: Intelligent data pruning for ML datasets
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Project-URL: Homepage, https://www.datapruning.com
|
|
@@ -16,9 +16,12 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
19
20
|
Requires-Dist: numpy>=1.24.0
|
|
20
21
|
Requires-Dist: pandas>=2.0.0
|
|
22
|
+
Requires-Dist: scikit-learn>=1.3.0
|
|
21
23
|
Requires-Dist: torch>=2.0.0
|
|
24
|
+
Dynamic: license-file
|
|
22
25
|
|
|
23
26
|
# DataPruning
|
|
24
27
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
setup.py
|
|
6
|
+
build_src/_core.c
|
|
7
|
+
datapruning/__init__.py
|
|
8
|
+
datapruning/pipeline.py
|
|
9
|
+
datapruning/sdk.py
|
|
10
|
+
datapruning.egg-info/PKG-INFO
|
|
11
|
+
datapruning.egg-info/SOURCES.txt
|
|
12
|
+
datapruning.egg-info/dependency_links.txt
|
|
13
|
+
datapruning.egg-info/requires.txt
|
|
14
|
+
datapruning.egg-info/top_level.txt
|
|
15
|
+
datapruning/algorithms/__init__.py
|
|
16
|
+
datapruning/algorithms/base.py
|
|
17
|
+
datapruning/algorithms/cluster_centroids.py
|
|
18
|
+
datapruning/algorithms/density_pruner.py
|
|
19
|
+
datapruning/algorithms/duplicate_aware.py
|
|
20
|
+
datapruning/algorithms/instance_hardness.py
|
|
21
|
+
datapruning/algorithms/random_undersampler.py
|
|
22
|
+
datapruning/algorithms/registry.py
|
|
23
|
+
datapruning/engine/__init__.py
|
|
24
|
+
datapruning/engine/benchmark.py
|
|
25
|
+
datapruning/engine/explainability.py
|
|
26
|
+
datapruning/engine/intelligence.py
|
|
27
|
+
datapruning/engine/optimizer.py
|
|
28
|
+
datapruning/engine/scanner.py
|
|
29
|
+
datapruning/engine/strategy_selector.py
|
|
30
|
+
datapruning/reports/__init__.py
|
|
31
|
+
datapruning/reports/exporter.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "datapruning"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.7"
|
|
8
8
|
description = "Intelligent data pruning for ML datasets"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,14 +24,12 @@ classifiers = [
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"numpy>=1.24.0",
|
|
26
26
|
"pandas>=2.0.0",
|
|
27
|
+
"scikit-learn>=1.3.0",
|
|
27
28
|
"torch>=2.0.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
31
32
|
Homepage = "https://www.datapruning.com"
|
|
32
33
|
|
|
33
|
-
[tool.setuptools]
|
|
34
|
-
license-files = []
|
|
35
|
-
|
|
36
34
|
[tool.setuptools.packages.find]
|
|
37
35
|
include = ["datapruning", "datapruning.*"]
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
MANIFEST.in
|
|
3
|
-
README.md
|
|
4
|
-
pyproject.toml
|
|
5
|
-
setup.py
|
|
6
|
-
build_src/_core.c
|
|
7
|
-
datapruning/__init__.py
|
|
8
|
-
datapruning/pipeline.py
|
|
9
|
-
datapruning.egg-info/PKG-INFO
|
|
10
|
-
datapruning.egg-info/SOURCES.txt
|
|
11
|
-
datapruning.egg-info/dependency_links.txt
|
|
12
|
-
datapruning.egg-info/requires.txt
|
|
13
|
-
datapruning.egg-info/top_level.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|