rapidsegment 1.2.1__tar.gz → 1.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapidsegment
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: A fast, multi-segment population scoring and combinatorial heuristic rule extraction engine.
5
5
  Author: Bishwarup Biswas
6
6
  Author-email: Bishwarup Biswas <bishwarup1429@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rapidsegment"
3
- version = "1.2.1"
3
+ version = "1.2.2"
4
4
  description = "A fast, multi-segment population scoring and combinatorial heuristic rule extraction engine."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rapidsegment"
3
- version = "1.2.1"
3
+ version = "1.2.2"
4
4
  description = "A fast, multi-segment population scoring and combinatorial heuristic rule extraction engine."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -95,8 +95,7 @@ class StrategicSegmentBuilder:
95
95
  feature_groups: Optional[Dict[str, List[str]]] = None,
96
96
  ignore_features: Optional[List[str]] = None,
97
97
  sort_priority: str = "rate_lift_count",
98
- binning_method: str = "optimal",
99
- naive_bins: int = 5 ,
98
+ binning_method: str = "optimal_cart", # "naive" | "optimal_cart" | "optimal_quantile" | "optimal" (alias of cart) naive_bins: int = 5 ,
100
99
  max_expansion_hops: int = 0,
101
100
  selection_metric: str = "iv",
102
101
  expand_log_mode: str = "none",
@@ -121,8 +120,12 @@ class StrategicSegmentBuilder:
121
120
  feature_groups: Mapping of business categories to columns (e.g. {'risk': ['scr', 'bal']}).
122
121
  ignore_features: Explicit list of columns to drop prior to IV calculation.
123
122
  sort_priority: Ranking criteria for selecting champion segments.
124
- binning_method: Which binning engine to use ('optimal' or 'naive').
125
- naive_bins: Number of quantile bins used when binning_method is 'naive'.
123
+ binning_method: Binning engine:
124
+ - "naive": DuckDB equal-frequency quantiles (supports adjacent-bin expansion).
125
+ - "optimal_cart": OptBinning with CART prebinning (target-aware cuts).
126
+ - "optimal_quantile": OptBinning with quantile prebinning (more stable).
127
+ - "optimal": alias for "optimal_cart" (backward compatible).
128
+ naive_bins: Number of quantile bins when binning_method is "naive".
126
129
  selection_metric: Metric used to rank features for top_n_vars selection ("iv" or "response_rate").
127
130
  max_expansion_hops: Adjacent-bin merging hop distance limit (0 disables expansion).
128
131
  expand_log_mode: Controls verbosity of adjacent-bin expansion logging ("none", "summary", "champion", "full").
@@ -159,6 +162,16 @@ class StrategicSegmentBuilder:
159
162
  self.diagnostics_: List[Dict[str, Any]] = []
160
163
  self.stop_reason: Optional[str] = None
161
164
  self.binning_method = binning_method
165
+ # Normalize aliases
166
+ _bm = (binning_method or "optimal_cart").lower().strip()
167
+ if _bm == "optimal":
168
+ _bm = "optimal_cart"
169
+ if _bm not in ("naive", "optimal_cart", "optimal_quantile"):
170
+ raise ValueError(
171
+ f"binning_method must be one of 'naive', 'optimal_cart', "
172
+ f"'optimal_quantile', or 'optimal' (alias of cart); got {_bm!r}"
173
+ )
174
+ self.binning_method = _bm
162
175
  self.naive_bins = naive_bins
163
176
  self.max_expansion_hops = max(0, int(max_expansion_hops))
164
177
  self.selection_metric = selection_metric
@@ -230,35 +243,42 @@ class StrategicSegmentBuilder:
230
243
  """
231
244
  priority = self.sort_priority
232
245
  if priority == "lift_count_rate":
233
- return (rule["lift"], rule["count"], rule["rate"])
246
+ key = (rule["lift"], rule["count"], rule["rate"])
234
247
  elif priority == "count_lift_rate":
235
- return (rule["count"], rule["lift"], rule["rate"])
248
+ key = (rule["count"], rule["lift"], rule["rate"])
236
249
  elif priority == "rate_lift_count":
237
- return (rule["rate"], rule["lift"], rule["count"])
250
+ key = (rule["rate"], rule["lift"], rule["count"])
238
251
  elif priority == "lift_rate_count":
239
- return (rule["lift"], rule["rate"], rule["count"])
252
+ key = (rule["lift"], rule["rate"], rule["count"])
240
253
  elif priority == "count_rate_lift":
241
- return (rule["count"], rule["rate"], rule["lift"])
254
+ key = (rule["count"], rule["rate"], rule["lift"])
242
255
  elif priority == "rate_count_lift":
243
- return (rule["rate"], rule["count"], rule["lift"])
256
+ key = (rule["rate"], rule["count"], rule["lift"])
244
257
  elif priority == "events_lift_rate":
245
- return (rule["events"], rule["lift"], rule["rate"])
258
+ key = (rule["events"], rule["lift"], rule["rate"])
246
259
  elif priority == "events_rate_lift":
247
- return (rule["events"], rule["rate"], rule["lift"])
260
+ key = (rule["events"], rule["rate"], rule["lift"])
248
261
  elif priority == "lift_events_rate":
249
- return (rule["lift"], rule["events"], rule["rate"])
262
+ key = (rule["lift"], rule["events"], rule["rate"])
250
263
  elif priority == "rate_events_lift":
251
- return (rule["rate"], rule["events"], rule["lift"])
264
+ key = (rule["rate"], rule["events"], rule["lift"])
252
265
  elif priority == "events_count_rate":
253
- return (rule["events"], rule["count"], rule["rate"])
266
+ key = (rule["events"], rule["count"], rule["rate"])
254
267
  elif priority == "events_rate_count":
255
- return (rule["events"], rule["rate"], rule["count"])
268
+ key = (rule["events"], rule["rate"], rule["count"])
256
269
  elif priority == "count_events_rate":
257
- return (rule["count"], rule["events"], rule["rate"])
270
+ key = (rule["count"], rule["events"], rule["rate"])
258
271
  elif priority == "rate_events_count":
259
- return (rule["rate"], rule["events"], rule["count"])
272
+ key = (rule["rate"], rule["events"], rule["count"])
260
273
  else:
261
- return (rule["lift"], rule["rate"], rule["count"])
274
+ key = (rule["lift"], rule["rate"], rule["count"])
275
+ # Deterministic tie-breaker: when every configured priority dimension
276
+ # is exactly equal between two candidates, the winner must not depend
277
+ # on incoming list order (which itself can vary run-to-run due to
278
+ # Python's per-process string hash randomization affecting set/dict
279
+ # iteration order, and unordered SQL GROUP BY results). Sorting on
280
+ # the rule string last makes ties resolve identically every run.
281
+ return key + (rule.get("rule", ""),)
262
282
 
263
283
  def compute_iv_ranking_and_bin(
264
284
  self,
@@ -380,14 +400,25 @@ class StrategicSegmentBuilder:
380
400
  # Transform bins array in DuckDB
381
401
  transformed_bins = thread_con.execute(
382
402
  f"""
383
- SELECT {case_expr} AS bin_label FROM current_df
403
+ SELECT
404
+ "__rs_row_id",
405
+ {case_expr} AS bin_label
406
+ FROM current_df
407
+ ORDER BY "__rs_row_id"
384
408
  """
385
409
  ).fetchnumpy()["bin_label"].astype(str)
386
410
 
387
411
  else:
388
412
  # Optimal binning fallback
389
413
  data_dict = thread_con.execute(
390
- f'SELECT "{col}", "{self.target}" FROM current_df'
414
+ f'''
415
+ SELECT
416
+ "__rs_row_id",
417
+ "{col}",
418
+ "{self.target}"
419
+ FROM current_df
420
+ ORDER BY "__rs_row_id"
421
+ '''
391
422
  ).fetchnumpy()
392
423
 
393
424
  col_arr_raw = data_dict[col]
@@ -396,10 +427,48 @@ class StrategicSegmentBuilder:
396
427
  col_arr = col_arr_raw.filled(np.nan if np.issubdtype(col_arr_raw.dtype, np.number) else None) if isinstance(col_arr_raw, np.ma.MaskedArray) else col_arr_raw
397
428
  target_arr = target_arr_raw.filled(0) if isinstance(target_arr_raw, np.ma.MaskedArray) else target_arr_raw
398
429
 
430
+ # Deterministic row order before CART prebinning.
431
+ # DuckDB SELECT has no ORDER BY; OptBinning's default prebinning
432
+ # (CART) can split differently when tied samples arrive in a
433
+ # different order, which changes bins/IV and cascades into
434
+ # different segments across runs.
435
+ #
436
+ # IMPORTANT: only the *fitting* input is sorted. The transform
437
+ # below must run on the ORIGINAL (unsorted) arrays so the
438
+ # returned bin labels stay aligned with "__rs_row_id" / the
439
+ # row order of current_df. Otherwise binned_df pairs the wrong
440
+ # target values with the wrong bin labels, corrupting both the
441
+ # event counts of 1-way rules and the row alignment of 2/3-way
442
+ # rules (which is what produced spurious candidates whose SQL
443
+ # re-validation then failed against the real table).
444
+ try:
445
+ col_arr = np.asarray(col_arr)
446
+ target_arr = np.asarray(target_arr)
447
+ if np.issubdtype(col_arr.dtype, np.number):
448
+ order = np.lexsort((
449
+ target_arr,
450
+ np.nan_to_num(col_arr.astype(float, copy=False), nan=np.inf),
451
+ ))
452
+ else:
453
+ col_as_str = col_arr.astype(str)
454
+ order = np.lexsort((target_arr, col_as_str))
455
+ col_fit = col_arr[order]
456
+ target_fit = target_arr[order]
457
+ except Exception:
458
+ col_fit = col_arr
459
+ target_fit = target_arr
460
+
399
461
  # Fit an Optimal Binning model to the current feature/target pair so we can
400
462
  # create monotonic, high-signal bins without relying on a fixed quantile grid.
401
- optb = OptimalBinning(name=col, dtype=dtype)
402
- optb.fit(col_arr, target_arr)
463
+ prebinning_method = (
464
+ "quantile" if self.binning_method == "optimal_quantile" else "cart"
465
+ )
466
+ optb = OptimalBinning(
467
+ name=col,
468
+ dtype=dtype,
469
+ prebinning_method=prebinning_method,
470
+ )
471
+ optb.fit(col_fit, target_fit)
403
472
 
404
473
  bin_table = optb.binning_table.build()
405
474
  iv_val = float(bin_table["IV"].values[-1])
@@ -469,11 +538,12 @@ class StrategicSegmentBuilder:
469
538
  ranking.append({"variable": col, "iv": iv, "max_rr": rr})
470
539
  if bins is not None:
471
540
  precomputed_bins[col] = bins
472
-
541
+ # Stable sort: primary metric descending, variable name ascending on ties
542
+ # so top_n_vars is identical across runs when scores collide.
473
543
  if self.selection_metric == "response_rate":
474
- ranking.sort(key=lambda x: x["max_rr"], reverse=True)
544
+ ranking.sort(key=lambda x: (-x["max_rr"], x["variable"]))
475
545
  else:
476
- ranking.sort(key=lambda x: x["iv"], reverse=True)
546
+ ranking.sort(key=lambda x: (-x["iv"], x["variable"]))
477
547
 
478
548
  return ranking, precomputed_bins
479
549
 
@@ -834,6 +904,11 @@ class StrategicSegmentBuilder:
834
904
  parts = [p.strip() for p in rule_str.split("&")]
835
905
  sql_conditions: List[str] = []
836
906
 
907
+ def _quote_sql_ident(ident: str) -> str:
908
+ # Quote every emitted column identifier so DuckDB reserved words
909
+ # (e.g. "default") and exotic column names do not break the predicate.
910
+ return '"' + str(ident).replace('"', '""') + '"'
911
+
837
912
  def _quote_sql_string(val: Any) -> str:
838
913
  txt = str(val)
839
914
  # escape single quotes for SQL and wrap in single quotes
@@ -908,10 +983,10 @@ class StrategicSegmentBuilder:
908
983
  range_conds = []
909
984
  if overall_lower.lower() != "-inf":
910
985
  op = ">=" if overall_lower_char == "[" else ">"
911
- range_conds.append(f"{col} {op} {overall_lower}")
986
+ range_conds.append(f"{_quote_sql_ident(col)} {op} {overall_lower}")
912
987
  if overall_upper.lower() != "inf":
913
988
  op = "<=" if overall_upper_char == "]" else "<"
914
- range_conds.append(f"{col} {op} {overall_upper}")
989
+ range_conds.append(f"{_quote_sql_ident(col)} {op} {overall_upper}")
915
990
 
916
991
  if range_conds:
917
992
  sql_conditions.append(" AND ".join(range_conds))
@@ -932,13 +1007,13 @@ class StrategicSegmentBuilder:
932
1007
  )
933
1008
  if is_single_nested:
934
1009
  cleaned = _strip_wrapping_quotes(interval[1:-1])
935
- sql_conditions.append(f"{col} = {_quote_sql_string(cleaned)}")
1010
+ sql_conditions.append(f"{_quote_sql_ident(col)} = {_quote_sql_string(cleaned)}")
936
1011
  continue
937
1012
  if cat_tokens:
938
1013
  cleaned_items = [_strip_wrapping_quotes(t) for t in cat_tokens]
939
1014
  formatted_items = ", ".join(_quote_sql_string(item) for item in cleaned_items if item)
940
1015
  if formatted_items:
941
- sql_conditions.append(f"{col} IN ({formatted_items})")
1016
+ sql_conditions.append(f"{_quote_sql_ident(col)} IN ({formatted_items})")
942
1017
  continue
943
1018
 
944
1019
  # 2. Explicit Categoricals
@@ -988,12 +1063,12 @@ class StrategicSegmentBuilder:
988
1063
  ]
989
1064
  )
990
1065
  if formatted_items:
991
- sql_conditions.append(f"{col} IN ({formatted_items})")
1066
+ sql_conditions.append(f"{_quote_sql_ident(col)} IN ({formatted_items})")
992
1067
  continue
993
-
1068
+
994
1069
  # 3. Special or Missing values
995
1070
  if interval in ["Special", "Missing"]:
996
- sql_conditions.append(f"{col} IS NULL")
1071
+ sql_conditions.append(f"{_quote_sql_ident(col)} IS NULL")
997
1072
  continue
998
1073
 
999
1074
  # 4. Standard Intervals [lo, hi) or Single Bracket Values [val]
@@ -1005,7 +1080,7 @@ class StrategicSegmentBuilder:
1005
1080
  raw_items = [_strip_wrapping_quotes(x) for x in inner.split(",") if x.strip()]
1006
1081
  formatted_items = ", ".join(_quote_sql_string(item) for item in raw_items)
1007
1082
  if formatted_items:
1008
- sql_conditions.append(f"{col} IN ({formatted_items})")
1083
+ sql_conditions.append(f"{_quote_sql_ident(col)} IN ({formatted_items})")
1009
1084
  continue
1010
1085
  left_char, right_char = interval[0], interval[-1]
1011
1086
  lower_str, upper_str = [x.strip() for x in inner.split(",", 1)]
@@ -1013,10 +1088,10 @@ class StrategicSegmentBuilder:
1013
1088
  range_conds = []
1014
1089
  if lower_str.lower() != "-inf":
1015
1090
  op = ">=" if left_char == "[" else ">"
1016
- range_conds.append(f"{col} {op} {lower_str}")
1091
+ range_conds.append(f"{_quote_sql_ident(col)} {op} {lower_str}")
1017
1092
  if upper_str.lower() != "inf":
1018
1093
  op = "<=" if right_char == "]" else "<"
1019
- range_conds.append(f"{col} {op} {upper_str}")
1094
+ range_conds.append(f"{_quote_sql_ident(col)} {op} {upper_str}")
1020
1095
 
1021
1096
  if range_conds:
1022
1097
  sql_conditions.append(" AND ".join(range_conds))
@@ -1025,11 +1100,11 @@ class StrategicSegmentBuilder:
1025
1100
  clean_val = _strip_wrapping_quotes(inner)
1026
1101
 
1027
1102
  if col_is_categorical:
1028
- sql_conditions.append(f"{col} = {_quote_sql_string(clean_val)}")
1103
+ sql_conditions.append(f"{_quote_sql_ident(col)} = {_quote_sql_string(clean_val)}")
1029
1104
  elif clean_val.replace(".", "", 1).replace("-", "", 1).isdigit():
1030
- sql_conditions.append(f"{col} = {clean_val}")
1105
+ sql_conditions.append(f"{_quote_sql_ident(col)} = {clean_val}")
1031
1106
  else:
1032
- sql_conditions.append(f"{col} = {_quote_sql_string(clean_val)}")
1107
+ sql_conditions.append(f"{_quote_sql_ident(col)} = {_quote_sql_string(clean_val)}")
1033
1108
 
1034
1109
  return " AND ".join(f"({cond})" for cond in sql_conditions)
1035
1110
 
@@ -1069,14 +1144,17 @@ class StrategicSegmentBuilder:
1069
1144
  con.execute(f"SET memory_limit = '{target_memory_gb}GB';")
1070
1145
  if db_temp_dir:
1071
1146
  con.execute(f"PRAGMA temp_directory='{db_temp_dir}';")
1072
- con.execute("SET preserve_insertion_order = false;")
1147
+ # con.execute("SET preserve_insertion_order = false;")
1073
1148
 
1074
1149
  logger.info(
1075
1150
  f"⚙️ DuckDB Configured for Disk Spilling: Threads={target_threads}/{total_cores}, "
1076
1151
  f"MemoryLimit={target_memory_gb}GB, TempDir={self.db_temp_dir}"
1077
1152
  )
1078
1153
  logger.info(f"📊 Sort priority: {self.sort_priority}")
1079
- logger.info(f"📦 Binning method: {self.binning_method} (naive_bins={self.naive_bins})")
1154
+ logger.info(
1155
+ f"📦 Binning method: {self.binning_method}"
1156
+ + (f" (naive_bins={self.naive_bins})" if self.binning_method == "naive" else "")
1157
+ )
1080
1158
  try:
1081
1159
  con.register("input_data_view", data)
1082
1160
  # Cast the target to DOUBLE up front so boolean targets work with AVG,
@@ -1089,9 +1167,15 @@ class StrategicSegmentBuilder:
1089
1167
  f"Available columns: {sorted(probe_cols)}"
1090
1168
  )
1091
1169
  con.execute(
1092
- f'CREATE OR REPLACE TABLE current_df AS '
1093
- f'SELECT * REPLACE (CAST("{self.target}" AS DOUBLE) AS "{self.target}") '
1094
- f'FROM input_data_view'
1170
+ f'''
1171
+ CREATE OR REPLACE TABLE current_df AS
1172
+ SELECT
1173
+ ROW_NUMBER() OVER () AS "__rs_row_id",
1174
+ * REPLACE (
1175
+ CAST("{self.target}" AS DOUBLE) AS "{self.target}"
1176
+ )
1177
+ FROM input_data_view
1178
+ '''
1095
1179
  )
1096
1180
 
1097
1181
  cols_info = con.execute("DESCRIBE current_df").fetchall()
@@ -1107,9 +1191,15 @@ class StrategicSegmentBuilder:
1107
1191
  if self.enable_diversity:
1108
1192
  self._validate_feature_groups(all_cols)
1109
1193
 
1110
- eligible_cols = [
1111
- c for c in all_cols if c != self.target and c not in self.ignore_features
1112
- ]
1194
+ eligible_cols = sorted(
1195
+ c
1196
+ for c in all_cols
1197
+ if (
1198
+ c != self.target
1199
+ and c != "__rs_row_id"
1200
+ and c not in self.ignore_features
1201
+ )
1202
+ )
1113
1203
  self.feature_usage_counts = {col: 0 for col in eligible_cols}
1114
1204
 
1115
1205
  if self.param_grid:
@@ -1211,7 +1301,11 @@ class StrategicSegmentBuilder:
1211
1301
  break
1212
1302
 
1213
1303
  raw_target_arr = con.execute(
1214
- f'SELECT "{self.target}" FROM current_df'
1304
+ f'''
1305
+ SELECT "{self.target}"
1306
+ FROM current_df
1307
+ ORDER BY "__rs_row_id"
1308
+ '''
1215
1309
  ).fetchnumpy()[self.target]
1216
1310
 
1217
1311
  if isinstance(raw_target_arr, np.ma.MaskedArray):
@@ -1231,14 +1325,16 @@ class StrategicSegmentBuilder:
1231
1325
  break
1232
1326
 
1233
1327
  con.execute("DROP TABLE IF EXISTS binned_df")
1234
- # Materialize binned_data via a pandas DataFrame and register it with DuckDB
1235
- try:
1236
- binned_df_pd = pd.DataFrame(binned_data)
1237
- con.register("binned_data_df", binned_df_pd)
1238
- con.execute("CREATE TABLE binned_df AS SELECT * FROM binned_data_df")
1239
- except Exception:
1240
- # Fallback to previous direct table creation if DataFrame registration fails
1241
- con.execute("CREATE TABLE binned_df AS SELECT * FROM binned_data")
1328
+
1329
+ con.register("binned_data_view", binned_data)
1330
+
1331
+ con.execute("""
1332
+ CREATE TABLE binned_df AS
1333
+ SELECT *
1334
+ FROM binned_data_view
1335
+ """)
1336
+
1337
+ con.unregister("binned_data_view")
1242
1338
 
1243
1339
  global_min_sample = (
1244
1340
  min(exp["min_sample_size"] for exp in experiments)
@@ -1269,7 +1365,7 @@ class StrategicSegmentBuilder:
1269
1365
  # Level 2 (Pairs)
1270
1366
  valid_2way_sets = set()
1271
1367
  if len(valid_1way_vars) >= 2 and (self.enable_2way or self.enable_3way):
1272
- combos_2 = [c for c in combinations(valid_1way_vars, 2) if self.is_diverse(c)]
1368
+ combos_2 = [c for c in combinations(sorted(valid_1way_vars), 2) if self.is_diverse(c)]
1273
1369
  if combos_2:
1274
1370
  res_2 = self._agg_combinations(con, combos_2, original_base_rate)
1275
1371
  if res_2:
@@ -1280,7 +1376,7 @@ class StrategicSegmentBuilder:
1280
1376
  # Level 3 (Triplets)
1281
1377
  if self.enable_3way and len(valid_1way_vars) >= 3 and valid_2way_sets:
1282
1378
  combos_3 = [
1283
- c for c in combinations(valid_1way_vars, 3)
1379
+ c for c in combinations(sorted(valid_1way_vars), 3)
1284
1380
  if self.is_diverse(c) and all(frozenset(p) in valid_2way_sets for p in combinations(c, 2))
1285
1381
  ]
1286
1382
  if combos_3:
@@ -1305,6 +1401,10 @@ class StrategicSegmentBuilder:
1305
1401
  grid_candidates.append(top_match)
1306
1402
 
1307
1403
  if not grid_candidates:
1404
+ self.diagnostics_[-1]["candidate_funnel"] = {
1405
+ "total_candidates_before_grid": len(all_candidate_rules),
1406
+ "candidates_after_grid": 0,
1407
+ }
1308
1408
  self.stop_reason = "No candidate rules met the minimum grid thresholds (sample size / lift)."
1309
1409
  logger.info("⏹️ No candidates cleared the grid. Stopping.")
1310
1410
  break
@@ -1864,4 +1964,4 @@ class StrategicSegmentBuilder:
1864
1964
  })
1865
1965
 
1866
1966
  health_report_df = pd.DataFrame(rows)
1867
- return health_report_df
1967
+ return health_report_df
File without changes
File without changes