rapidsegment 1.0.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.
- rapidsegment/__init__.py +8 -0
- rapidsegment/builder.py +625 -0
- rapidsegment/py.typed +0 -0
- rapidsegment/scorer.py +174 -0
- rapidsegment/utils/__init__.py +3 -0
- rapidsegment/utils/data_loader.py +170 -0
- rapidsegment/utils/on_gcp_feature_selection.py +271 -0
- rapidsegment/utils/undersampler.sql +14 -0
- rapidsegment-1.0.0.dist-info/METADATA +470 -0
- rapidsegment-1.0.0.dist-info/RECORD +11 -0
- rapidsegment-1.0.0.dist-info/WHEEL +4 -0
rapidsegment/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
2
|
+
__author__ = "Bishwarup Biswas <bishwarup1429@gmail.com>"
|
|
3
|
+
|
|
4
|
+
from .utils import UniversalDataLoader
|
|
5
|
+
from .builder import StrategicSegmentBuilder
|
|
6
|
+
from .scorer import StrategicSegmentScore
|
|
7
|
+
|
|
8
|
+
__all__ = ["UniversalDataLoader", "StrategicSegmentBuilder", "StrategicSegmentScore"]
|
rapidsegment/builder.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strategic Segmentation Engine
|
|
3
|
+
=========================================
|
|
4
|
+
Combinatorial heuristic segmentation using Optimal Binning, Apriori pruning,
|
|
5
|
+
and vectorized DuckDB scorecard deciling.
|
|
6
|
+
|
|
7
|
+
Author: Bishwarup Biswas + Gemini
|
|
8
|
+
Python Version: 3.9+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import multiprocessing
|
|
14
|
+
import re
|
|
15
|
+
import itertools
|
|
16
|
+
from itertools import combinations
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
18
|
+
import os
|
|
19
|
+
import psutil
|
|
20
|
+
import duckdb
|
|
21
|
+
import numpy as np
|
|
22
|
+
from joblib import Parallel, delayed
|
|
23
|
+
from optbinning import OptimalBinning
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
# 1. Get current date and time
|
|
27
|
+
now = datetime.now()
|
|
28
|
+
|
|
29
|
+
# Example output: "2026_07_11_22_24_30"
|
|
30
|
+
timestamp = now.strftime("%Y_%m_%d_%H_%M_%S")
|
|
31
|
+
|
|
32
|
+
# Configure Production Module Logger
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO,
|
|
35
|
+
format="%(asctime)s | %(levelname)-8s | [%(filename)s:%(lineno)d] | %(message)s",
|
|
36
|
+
)
|
|
37
|
+
logger = logging.getLogger("StrategicEngine")
|
|
38
|
+
|
|
39
|
+
# Pre-compile regex at module load for O(1) lookup inside loops
|
|
40
|
+
_BRACKET_REGEX = re.compile(r"\[(.*?)\]", flags = re.DOTALL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StrategicSegmentBuilder:
|
|
44
|
+
"""Extracts mutually exclusive, predictive segments from tabular data.
|
|
45
|
+
|
|
46
|
+
Utilizes Optimal Binning to discretize continuous features into monotonic
|
|
47
|
+
Information Value (IV) bins, applying an Apriori-style combinatorial prune
|
|
48
|
+
to surface multi-way rules meeting defined Lift and Volume thresholds.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
target: Dependent binary target column name (1 = Event, 0 = Non-Event).
|
|
52
|
+
n_jobs: Number of CPU cores allocated to parallelized search jobs.
|
|
53
|
+
min_sample_size: Absolute minimum row count required for a valid rule (fallback default).
|
|
54
|
+
min_lift: Minimum lift cutoff (Segment Rate / Population Base Rate) (fallback default).
|
|
55
|
+
min_events: Minimum number of positive events required for a valid rule (fallback default).
|
|
56
|
+
top_n_vars: Number of highest-IV features passed into the Apriori engine.
|
|
57
|
+
max_segments: Hard stopping ceiling for extracted mutually exclusive segments.
|
|
58
|
+
max_feature_reuse: Structural limit for tracking and restricting feature dominance.
|
|
59
|
+
param_grid: Optional dictionary of parameter grid search values for min_sample_size and min_lift.
|
|
60
|
+
enable_diversity: If True, blocks rules combining variables from the same business group.
|
|
61
|
+
enable_1way: Allow 1-dimensional rules in final pool.
|
|
62
|
+
enable_2way: Allow 2-dimensional intersection rules in final pool.
|
|
63
|
+
enable_3way: Allow 3-dimensional intersection rules in final pool.
|
|
64
|
+
feature_groups: Mapping of business categories to columns (e.g. {'risk': ['scr', 'bal']}).
|
|
65
|
+
ignore_features: Explicit list of columns to drop prior to IV calculation.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
target: str,
|
|
71
|
+
n_jobs: int = -1,
|
|
72
|
+
min_sample_size: int = 1000,
|
|
73
|
+
min_lift: float = 2.0,
|
|
74
|
+
min_events: int = 5,
|
|
75
|
+
top_n_vars: int = 20,
|
|
76
|
+
max_segments: int = 10,
|
|
77
|
+
max_feature_reuse: int = 1,
|
|
78
|
+
param_grid: Optional[Dict[str, List[Any]]] = None,
|
|
79
|
+
enable_diversity: bool = False,
|
|
80
|
+
enable_1way: bool = True,
|
|
81
|
+
enable_2way: bool = True,
|
|
82
|
+
enable_3way: bool = True,
|
|
83
|
+
feature_groups: Optional[Dict[str, List[str]]] = None,
|
|
84
|
+
ignore_features: Optional[List[str]] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.target = target
|
|
87
|
+
self.n_jobs = (
|
|
88
|
+
n_jobs if n_jobs != -1 else max(1, multiprocessing.cpu_count() - 1)
|
|
89
|
+
)
|
|
90
|
+
self.min_sample_size = min_sample_size
|
|
91
|
+
self.min_lift = min_lift
|
|
92
|
+
self.min_events = min_events
|
|
93
|
+
self.top_n_vars = top_n_vars
|
|
94
|
+
self.max_segments = max_segments
|
|
95
|
+
self.max_feature_reuse = max_feature_reuse
|
|
96
|
+
self.segments: List[Dict[str, Any]] = []
|
|
97
|
+
self.param_grid = param_grid or {}
|
|
98
|
+
self.enable_diversity = enable_diversity
|
|
99
|
+
self.enable_1way = enable_1way
|
|
100
|
+
self.enable_2way = enable_2way
|
|
101
|
+
self.enable_3way = enable_3way
|
|
102
|
+
self.feature_groups = feature_groups or {}
|
|
103
|
+
self.ignore_features = ignore_features or []
|
|
104
|
+
self.feature_usage_counts: Dict[str, int] = {}
|
|
105
|
+
# New: Diagnostic repository
|
|
106
|
+
self.diagnostics_: List[Dict[str, Any]] = []
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _resolve_optb_dtype(duckdb_type: str) -> str:
|
|
110
|
+
"""Determines the correct OptBinning data type flag from a DuckDB type string."""
|
|
111
|
+
dtype_upper = duckdb_type.upper()
|
|
112
|
+
if any(t in dtype_upper for t in ["VARCHAR", "CHAR", "STRING", "TEXT", "UUID"]):
|
|
113
|
+
return "categorical"
|
|
114
|
+
return "numerical"
|
|
115
|
+
|
|
116
|
+
def _validate_feature_groups(self, columns: List[str]) -> None:
|
|
117
|
+
"""Validates that all declared feature group variables exist in the target dataset."""
|
|
118
|
+
if not self.feature_groups:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
active_cols = set(columns) - {self.target} - set(self.ignore_features)
|
|
122
|
+
validated_count = 0
|
|
123
|
+
|
|
124
|
+
for group, vars_list in self.feature_groups.items():
|
|
125
|
+
for var in vars_list:
|
|
126
|
+
if var not in active_cols:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Schema Mismatch: Feature '{var}' declared in group '{group}' "
|
|
129
|
+
"was not found in the provided DataFrame/Table."
|
|
130
|
+
)
|
|
131
|
+
validated_count += 1
|
|
132
|
+
|
|
133
|
+
logger.info(
|
|
134
|
+
f"Feature group validation passed. ({validated_count} features mapped)"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_group(self, var: str) -> str:
|
|
138
|
+
"""Returns the assigned business category for a feature, or the feature name itself."""
|
|
139
|
+
for group, vars_list in self.feature_groups.items():
|
|
140
|
+
if var in vars_list:
|
|
141
|
+
return group
|
|
142
|
+
return var
|
|
143
|
+
|
|
144
|
+
def is_diverse(self, combo: Tuple[str, ...]) -> bool:
|
|
145
|
+
"""Ensures a tuple of features spans strictly distinct analytical groups."""
|
|
146
|
+
if not self.enable_diversity:
|
|
147
|
+
return True
|
|
148
|
+
groups = [self.get_group(v) for v in combo]
|
|
149
|
+
return len(groups) == len(set(groups))
|
|
150
|
+
|
|
151
|
+
def compute_iv_ranking_and_bin(
|
|
152
|
+
self, con: duckdb.DuckDBPyConnection, eligible_cols: List[str], columns_types: Dict[str, str]
|
|
153
|
+
) -> Tuple[List[Dict[str, Union[str, float]]], Dict[str, np.ndarray]]:
|
|
154
|
+
"""Calculates Information Value (IV) and returns pre-computed bins in a single pass."""
|
|
155
|
+
|
|
156
|
+
def _worker(col: str) -> Tuple[str, float, Optional[np.ndarray]]:
|
|
157
|
+
try:
|
|
158
|
+
data_dict = con.execute(f'SELECT "{col}", "{self.target}" FROM current_df').fetchnumpy()
|
|
159
|
+
col_arr = data_dict[col]
|
|
160
|
+
target_arr = data_dict[self.target]
|
|
161
|
+
dtype = self._resolve_optb_dtype(columns_types[col])
|
|
162
|
+
|
|
163
|
+
optb = OptimalBinning(name=col, dtype=dtype)
|
|
164
|
+
optb.fit(col_arr, target_arr)
|
|
165
|
+
|
|
166
|
+
iv_val = optb.binning_table.build()["IV"].values[-1]
|
|
167
|
+
transformed_bins = optb.transform(col_arr, metric="bins").astype(str)
|
|
168
|
+
return col, float(iv_val) * 100, transformed_bins
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.debug(f"IV computation failed for {col}: {e}")
|
|
171
|
+
return col, 0.0, None
|
|
172
|
+
|
|
173
|
+
results = Parallel(n_jobs=self.n_jobs, prefer="threads")(
|
|
174
|
+
delayed(_worker)(col) for col in eligible_cols
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
ranking = []
|
|
178
|
+
precomputed_bins = {}
|
|
179
|
+
for col, iv, bins in results:
|
|
180
|
+
ranking.append({"variable": col, "iv": iv})
|
|
181
|
+
if bins is not None:
|
|
182
|
+
precomputed_bins[col] = bins
|
|
183
|
+
|
|
184
|
+
ranking.sort(key=lambda x: x["iv"], reverse=True)
|
|
185
|
+
return ranking, precomputed_bins
|
|
186
|
+
|
|
187
|
+
def _agg_combinations(
|
|
188
|
+
self,
|
|
189
|
+
con: duckdb.DuckDBPyConnection,
|
|
190
|
+
combo_list: List[Tuple[str, ...]],
|
|
191
|
+
base_rate: float,
|
|
192
|
+
) -> List[Dict[str, Any]]:
|
|
193
|
+
"""Batch-executes SQL combinatorics via DuckDB GROUP BY to bypass slow Pandas operations."""
|
|
194
|
+
if not combo_list:
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
queries = []
|
|
198
|
+
for combo in combo_list:
|
|
199
|
+
cols_str = ", ".join([f'"{c}"' for c in combo])
|
|
200
|
+
rule_concat = " || ' & ' || ".join([f"'{c}=' || CAST(\"{c}\" AS VARCHAR)" for c in combo])
|
|
201
|
+
combo_str = ",".join(combo)
|
|
202
|
+
|
|
203
|
+
query = f"""
|
|
204
|
+
SELECT
|
|
205
|
+
{rule_concat} AS rule,
|
|
206
|
+
COUNT("{self.target}")::BIGINT AS count,
|
|
207
|
+
SUM(CAST("{self.target}" AS DOUBLE)) AS events,
|
|
208
|
+
'{combo_str}' AS combo_vars_str
|
|
209
|
+
FROM binned_df
|
|
210
|
+
GROUP BY {cols_str}
|
|
211
|
+
HAVING COUNT("{self.target}") >= {self.min_sample_size}
|
|
212
|
+
AND SUM(CAST("{self.target}" AS DOUBLE)) >= {self.min_events}
|
|
213
|
+
"""
|
|
214
|
+
queries.append(query)
|
|
215
|
+
|
|
216
|
+
valid_results = []
|
|
217
|
+
chunk_size = 100 # Increased step size to leverage broader hardware parallelism
|
|
218
|
+
|
|
219
|
+
# Batch execute independent group bys natively in C++ via UNION ALL
|
|
220
|
+
for i in range(0, len(queries), chunk_size):
|
|
221
|
+
chunk = queries[i:i+chunk_size]
|
|
222
|
+
union_query = " UNION ALL ".join(chunk)
|
|
223
|
+
|
|
224
|
+
res = con.execute(union_query).fetchall()
|
|
225
|
+
for rule, count, events, combo_vars_str in res:
|
|
226
|
+
rate = (events / count) * 100.0 if count > 0 else 0
|
|
227
|
+
lift = rate / (base_rate * 100.0) if base_rate > 0 else 0
|
|
228
|
+
|
|
229
|
+
# Require at least self.min_events events and lift > 1.0
|
|
230
|
+
|
|
231
|
+
if lift >= self.min_lift and events >= self.min_events and lift > 1.0:
|
|
232
|
+
valid_results.append({
|
|
233
|
+
"rule": rule,
|
|
234
|
+
"count": count,
|
|
235
|
+
"rate": rate,
|
|
236
|
+
"lift": lift,
|
|
237
|
+
"combo_vars": tuple(combo_vars_str.split(","))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return valid_results
|
|
241
|
+
|
|
242
|
+
def parse_rule_to_sql(self, rule_str: str) -> str:
|
|
243
|
+
"""Translates OptBinning string syntax into a production SQL WHERE clause."""
|
|
244
|
+
parts = [p.strip() for p in rule_str.split("&")]
|
|
245
|
+
sql_conditions: List[str] = []
|
|
246
|
+
|
|
247
|
+
for part in parts:
|
|
248
|
+
if "=" not in part:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
col, interval = [x.strip() for x in part.split("=", 1)]
|
|
252
|
+
bracket_match = _BRACKET_REGEX.search(interval)
|
|
253
|
+
|
|
254
|
+
is_categorical = False
|
|
255
|
+
if bracket_match:
|
|
256
|
+
content = bracket_match.group(1)
|
|
257
|
+
if any(
|
|
258
|
+
k in interval for k in ("'", '"', "Array", "Categorical")
|
|
259
|
+
) or not interval.startswith(("[", "(")):
|
|
260
|
+
is_categorical = True
|
|
261
|
+
elif len(content.split(",")) > 2:
|
|
262
|
+
is_categorical = True
|
|
263
|
+
|
|
264
|
+
# Robust Categorical Set Handling using AST Literal Parsing
|
|
265
|
+
if is_categorical and bracket_match:
|
|
266
|
+
import ast
|
|
267
|
+
try:
|
|
268
|
+
# Safely convert the string string-representation of a list e.g. "['A', 'B']" into an actual Python list
|
|
269
|
+
raw_items = ast.literal_eval(bracket_match.group(0))
|
|
270
|
+
except Exception:
|
|
271
|
+
# Fallback backup parsing if string structure is slightly malformed
|
|
272
|
+
raw_items = [
|
|
273
|
+
i.strip().strip("'").strip('"')
|
|
274
|
+
for i in bracket_match.group(1).split(",")
|
|
275
|
+
if i.strip()
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
# If the item is a true string instance, wrap it in SQL quotes. Otherwise, treat it as a raw literal number.
|
|
279
|
+
formatted_items = ", ".join(
|
|
280
|
+
[
|
|
281
|
+
f"'{item}'" if isinstance(item, str) else str(item)
|
|
282
|
+
for item in raw_items
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if formatted_items:
|
|
287
|
+
sql_conditions.append(f'{col} IN ({formatted_items})')
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# 2. Null/Special State Handling
|
|
291
|
+
if interval in ["Special", "Missing"]:
|
|
292
|
+
sql_conditions.append(f'{col} IS NULL')
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# 3. Continuous Numeric Range Handling
|
|
296
|
+
if interval.startswith(("[", "(")):
|
|
297
|
+
left_char, right_char = interval[0], interval[-1]
|
|
298
|
+
lower_str, upper_str = [x.strip() for x in interval[1:-1].split(",", 1)]
|
|
299
|
+
|
|
300
|
+
range_conds = []
|
|
301
|
+
if lower_str.lower() != "-inf":
|
|
302
|
+
op = ">=" if left_char == "[" else ">"
|
|
303
|
+
range_conds.append(f'{col} {op} {lower_str}')
|
|
304
|
+
|
|
305
|
+
if upper_str.lower() != "inf":
|
|
306
|
+
op = "<=" if right_char == "]" else "<"
|
|
307
|
+
range_conds.append(f'{col} {op} {upper_str}')
|
|
308
|
+
|
|
309
|
+
if range_conds:
|
|
310
|
+
sql_conditions.append(" AND ".join(range_conds))
|
|
311
|
+
|
|
312
|
+
return " AND ".join(
|
|
313
|
+
f"({cond})" if "AND" in cond else cond for cond in sql_conditions
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def extract_segments(self, data: Any) -> List[Dict[str, Any]]:
|
|
317
|
+
"""Sequentially extracts high-lift segments using an iterative Multi-Threshold Grid Search
|
|
318
|
+
while applying feature usage constraints to eliminate structural feature dominance.
|
|
319
|
+
"""
|
|
320
|
+
# Memory Optimization: Use file-backed storage
|
|
321
|
+
if os.path.exists(f"experiment_{timestamp}.db"):
|
|
322
|
+
os.remove(f"experiment_{timestamp}.db")
|
|
323
|
+
con = duckdb.connect(f"experiment_{timestamp}.db")
|
|
324
|
+
|
|
325
|
+
# 1. Dynamically calculate available CPU cores
|
|
326
|
+
total_cores = os.cpu_count() or 1
|
|
327
|
+
target_threads = max(1, total_cores - 2) if total_cores > 4 else total_cores
|
|
328
|
+
|
|
329
|
+
# 2. Dynamically calculate memory limit (in bytes)
|
|
330
|
+
total_memory_bytes = psutil.virtual_memory().total
|
|
331
|
+
target_memory_gb = int((total_memory_bytes * 0.5) / (1024 ** 3))
|
|
332
|
+
|
|
333
|
+
# 3. Apply the settings dynamically to DuckDB
|
|
334
|
+
con.execute(f"SET threads = {target_threads};")
|
|
335
|
+
con.execute(f"SET memory_limit = '{target_memory_gb}GB';")
|
|
336
|
+
|
|
337
|
+
logger.info(f"DuckDB Configured: Threads={target_threads}/{total_cores}, MemoryLimit={target_memory_gb}GB")
|
|
338
|
+
|
|
339
|
+
con.execute("CREATE OR REPLACE TABLE current_df AS SELECT * FROM data")
|
|
340
|
+
|
|
341
|
+
cols_info = con.execute("DESCRIBE current_df").fetchall()
|
|
342
|
+
columns_types = {row[0]: row[1] for row in cols_info}
|
|
343
|
+
all_cols = list(columns_types.keys())
|
|
344
|
+
|
|
345
|
+
if self.enable_diversity:
|
|
346
|
+
self._validate_feature_groups(all_cols)
|
|
347
|
+
|
|
348
|
+
eligible_cols = [c for c in all_cols if c != self.target and c not in self.ignore_features]
|
|
349
|
+
self.feature_usage_counts = {col: 0 for col in eligible_cols}
|
|
350
|
+
|
|
351
|
+
if self.param_grid:
|
|
352
|
+
logger.info(
|
|
353
|
+
f"Dynamic Grid Search Enabled: {len(self.param_grid.get('min_sample_size', [self.min_sample_size])) * len(self.param_grid.get('min_lift', [self.min_lift]))} total configurations."
|
|
354
|
+
)
|
|
355
|
+
sizes = self.param_grid.get("min_sample_size", [self.min_sample_size])
|
|
356
|
+
lifts = self.param_grid.get("min_lift", [self.min_lift])
|
|
357
|
+
experiments = [
|
|
358
|
+
{"min_sample_size": s, "min_lift": l}
|
|
359
|
+
for s, l in itertools.product(sizes, lifts)
|
|
360
|
+
]
|
|
361
|
+
else:
|
|
362
|
+
experiments = [{"min_sample_size": self.min_sample_size, "min_lift": self.min_lift}]
|
|
363
|
+
|
|
364
|
+
for i in range(1, self.max_segments + 1):
|
|
365
|
+
res = con.execute(f'SELECT AVG("{self.target}"), COUNT(*) FROM current_df').fetchone()
|
|
366
|
+
base_rate, current_volume = res[0] or 0.0, res[1] or 0
|
|
367
|
+
|
|
368
|
+
min_floor_volume = min(exp["min_sample_size"] for exp in experiments)
|
|
369
|
+
|
|
370
|
+
if base_rate == 0 or current_volume < min_floor_volume:
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
logger.info(
|
|
374
|
+
f"Iteration {i} | Remaining Volume: {current_volume:,} | Base Rate: {base_rate*100:.2f}%"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Speed Tweak: Single-pass pipeline computes IV and structures transformation bins at the same time
|
|
378
|
+
iv_ranking, precomputed_bins = self.compute_iv_ranking_and_bin(con, eligible_cols, columns_types)
|
|
379
|
+
|
|
380
|
+
# --- START DIAGNOSTIC CAPTURE ---
|
|
381
|
+
current_iv_map = {row["variable"]: row["iv"] for row in iv_ranking}
|
|
382
|
+
top_n_variable_names = [r["variable"] for r in iv_ranking[:self.top_n_vars]]
|
|
383
|
+
|
|
384
|
+
iteration_snapshot = {}
|
|
385
|
+
for col in eligible_cols:
|
|
386
|
+
used_count = self.feature_usage_counts.get(col, 0)
|
|
387
|
+
current_iv = current_iv_map.get(col, 0.0)
|
|
388
|
+
|
|
389
|
+
if used_count >= self.max_feature_reuse:
|
|
390
|
+
status = "Excluded (Max Feature Reuse Exceeded)"
|
|
391
|
+
elif current_iv <= 0.0:
|
|
392
|
+
status = "Excluded (Information Value is Zero/Invalid)"
|
|
393
|
+
elif col not in top_n_variable_names:
|
|
394
|
+
status = "Excluded (Outside Top N Features by IV)"
|
|
395
|
+
else:
|
|
396
|
+
status = "Eligible for Combination Search"
|
|
397
|
+
|
|
398
|
+
iteration_snapshot[col] = {
|
|
399
|
+
"iv": current_iv,
|
|
400
|
+
"times_used_previously": used_count,
|
|
401
|
+
"status": status
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
self.diagnostics_.append({
|
|
405
|
+
"iteration": i,
|
|
406
|
+
"residual_volume": current_volume,
|
|
407
|
+
"base_rate": base_rate,
|
|
408
|
+
"features_state": iteration_snapshot,
|
|
409
|
+
"winning_segment": None
|
|
410
|
+
})
|
|
411
|
+
# --- END DIAGNOSTIC CAPTURE ---
|
|
412
|
+
|
|
413
|
+
allowed_vars = [
|
|
414
|
+
row["variable"] for row in iv_ranking
|
|
415
|
+
if self.feature_usage_counts.get(row["variable"], 0) < self.max_feature_reuse
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
top_vars = allowed_vars[:self.top_n_vars]
|
|
419
|
+
if not top_vars:
|
|
420
|
+
logger.warning("All eligible features have been exhausted via max_feature_reuse filters. Aborting.")
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
# Speed Tweak: Load the arrays from memory without another fit cycle
|
|
424
|
+
binned_data = {self.target: con.execute(f'SELECT "{self.target}" FROM current_df').fetchnumpy()[self.target]}
|
|
425
|
+
valid_vars = []
|
|
426
|
+
for v in top_vars:
|
|
427
|
+
if v in precomputed_bins:
|
|
428
|
+
if len(np.unique(precomputed_bins[v])) > 1:
|
|
429
|
+
binned_data[v] = precomputed_bins[v]
|
|
430
|
+
valid_vars.append(v)
|
|
431
|
+
|
|
432
|
+
con.execute("DROP TABLE IF EXISTS binned_df")
|
|
433
|
+
con.execute("CREATE TABLE binned_df AS SELECT * FROM binned_data")
|
|
434
|
+
|
|
435
|
+
grid_candidates: List[Dict[str, Any]] = []
|
|
436
|
+
|
|
437
|
+
# 3. Parameter Matrix Grid Sweep
|
|
438
|
+
for config in experiments:
|
|
439
|
+
self.min_sample_size = config["min_sample_size"]
|
|
440
|
+
self.min_lift = config["min_lift"]
|
|
441
|
+
|
|
442
|
+
all_rules: List[Dict[str, Any]] = []
|
|
443
|
+
|
|
444
|
+
# Apriori Level 1 (Singles)
|
|
445
|
+
res_1 = self._agg_combinations(
|
|
446
|
+
con, [(c,) for c in valid_vars], base_rate
|
|
447
|
+
)
|
|
448
|
+
valid_1way_vars = set()
|
|
449
|
+
|
|
450
|
+
if res_1:
|
|
451
|
+
valid_1way_vars = {c["combo_vars"][0] for c in res_1}
|
|
452
|
+
if self.enable_1way:
|
|
453
|
+
all_rules.extend(res_1)
|
|
454
|
+
|
|
455
|
+
if not valid_1way_vars:
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
# Apriori Level 2 (Pairs)
|
|
459
|
+
valid_2way_sets = set()
|
|
460
|
+
if len(valid_1way_vars) >= 2 and (self.enable_2way or self.enable_3way):
|
|
461
|
+
combos_2 = [
|
|
462
|
+
c for c in combinations(valid_1way_vars, 2) if self.is_diverse(c)
|
|
463
|
+
]
|
|
464
|
+
if combos_2:
|
|
465
|
+
res_2 = self._agg_combinations(con, combos_2, base_rate)
|
|
466
|
+
if res_2:
|
|
467
|
+
valid_2way_sets = {frozenset(c["combo_vars"]) for c in res_2}
|
|
468
|
+
if self.enable_2way:
|
|
469
|
+
all_rules.extend(res_2)
|
|
470
|
+
|
|
471
|
+
# Apriori Level 3 (Triplets)
|
|
472
|
+
if self.enable_3way and len(valid_1way_vars) >= 3 and valid_2way_sets:
|
|
473
|
+
combos_3 = [
|
|
474
|
+
c
|
|
475
|
+
for c in combinations(valid_1way_vars, 3)
|
|
476
|
+
if self.is_diverse(c)
|
|
477
|
+
and all(
|
|
478
|
+
frozenset(p) in valid_2way_sets for p in combinations(c, 2)
|
|
479
|
+
)
|
|
480
|
+
]
|
|
481
|
+
if combos_3:
|
|
482
|
+
res_3 = self._agg_combinations(con, combos_3, base_rate)
|
|
483
|
+
if res_3:
|
|
484
|
+
all_rules.extend(res_3)
|
|
485
|
+
|
|
486
|
+
if all_rules:
|
|
487
|
+
# Sort candidates natively in python to avoid DataFrame serialization overhead
|
|
488
|
+
all_rules.sort(key=lambda x: (x["lift"], x["rate"], x["count"]), reverse=True)
|
|
489
|
+
top_match = all_rules[0].copy()
|
|
490
|
+
top_match["grid_min_sample_size"] = config["min_sample_size"]
|
|
491
|
+
top_match["grid_min_lift"] = config["min_lift"]
|
|
492
|
+
grid_candidates.append(top_match)
|
|
493
|
+
|
|
494
|
+
if not grid_candidates:
|
|
495
|
+
logger.info("No active candidates cleared criteria pool across grid variations. Stopping.")
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
# 4. Resolve Championship Rule Across Parameter Configuration Grid Result Sets
|
|
499
|
+
grid_candidates.sort(key=lambda x: (x["lift"], x["count"], x["rate"]), reverse=True)
|
|
500
|
+
best_match = grid_candidates[0]
|
|
501
|
+
|
|
502
|
+
best_rule = best_match["rule"]
|
|
503
|
+
best_sql = self.parse_rule_to_sql(best_rule)
|
|
504
|
+
winning_combo = best_match["combo_vars"]
|
|
505
|
+
|
|
506
|
+
# 5. Commit Feature Adoption and Increment Dominance Counter State
|
|
507
|
+
for var in winning_combo:
|
|
508
|
+
self.feature_usage_counts[var] = self.feature_usage_counts.get(var, 0) + 1
|
|
509
|
+
logger.info(f"Feature Usage Tracker Update -> '{var}' used count = {self.feature_usage_counts[var]}")
|
|
510
|
+
|
|
511
|
+
self.segments.append(
|
|
512
|
+
{
|
|
513
|
+
"segment_id": i,
|
|
514
|
+
"rule_string": best_rule,
|
|
515
|
+
"sql_filter": best_sql,
|
|
516
|
+
"count": int(best_match["count"]),
|
|
517
|
+
"rate": float(best_match["rate"]),
|
|
518
|
+
"lift": float(best_match["lift"]),
|
|
519
|
+
"meta_applied_sample_size": int(best_match["grid_min_sample_size"]),
|
|
520
|
+
"meta_applied_min_lift": float(best_match["grid_min_lift"])
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
logger.info(f"Segment {i} Captured (Size Floor: {best_match['grid_min_sample_size']} | Lift Floor: {best_match['grid_min_lift']}): {best_sql}")
|
|
525
|
+
|
|
526
|
+
self.diagnostics_[-1]["winning_segment"] = {
|
|
527
|
+
"rule": best_rule,
|
|
528
|
+
"sql_filter": best_sql,
|
|
529
|
+
"variables_used": list(winning_combo),
|
|
530
|
+
"lift": float(best_match["lift"]),
|
|
531
|
+
"count": int(best_match["count"])
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
# Memory Optimization: Copy and Swap prevents dead-tuple fragmentation
|
|
535
|
+
con.execute(f"""
|
|
536
|
+
CREATE TABLE temp_residual AS
|
|
537
|
+
SELECT * FROM current_df
|
|
538
|
+
WHERE NOT ({best_sql})
|
|
539
|
+
""")
|
|
540
|
+
con.execute("DROP TABLE current_df")
|
|
541
|
+
con.execute("ALTER TABLE temp_residual RENAME TO current_df")
|
|
542
|
+
|
|
543
|
+
con.close()
|
|
544
|
+
return self.segments
|
|
545
|
+
|
|
546
|
+
def evaluate_final_coverage(self, original_data: Any) -> List[Dict[str, Any]]:
|
|
547
|
+
"""Executes a full CASE WHEN query over the source dataset to map mutually exclusive coverage."""
|
|
548
|
+
if not self.segments:
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
con = duckdb.connect(f"experiment_{timestamp}.db")
|
|
552
|
+
con.execute("CREATE TABLE original_df AS SELECT * FROM original_data")
|
|
553
|
+
|
|
554
|
+
case_statements = [
|
|
555
|
+
f"WHEN {seg['sql_filter']} THEN {seg['segment_id']}"
|
|
556
|
+
for seg in self.segments
|
|
557
|
+
]
|
|
558
|
+
case_sql = "\n ".join(case_statements)
|
|
559
|
+
|
|
560
|
+
final_query = f"""
|
|
561
|
+
WITH PER_SEG_KPIS AS (
|
|
562
|
+
SELECT
|
|
563
|
+
CASE {case_sql} ELSE 0 END AS segment,
|
|
564
|
+
COUNT(*) AS total_count,
|
|
565
|
+
SUM(CAST("{self.target}" AS DOUBLE)) AS target_events,
|
|
566
|
+
(SUM(CAST("{self.target}" AS DOUBLE)) * 100.0 / COUNT(*)) AS response_rate
|
|
567
|
+
FROM original_df
|
|
568
|
+
GROUP BY 1
|
|
569
|
+
),
|
|
570
|
+
BASE_KPIS AS (
|
|
571
|
+
SELECT *,
|
|
572
|
+
SUM(total_count) OVER() AS total_population,
|
|
573
|
+
(SUM(target_events) OVER() * 1.0 / SUM(total_count) OVER()) * 100 AS base_response_rate
|
|
574
|
+
FROM PER_SEG_KPIS
|
|
575
|
+
)
|
|
576
|
+
SELECT
|
|
577
|
+
PER_SEG_KPIS.*,
|
|
578
|
+
BASE_KPIS.base_response_rate,
|
|
579
|
+
(PER_SEG_KPIS.total_count * 1.0 / BASE_KPIS.total_population) * 100 AS capture_rate,
|
|
580
|
+
(PER_SEG_KPIS.response_rate / BASE_KPIS.base_response_rate) AS lift
|
|
581
|
+
FROM PER_SEG_KPIS
|
|
582
|
+
LEFT JOIN BASE_KPIS ON PER_SEG_KPIS.segment = BASE_KPIS.segment
|
|
583
|
+
ORDER BY segment
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
# Native return as a List of Dictionaries
|
|
587
|
+
res = con.execute(final_query)
|
|
588
|
+
columns = [desc[0] for desc in res.description]
|
|
589
|
+
# Make sure to close connection before returning
|
|
590
|
+
res_list = [dict(zip(columns, row)) for row in res.fetchall()]
|
|
591
|
+
con.close()
|
|
592
|
+
return res_list
|
|
593
|
+
|
|
594
|
+
def explain_feature_journey(self, feature_name: str) -> None:
|
|
595
|
+
"""Prints a detailed audit trail of a specific feature across all iterations."""
|
|
596
|
+
if not self.diagnostics_:
|
|
597
|
+
print("No diagnostic records found. Run extract_segments() first.")
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
print("=" * 80)
|
|
601
|
+
print(f"AUDIT TRAIL FOR FEATURE: '{feature_name}'")
|
|
602
|
+
print("=" * 80)
|
|
603
|
+
|
|
604
|
+
for record in self.diagnostics_:
|
|
605
|
+
iter_num = record["iteration"]
|
|
606
|
+
state = record["features_state"].get(feature_name)
|
|
607
|
+
winner = record["winning_segment"]
|
|
608
|
+
|
|
609
|
+
if not state:
|
|
610
|
+
print(f"Iteration {iter_num}: Variable not present or was ignored.")
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
print(f"\n[Iteration {iter_num}]")
|
|
614
|
+
print(f" • Current dynamic IV : {state['iv']:.4f}")
|
|
615
|
+
print(f" • Previous times used : {state['times_used_previously']}")
|
|
616
|
+
print(f" • Selection Status : {state['status']}")
|
|
617
|
+
|
|
618
|
+
if winner and feature_name in winner["variables_used"]:
|
|
619
|
+
print(f" 🎉 SELECTED as part of winning rule!")
|
|
620
|
+
print(f" Rule: {winner['rule']}")
|
|
621
|
+
elif winner:
|
|
622
|
+
print(f" • Winner this round : {winner['rule']} (Variables: {winner['variables_used']})")
|
|
623
|
+
print("=" * 80)
|
|
624
|
+
|
|
625
|
+
|
rapidsegment/py.typed
ADDED
|
File without changes
|