arborenum 0.2.1__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.1
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.1"
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()
@@ -727,6 +752,13 @@ class ArborEnum:
727
752
  eta_defer=eta_defer,
728
753
  )
729
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
+
730
762
  return self.fit_prepared(
731
763
  lambda_reg=lambda_reg,
732
764
  depth_budget=depth_budget,
@@ -1690,6 +1722,13 @@ class ArborEnum:
1690
1722
  thresh = round((1.0 + mult) * min_obj)
1691
1723
  return sum(cnt for obj, cnt in hist if obj <= thresh)
1692
1724
 
1725
+ def alternating_optimization(self, max_iterations=10):
1726
+ return int(
1727
+ self._model.alternating_optimization(
1728
+ int(max_iterations)
1729
+ )
1730
+ )
1731
+
1693
1732
  def get_continuous_starts(self):
1694
1733
  # return the internal column index at which each continuous threshold group begins.
1695
1734
  return list(self._model.get_continuous_starts())
@@ -1976,6 +2015,458 @@ class ArborEnum:
1976
2015
  )
1977
2016
 
1978
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
+
1979
2470
 
1980
2471
  def plot_tree(self, tree_index: int, feature_names=None, figsize=(8, 6), ax=None, title=None, show=True):
1981
2472
  paths, preds = self.get_tree_paths(tree_index)
@@ -2396,6 +2887,8 @@ class ArborEnum:
2396
2887
  lookahead_k=1,
2397
2888
  seed=0,
2398
2889
  memory_efficient=False,
2890
+ key_mode="hash",
2891
+ trie_cache_enabled=True,
2399
2892
  proxy_refinement="auto",
2400
2893
  refinement_width=1,
2401
2894
  max_refinement_rounds=-1,
@@ -2413,6 +2906,7 @@ class ArborEnum:
2413
2906
  eta_defer=0.0,
2414
2907
  bb_pred=None,
2415
2908
  return_joint_samples=False,
2909
+ lossless=False,
2416
2910
  ):
2417
2911
 
2418
2912
  if hasattr(X, "columns"):
@@ -2607,6 +3101,8 @@ class ArborEnum:
2607
3101
  int(lookahead_k),
2608
3102
  int(seed),
2609
3103
  bool(memory_efficient),
3104
+ parse_key_mode(key_mode),
3105
+ bool(trie_cache_enabled),
2610
3106
  bool(early_stopping),
2611
3107
  int(refinement_width),
2612
3108
  int(max_refinement_rounds),
@@ -2629,6 +3125,7 @@ class ArborEnum:
2629
3125
  float(eta_defer),
2630
3126
  bb_pred_vec,
2631
3127
  bool(return_joint_samples),
3128
+ bool(lossless),
2632
3129
  )
2633
3130
 
2634
3131
  if (
@@ -2658,11 +3155,14 @@ class ArborEnum:
2658
3155
  lookahead_k=1,
2659
3156
  seed=0,
2660
3157
  memory_efficient=False,
3158
+ key_mode="hash",
3159
+ trie_cache_enabled=True,
2661
3160
  binning_map=None,
2662
3161
  use_deferral=False,
2663
3162
  eta_defer=0.0,
2664
3163
  bb_pred=None,
2665
3164
  return_joint_samples=False,
3165
+ lossless=False,
2666
3166
  ):
2667
3167
  X = np.asarray(X, dtype=np.uint8)
2668
3168
  y = np.asarray(y, dtype=int)
@@ -2721,11 +3221,14 @@ class ArborEnum:
2721
3221
  int(lookahead_k),
2722
3222
  int(seed),
2723
3223
  bool(memory_efficient),
3224
+ parse_key_mode(key_mode),
3225
+ bool(trie_cache_enabled),
2724
3226
  binning_map,
2725
3227
  bool(use_deferral),
2726
3228
  float(eta_defer),
2727
3229
  bb_pred_vec,
2728
3230
  bool(return_joint_samples),
3231
+ bool(lossless),
2729
3232
  )
2730
3233
 
2731
3234
  if (
@@ -2760,6 +3263,8 @@ class ArborEnum:
2760
3263
  lookahead_k=1,
2761
3264
  seed=0,
2762
3265
  memory_efficient=False,
3266
+ key_mode="hash",
3267
+ trie_cache_enabled=True,
2763
3268
  use_anytime_fit=False,
2764
3269
  refinement_width=1,
2765
3270
  max_refinement_rounds=-1,
@@ -2782,6 +3287,7 @@ class ArborEnum:
2782
3287
  eta_defer=0.0,
2783
3288
  bb_pred=None,
2784
3289
  return_joint_samples=False,
3290
+ lossless=False,
2785
3291
  ):
2786
3292
  X_num = np.asarray(X_num, dtype=np.float64)
2787
3293
  y = np.asarray(y, dtype=int)
@@ -2960,6 +3466,8 @@ class ArborEnum:
2960
3466
  int(lookahead_k),
2961
3467
  int(seed),
2962
3468
  bool(memory_efficient),
3469
+ parse_key_mode(key_mode),
3470
+ bool(trie_cache_enabled),
2963
3471
  bool(use_anytime_fit),
2964
3472
  int(refinement_width),
2965
3473
  int(max_refinement_rounds),
@@ -2984,6 +3492,7 @@ class ArborEnum:
2984
3492
  float(eta_defer),
2985
3493
  bb_pred_vec,
2986
3494
  bool(return_joint_samples),
3495
+ bool(lossless),
2987
3496
  )
2988
3497
 
2989
3498
  if (