arborenum 0.2.0__tar.gz → 0.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: arborenum
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Exact, approximate, and anytime decision tree Rashomon sets over binary and continuous features
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arborenum"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Exact, approximate, and anytime decision tree Rashomon sets over binary and continuous features"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -10,10 +10,35 @@ from ._core import (
10
10
  )
11
11
  from ._threshold_guessing import ThresholdGuessBinarizer
12
12
 
13
+ import json
14
+ from pathlib import Path
15
+ from importlib.resources import files
16
+
13
17
  DEFER_PREDICTION = -1
14
18
 
15
19
  __all__ = ["ArborEnum", "ThresholdGuessBinarizer", "DEFER_PREDICTION"]
16
20
 
21
+ def _json_safe(x):
22
+ if isinstance(x, np.integer):
23
+ return int(x)
24
+
25
+ if isinstance(x, np.floating):
26
+ return float(x)
27
+
28
+ if isinstance(x, np.ndarray):
29
+ return x.tolist()
30
+
31
+ if isinstance(x, dict):
32
+ return {
33
+ str(k): _json_safe(v)
34
+ for k, v in x.items()
35
+ }
36
+
37
+ if isinstance(x, (list, tuple)):
38
+ return [_json_safe(v) for v in x]
39
+
40
+ return x
41
+
17
42
  def _normalize_key(s: str) -> str:
18
43
  # lower, trim, and make separators uniform
19
44
  s = str(s).strip().lower()
@@ -311,6 +336,37 @@ def _as_2d_numeric_array(X, name):
311
336
 
312
337
  return X
313
338
 
339
+ def _shift_column_values_to_midpoints(column):
340
+ # replace each observed value except the maximum with the midpoint
341
+ # between it and the next distinct observed value.
342
+ # used for training because splits are directly on unique values in the cpp
343
+
344
+ column = np.asarray(column, dtype=np.float64)
345
+ unique = np.unique(column)
346
+
347
+ if unique.size <= 1:
348
+ return column
349
+
350
+ left = unique[:-1]
351
+ right = unique[1:]
352
+
353
+ midpoints = left + 0.5 * (right - left)
354
+
355
+ valid = (
356
+ np.isfinite(midpoints)
357
+ & (midpoints > left)
358
+ & (midpoints < right)
359
+ )
360
+
361
+ shifted_unique = unique.copy()
362
+ shifted_unique[:-1] = np.where(
363
+ valid,
364
+ midpoints,
365
+ left,
366
+ )
367
+
368
+ positions = np.searchsorted(unique, column)
369
+ return shifted_unique[positions]
314
370
 
315
371
  def _split_binary_and_continuous(
316
372
  X,
@@ -331,8 +387,26 @@ def _split_binary_and_continuous(
331
387
  if unique.size <= 1:
332
388
  continue
333
389
 
390
+ left = unique[:-1]
391
+ right = unique[1:]
392
+
393
+ thresholds = left + 0.5 * (right - left)
394
+
395
+ valid = (
396
+ np.isfinite(thresholds)
397
+ & (thresholds > left)
398
+ & (thresholds < right)
399
+ )
400
+
401
+ thresholds = np.where(
402
+ valid,
403
+ thresholds,
404
+ left,
405
+ )
406
+
334
407
  if unique.size <= binary_unique_threshold:
335
- for threshold in unique[:-1]:
408
+ # python directly creates the midpoint threshold columns for low cardinality features
409
+ for threshold in thresholds:
336
410
  binary_columns.append(
337
411
  (column <= threshold).astype(np.uint8)
338
412
  )
@@ -343,9 +417,19 @@ def _split_binary_and_continuous(
343
417
  "cutpoint": float(threshold),
344
418
  }
345
419
  )
420
+
346
421
  else:
422
+ # shift each observed level so the existing C++ threshold
423
+ # generation (split on unique values) produces the midpoint cutpoints.
424
+ shifted_column = _shift_column_values_to_midpoints(
425
+ column
426
+ )
427
+
347
428
  numerical_columns.append(
348
- column.astype(np.float64, copy=False)
429
+ shifted_column.astype(
430
+ np.float64,
431
+ copy=False,
432
+ )
349
433
  )
350
434
 
351
435
  numerical_feature_indices.append(j)
@@ -668,6 +752,13 @@ class ArborEnum:
668
752
  eta_defer=eta_defer,
669
753
  )
670
754
 
755
+ self.lambda_reg_ = float(lambda_reg)
756
+ self.depth_budget_ = int(depth_budget)
757
+ self.rashomon_mult_ = float(rashomon_mult)
758
+ self.multiplicative_slack_ = float(multiplicative_slack)
759
+ self.lookahead_k_ = int(lookahead_k)
760
+ self.eta_defer_ = float(eta_defer)
761
+
671
762
  return self.fit_prepared(
672
763
  lambda_reg=lambda_reg,
673
764
  depth_budget=depth_budget,
@@ -1631,6 +1722,13 @@ class ArborEnum:
1631
1722
  thresh = round((1.0 + mult) * min_obj)
1632
1723
  return sum(cnt for obj, cnt in hist if obj <= thresh)
1633
1724
 
1725
+ def alternating_optimization(self, max_iterations=10):
1726
+ return int(
1727
+ self._model.alternating_optimization(
1728
+ int(max_iterations)
1729
+ )
1730
+ )
1731
+
1634
1732
  def get_continuous_starts(self):
1635
1733
  # return the internal column index at which each continuous threshold group begins.
1636
1734
  return list(self._model.get_continuous_starts())
@@ -1917,6 +2015,458 @@ class ArborEnum:
1917
2015
  )
1918
2016
 
1919
2017
  return names
2018
+
2019
+ def export_andor_graph(self, as_dict=True):
2020
+ g = self._model.export_andor_graph()
2021
+
2022
+ if not as_dict:
2023
+ return g
2024
+
2025
+ return {
2026
+ "root_trie_id": int(g.root_trie_id),
2027
+
2028
+ "trie_nodes": [
2029
+ {
2030
+ "id": int(node.id),
2031
+ "budget": int(node.budget),
2032
+ "min_objective": int(node.min_objective),
2033
+ "subproblem_size": int(
2034
+ node.subproblem_size
2035
+ ),
2036
+ "leaf_ids": [
2037
+ int(x)
2038
+ for x in node.leaf_ids
2039
+ ],
2040
+ "split_ids": [
2041
+ int(x)
2042
+ for x in node.split_ids
2043
+ ],
2044
+ }
2045
+ for node in g.trie_nodes
2046
+ ],
2047
+
2048
+ "split_nodes": [
2049
+ {
2050
+ "id": int(split.id),
2051
+ "parent_trie_id": int(
2052
+ split.parent_trie_id
2053
+ ),
2054
+ "feature": int(split.feature),
2055
+ "left_trie_id": int(
2056
+ split.left_trie_id
2057
+ ),
2058
+ "right_trie_id": int(
2059
+ split.right_trie_id
2060
+ ),
2061
+ "min_objective": int(
2062
+ split.min_objective
2063
+ ),
2064
+ }
2065
+ for split in g.split_nodes
2066
+ ],
2067
+
2068
+ "leaf_nodes": [
2069
+ {
2070
+ "id": int(leaf.id),
2071
+ "parent_trie_id": int(
2072
+ leaf.parent_trie_id
2073
+ ),
2074
+ "prediction": int(
2075
+ leaf.prediction
2076
+ ),
2077
+ "loss": int(leaf.loss),
2078
+ "subproblem_size": int(
2079
+ leaf.subproblem_size
2080
+ ),
2081
+ }
2082
+ for leaf in g.leaf_nodes
2083
+ ],
2084
+ }
2085
+
2086
+ def _get_builder_feature_metadata(
2087
+ self,
2088
+ feature_names=None,
2089
+ ):
2090
+ if self.binary_feature_specs_ is None:
2091
+ raise RuntimeError(
2092
+ "Feature metadata is unavailable. "
2093
+ "Fit ArborEnum through fit(X, y, ...) first."
2094
+ )
2095
+
2096
+ if self.continuous_feature_indices_ is None:
2097
+ raise RuntimeError(
2098
+ "Continuous feature metadata is unavailable."
2099
+ )
2100
+
2101
+ if self.n_features_in_ is None:
2102
+ raise RuntimeError(
2103
+ "Original feature count is unavailable."
2104
+ )
2105
+
2106
+ if feature_names is None:
2107
+ feature_names = list(self.feature_names_in_)
2108
+ else:
2109
+ feature_names = list(feature_names)
2110
+
2111
+ if len(feature_names) != self.n_features_in_:
2112
+ raise ValueError(
2113
+ "feature_names must contain one name for "
2114
+ "every original input feature."
2115
+ )
2116
+
2117
+ # exactly aligned with our internal ids
2118
+ internal_feature_names = (
2119
+ self.get_internal_feature_names(feature_names)
2120
+ )
2121
+
2122
+ thresholds = {}
2123
+ feature_registry = []
2124
+
2125
+ # low cardinality features
2126
+ for internal_feature, spec in enumerate(
2127
+ self.binary_feature_specs_
2128
+ ):
2129
+ original_feature = int(
2130
+ spec["original_feature"]
2131
+ )
2132
+
2133
+ cutpoint = float(spec["cutpoint"])
2134
+
2135
+ thresholds[internal_feature] = cutpoint
2136
+
2137
+ feature_registry.append(
2138
+ {
2139
+ "internalFeature": internal_feature,
2140
+ "originalFeature": original_feature,
2141
+ "originalName": str(
2142
+ feature_names[original_feature]
2143
+ ),
2144
+ "threshold": cutpoint,
2145
+ "kind": "binary_threshold",
2146
+ "continuousGroup": None,
2147
+ }
2148
+ )
2149
+
2150
+ # cpp generated continuous groups
2151
+ continuous_groups = {}
2152
+
2153
+ starts = [
2154
+ int(x)
2155
+ for x in self._model.get_continuous_starts()
2156
+ ]
2157
+
2158
+ for group, original_feature in enumerate(
2159
+ self.continuous_feature_indices_
2160
+ ):
2161
+ original_feature = int(original_feature)
2162
+
2163
+ cutpoints = [
2164
+ float(x)
2165
+ for x in self.get_continuous_cutpoints(group)
2166
+ ]
2167
+
2168
+ start = starts[group]
2169
+
2170
+ internal_columns = []
2171
+
2172
+ for offset, cutpoint in enumerate(cutpoints):
2173
+ internal_feature = start + offset
2174
+
2175
+ thresholds[internal_feature] = cutpoint
2176
+ internal_columns.append(internal_feature)
2177
+
2178
+ feature_registry.append(
2179
+ {
2180
+ "internalFeature": internal_feature,
2181
+ "originalFeature": original_feature,
2182
+ "originalName": str(
2183
+ feature_names[original_feature]
2184
+ ),
2185
+ "threshold": cutpoint,
2186
+ "kind": "continuous_threshold",
2187
+ "continuousGroup": group,
2188
+ }
2189
+ )
2190
+
2191
+ continuous_groups[
2192
+ str(feature_names[original_feature])
2193
+ ] = internal_columns
2194
+
2195
+ # feature_registry[i] describes split.feature == i. this should already be the case by how we went over.
2196
+ feature_registry.sort(
2197
+ key=lambda x: x["internalFeature"]
2198
+ )
2199
+
2200
+ for expected, entry in enumerate(feature_registry):
2201
+ if entry["internalFeature"] != expected:
2202
+ raise RuntimeError(
2203
+ "Builder feature metadata is not aligned "
2204
+ "with ArborEnum internal feature indices: "
2205
+ f"expected {expected}, got "
2206
+ f"{entry['internalFeature']}."
2207
+ )
2208
+
2209
+ if len(internal_feature_names) != len(
2210
+ feature_registry
2211
+ ):
2212
+ raise RuntimeError(
2213
+ "Internal feature-name count does not match "
2214
+ "the builder feature registry."
2215
+ )
2216
+
2217
+ if len(starts) != len(
2218
+ self.continuous_feature_indices_
2219
+ ):
2220
+ raise RuntimeError(
2221
+ "Continuous-group metadata is inconsistent: "
2222
+ "the number of C++ continuous groups does not "
2223
+ "match continuous_feature_indices_."
2224
+ )
2225
+
2226
+
2227
+ # feature_registry says for each threshold
2228
+ '''
2229
+ internalFeature: 0,
2230
+ originalFeature: 1,
2231
+ originalName: sex,
2232
+ threshold: 0.5,
2233
+ kind: binary_threshold,
2234
+ continuousGroup: None,
2235
+ '''
2236
+
2237
+ return {
2238
+ "featureRegistry": feature_registry,
2239
+ }
2240
+
2241
+
2242
+ def save_builder_payload(
2243
+ self,
2244
+ path,
2245
+ *,
2246
+ feature_names=None,
2247
+ feature_descriptions=None,
2248
+ lambda_reg=None,
2249
+ depth_budget=None,
2250
+ rashomon_mult=None,
2251
+ multiplicative_slack=None,
2252
+ lookahead_k=None,
2253
+ root_n=None,
2254
+ gamma=None,
2255
+ eta_defer=None,
2256
+ indent=2,
2257
+ ):
2258
+ graph = self.export_andor_graph(
2259
+ as_dict=True
2260
+ )
2261
+
2262
+ feature_meta = (
2263
+ self._get_builder_feature_metadata(
2264
+ feature_names=feature_names
2265
+ )
2266
+ )
2267
+
2268
+ if lambda_reg is None:
2269
+ lambda_reg = getattr(
2270
+ self,
2271
+ "lambda_reg_",
2272
+ None,
2273
+ )
2274
+
2275
+ if depth_budget is None:
2276
+ depth_budget = getattr(
2277
+ self,
2278
+ "depth_budget_",
2279
+ None,
2280
+ )
2281
+
2282
+ if rashomon_mult is None:
2283
+ rashomon_mult = getattr(
2284
+ self,
2285
+ "rashomon_mult_",
2286
+ None,
2287
+ )
2288
+
2289
+ if multiplicative_slack is None:
2290
+ multiplicative_slack = getattr(
2291
+ self,
2292
+ "multiplicative_slack_",
2293
+ None,
2294
+ )
2295
+
2296
+ if lookahead_k is None:
2297
+ lookahead_k = getattr(
2298
+ self,
2299
+ "lookahead_k_",
2300
+ None,
2301
+ )
2302
+
2303
+ if eta_defer is None:
2304
+ eta_defer = getattr(
2305
+ self,
2306
+ "eta_defer_",
2307
+ None,
2308
+ )
2309
+
2310
+ meta = {
2311
+ **feature_meta,
2312
+
2313
+ "featureDescriptions": feature_descriptions,
2314
+
2315
+ "lambda_reg": lambda_reg,
2316
+ "depth_budget": depth_budget,
2317
+ "rashomon_mult": rashomon_mult,
2318
+ "multiplicative_slack": multiplicative_slack,
2319
+ "lookahead_k": lookahead_k,
2320
+ "root_n": root_n,
2321
+ "gamma": gamma,
2322
+ "eta_defer": eta_defer,
2323
+ }
2324
+
2325
+ root_id = int(
2326
+ graph.get("root_trie_id", 0)
2327
+ )
2328
+
2329
+ root = next(
2330
+ (
2331
+ node
2332
+ for node in graph.get(
2333
+ "trie_nodes",
2334
+ [],
2335
+ )
2336
+ if int(node.get("id", -1)) == root_id
2337
+ ),
2338
+ None,
2339
+ )
2340
+
2341
+ if root is not None:
2342
+ meta["root_budget"] = root.get(
2343
+ "budget"
2344
+ )
2345
+
2346
+ meta["root_min_objective"] = root.get(
2347
+ "min_objective"
2348
+ )
2349
+
2350
+ meta["root_subproblem_size"] = root.get(
2351
+ "subproblem_size"
2352
+ )
2353
+
2354
+ if root_n is None:
2355
+ meta["root_n"] = root.get(
2356
+ "subproblem_size"
2357
+ )
2358
+
2359
+ if (
2360
+ gamma is None
2361
+ and lambda_reg is not None
2362
+ and meta.get("root_n") is not None
2363
+ ):
2364
+ meta["gamma"] = int(
2365
+ round(
2366
+ float(lambda_reg)
2367
+ * int(meta["root_n"])
2368
+ )
2369
+ )
2370
+
2371
+ payload = {
2372
+ "graph": graph,
2373
+ "meta": {
2374
+ k: v
2375
+ for k, v in meta.items()
2376
+ if v is not None
2377
+ },
2378
+ }
2379
+
2380
+ path = Path(path)
2381
+
2382
+ path.parent.mkdir(
2383
+ parents=True,
2384
+ exist_ok=True,
2385
+ )
2386
+
2387
+ path.write_text(
2388
+ json.dumps(
2389
+ _json_safe(payload),
2390
+ indent=indent,
2391
+ ),
2392
+ encoding="utf-8",
2393
+ )
2394
+
2395
+ return path
2396
+
2397
+ def save_builder_html(
2398
+ self,
2399
+ path,
2400
+ **kwargs,
2401
+ ):
2402
+ payload_path = (
2403
+ Path(path).with_suffix(
2404
+ ".payload.json"
2405
+ )
2406
+ )
2407
+
2408
+ self.save_builder_payload(
2409
+ payload_path,
2410
+ **kwargs,
2411
+ )
2412
+
2413
+ payload = json.loads(
2414
+ payload_path.read_text(
2415
+ encoding="utf-8"
2416
+ )
2417
+ )
2418
+
2419
+ payload_path.unlink(
2420
+ missing_ok=True
2421
+ )
2422
+
2423
+ payload_json = json.dumps(payload)
2424
+
2425
+ # prevent accidental closing of the script tag
2426
+ payload_json = payload_json.replace(
2427
+ "</",
2428
+ "<\\/",
2429
+ )
2430
+
2431
+ html = (
2432
+ files(__package__)
2433
+ .joinpath(
2434
+ "builder_static/index.html"
2435
+ )
2436
+ .read_text(
2437
+ encoding="utf-8"
2438
+ )
2439
+ )
2440
+
2441
+ inject = f"""
2442
+ <script>
2443
+ window.ARBORENUM_BUILDER_PAYLOAD = {payload_json};
2444
+ </script>
2445
+ """
2446
+
2447
+ if "</head>" in html:
2448
+ html = html.replace(
2449
+ "</head>",
2450
+ inject + "\n</head>",
2451
+ 1,
2452
+ )
2453
+ else:
2454
+ html = inject + html
2455
+
2456
+ path = Path(path)
2457
+
2458
+ path.parent.mkdir(
2459
+ parents=True,
2460
+ exist_ok=True,
2461
+ )
2462
+
2463
+ path.write_text(
2464
+ html,
2465
+ encoding="utf-8",
2466
+ )
2467
+
2468
+ return path
2469
+
1920
2470
 
1921
2471
  def plot_tree(self, tree_index: int, feature_names=None, figsize=(8, 6), ax=None, title=None, show=True):
1922
2472
  paths, preds = self.get_tree_paths(tree_index)
@@ -2337,6 +2887,8 @@ class ArborEnum:
2337
2887
  lookahead_k=1,
2338
2888
  seed=0,
2339
2889
  memory_efficient=False,
2890
+ key_mode="hash",
2891
+ trie_cache_enabled=True,
2340
2892
  proxy_refinement="auto",
2341
2893
  refinement_width=1,
2342
2894
  max_refinement_rounds=-1,
@@ -2354,6 +2906,7 @@ class ArborEnum:
2354
2906
  eta_defer=0.0,
2355
2907
  bb_pred=None,
2356
2908
  return_joint_samples=False,
2909
+ lossless=False,
2357
2910
  ):
2358
2911
 
2359
2912
  if hasattr(X, "columns"):
@@ -2548,6 +3101,8 @@ class ArborEnum:
2548
3101
  int(lookahead_k),
2549
3102
  int(seed),
2550
3103
  bool(memory_efficient),
3104
+ parse_key_mode(key_mode),
3105
+ bool(trie_cache_enabled),
2551
3106
  bool(early_stopping),
2552
3107
  int(refinement_width),
2553
3108
  int(max_refinement_rounds),
@@ -2570,6 +3125,7 @@ class ArborEnum:
2570
3125
  float(eta_defer),
2571
3126
  bb_pred_vec,
2572
3127
  bool(return_joint_samples),
3128
+ bool(lossless),
2573
3129
  )
2574
3130
 
2575
3131
  if (
@@ -2599,11 +3155,14 @@ class ArborEnum:
2599
3155
  lookahead_k=1,
2600
3156
  seed=0,
2601
3157
  memory_efficient=False,
3158
+ key_mode="hash",
3159
+ trie_cache_enabled=True,
2602
3160
  binning_map=None,
2603
3161
  use_deferral=False,
2604
3162
  eta_defer=0.0,
2605
3163
  bb_pred=None,
2606
3164
  return_joint_samples=False,
3165
+ lossless=False,
2607
3166
  ):
2608
3167
  X = np.asarray(X, dtype=np.uint8)
2609
3168
  y = np.asarray(y, dtype=int)
@@ -2662,11 +3221,14 @@ class ArborEnum:
2662
3221
  int(lookahead_k),
2663
3222
  int(seed),
2664
3223
  bool(memory_efficient),
3224
+ parse_key_mode(key_mode),
3225
+ bool(trie_cache_enabled),
2665
3226
  binning_map,
2666
3227
  bool(use_deferral),
2667
3228
  float(eta_defer),
2668
3229
  bb_pred_vec,
2669
3230
  bool(return_joint_samples),
3231
+ bool(lossless),
2670
3232
  )
2671
3233
 
2672
3234
  if (
@@ -2701,6 +3263,8 @@ class ArborEnum:
2701
3263
  lookahead_k=1,
2702
3264
  seed=0,
2703
3265
  memory_efficient=False,
3266
+ key_mode="hash",
3267
+ trie_cache_enabled=True,
2704
3268
  use_anytime_fit=False,
2705
3269
  refinement_width=1,
2706
3270
  max_refinement_rounds=-1,
@@ -2723,6 +3287,7 @@ class ArborEnum:
2723
3287
  eta_defer=0.0,
2724
3288
  bb_pred=None,
2725
3289
  return_joint_samples=False,
3290
+ lossless=False,
2726
3291
  ):
2727
3292
  X_num = np.asarray(X_num, dtype=np.float64)
2728
3293
  y = np.asarray(y, dtype=int)
@@ -2901,6 +3466,8 @@ class ArborEnum:
2901
3466
  int(lookahead_k),
2902
3467
  int(seed),
2903
3468
  bool(memory_efficient),
3469
+ parse_key_mode(key_mode),
3470
+ bool(trie_cache_enabled),
2904
3471
  bool(use_anytime_fit),
2905
3472
  int(refinement_width),
2906
3473
  int(max_refinement_rounds),
@@ -2925,6 +3492,7 @@ class ArborEnum:
2925
3492
  float(eta_defer),
2926
3493
  bb_pred_vec,
2927
3494
  bool(return_joint_samples),
3495
+ bool(lossless),
2928
3496
  )
2929
3497
 
2930
3498
  if (