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.
@@ -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"]
@@ -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