SearchLibrium 0.0.126__tar.gz → 0.0.128__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.
Files changed (53) hide show
  1. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/PKG-INFO +141 -23
  2. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/README.md +137 -22
  3. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/pyproject.toml +4 -1
  4. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/__init__.py +2 -2
  5. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/constraints_builder.py +31 -0
  6. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/search.py +79 -2
  7. searchlibrium-0.0.128/src/SearchLibrium/version.txt +1 -0
  8. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/PKG-INFO +141 -23
  9. searchlibrium-0.0.126/src/SearchLibrium/version.txt +0 -1
  10. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/setup.cfg +0 -0
  11. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/Halton.py +0 -0
  12. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/MixedLogit.py +0 -0
  13. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  14. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/RandomP.py +0 -0
  15. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  16. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  17. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/__main__.py +0 -0
  18. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/_choice_model.py +0 -0
  19. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/_device.py +0 -0
  20. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/banditsa.py +0 -0
  21. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/bhhh/minimize.py +0 -0
  22. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/boxcox_functions.py +0 -0
  23. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/call_meta.py +0 -0
  24. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/harmony.py +0 -0
  25. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/hspbil.py +0 -0
  26. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/latent_class.py +0 -0
  27. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/main.py +0 -0
  28. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/main_debug.py +0 -0
  29. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/mdcev.py +0 -0
  30. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/misc.py +0 -0
  31. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/mixed_logit.py +0 -0
  32. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/mixed_nested.py +0 -0
  33. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/mixedrrm.py +0 -0
  34. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/multinomial_logit.py +0 -0
  35. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/multinomial_nested.py +0 -0
  36. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/multinomial_probit.py +0 -0
  37. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/ordered_logit.py +0 -0
  38. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  39. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/predict.py +0 -0
  40. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/rrm.py +0 -0
  41. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/sapbil.py +0 -0
  42. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/selection_models.py +0 -0
  43. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/setup.py +0 -0
  44. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/siman.py +0 -0
  45. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/test_lc_de.py +0 -0
  46. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/test_mario_searches.py +0 -0
  47. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  48. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium/threshold.py +0 -0
  49. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  50. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  51. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  52. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/requires.txt +0 -0
  53. {searchlibrium-0.0.126 → searchlibrium-0.0.128}/src/SearchLibrium.egg-info/top_level.txt +0 -0
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.126
3
+ Version: 0.0.128
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
7
7
  Project-URL: Homepage, https://pypi.org/project/SearchLibrium/
8
+ Project-URL: Repository, https://github.com/zahern/HypothesisX
9
+ Project-URL: Issues, https://github.com/zahern/HypothesisX/issues
10
+ Project-URL: Documentation, https://pypi.org/project/SearchLibrium/
8
11
  Keywords: econometric models,search,discrete choice,logit,probit
9
12
  Classifier: Development Status :: 4 - Beta
10
13
  Classifier: Intended Audience :: Science/Research
@@ -41,8 +44,9 @@ Requires-Dist: pytest; extra == "dev"
41
44
  [![Python](https://img.shields.io/pypi/pyversions/SearchLibrium.svg)](https://pypi.org/project/SearchLibrium/)
42
45
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
43
46
  [![CI](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml/badge.svg)](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml)
47
+ [![GitHub](https://img.shields.io/badge/GitHub-HypothesisX-lightgrey?logo=github)](https://github.com/zahern/HypothesisX)
44
48
 
45
- **Automated discrete choice model search powered by Simulated Annealing, Harmony Search, and JAX-accelerated MLE.**
49
+ **Automated discrete choice model search powered by Simulated Annealing, Bandit-guided SA, Harmony Search, SA+PBIL, HS+PBIL, Parallel SA, and JAX-accelerated MLE.**
46
50
 
47
51
  SearchLibrium searches over model specifications — which variables to include, whether parameters should be random, which transformations to apply, and which model class to use — and returns the best converged, all-significant model according to your chosen criterion (BIC, AIC, log-likelihood, MAE, or multi-objective combinations).
48
52
 
@@ -122,16 +126,19 @@ A **run dashboard** is printed automatically at the end of every search, showing
122
126
 
123
127
  ## How the search works
124
128
 
125
- The search uses **Simulated Annealing (SA)** to explore the space of model specifications:
129
+ The search uses **metaheuristic optimisation** (SA, BanditSA, Harmony Search,
130
+ SA+PBIL, HS+PBIL, or parallel variants) to explore the space of model
131
+ specifications:
126
132
 
127
133
  ```text
128
134
  generate starting solution
129
- └─ for each SA temperature step
135
+ └─ for each iteration / temperature step / improvisation
130
136
  └─ perturb current specification → guaranteed distinct from current
131
137
  ├─ fit model with JAX-accelerated MLE
132
138
  ├─ run backward elimination (remove insignificant vars, refit)
133
- ├─ accept if converged + Metropolis criterion satisfied
134
- └─ update best solution
139
+ ├─ accept if converged + acceptance criterion satisfied
140
+ │ (Metropolis, dominance, or pitch adjustment)
141
+ └─ update best solution / harmony memory
135
142
  print dashboard
136
143
  ```
137
144
 
@@ -433,27 +440,42 @@ python -m SearchLibrium --test_search_nest # run nested logit search
433
440
 
434
441
  ## Search algorithms
435
442
 
436
- Both algorithms share a **consistent interface** through `call_search`:
443
+ All algorithms share a **consistent interface**. The library auto-estimates
444
+ hyperparameters from problem size when you omit `ctrl`:
437
445
 
438
446
  ```python
439
- from SearchLibrium import call_search, estimate_ctrl
440
-
441
- # Auto-estimate hyperparameters from problem size (recommended)
442
- best = call_search(params) # SA by default
443
- best = call_search(params, algorithm='hs') # Harmony Search
444
-
445
- # Manual hyperparameters
446
- best = call_search(params, ctrl=(1000, 0.001, 100, 20)) # SA
447
- best = call_search(params, algorithm='hs',
448
- ctrl=(20, 500, 0.9, 0.6, 0.85, 0.3)) # HS
449
-
450
- # Inspect auto-estimated ctrl before running
447
+ from SearchLibrium import (call_search, call_siman, call_harmony, call_sapbil,
448
+ call_banditsa, call_harmony_pbil,
449
+ call_parsa, call_parcopsa, estimate_ctrl)
450
+
451
+ # Unified entry point auto hyperparameters (recommended)
452
+ best = call_search(params) # SA (default)
453
+ best = call_search(params, algorithm='hs') # Harmony Search
454
+ best = call_search(params, algorithm='sapbil') # SA + PBIL
455
+ best = call_search(params, algorithm='banditsa') # Bandit-guided SA
456
+ best = call_search(params, algorithm='hspbil') # HS + PBIL
457
+
458
+ # Inspect auto-estimated hyperparameters before running
451
459
  ctrl = estimate_ctrl(params, algorithm='sa')
452
460
  print(ctrl)
453
461
  ```
454
462
 
463
+ ### Algorithm reference
464
+
465
+ | Function | Description | Population-based | Key idea |
466
+ | -------- | ----------- | :---: | -------- |
467
+ | `call_siman` | Simulated Annealing | | Metropolis acceptance with temperature cooling |
468
+ | `call_banditsa` | Bandit-guided SA | | Thompson Sampling to choose perturbation actions |
469
+ | `call_sapbil` | SA + PBIL | ✓ | Probability matrix learns from accepted solutions to bias perturbations |
470
+ | `call_harmony` | Harmony Search | ✓ | Memory-based improvisation with pitch adjustment |
471
+ | `call_harmony_pbil` | HS + PBIL | ✓ | Harmony Search with PBIL-guided pitch perturbation |
472
+ | `call_parsa` | Parallel SA | | Multiple independent SA solvers running in parallel |
473
+ | `call_parcopsa` | Parallel Cooperative SA | ✓ | Parallel SA with periodic best-solution sharing |
474
+
455
475
  ### Simulated Annealing (`call_siman` / `algorithm='sa'`)
456
476
 
477
+ Control tuple: `(tI, tF, max_temp_steps, max_iter)`
478
+
457
479
  | Parameter | Meaning |
458
480
  | --------- | ------- |
459
481
  | `tI` | Initial temperature — higher → more exploration |
@@ -467,12 +489,14 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
467
489
 
468
490
  ### Harmony Search (`call_harmony` / `algorithm='hs'`)
469
491
 
492
+ Control tuple: `(max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)`
493
+
470
494
  | Parameter | Meaning |
471
495
  | --------- | ------- |
472
496
  | `max_mem` | Harmony memory size (population) |
473
497
  | `maxiter` | Improvisation iterations |
474
- | `max_harm` | Max harmony consideration rate |
475
- | `min_harm` | Min harmony consideration rate |
498
+ | `max_harm` | Max harmony memory consideration rate |
499
+ | `min_harm` | Min harmony memory consideration rate |
476
500
  | `max_pitch` | Max pitch adjustment rate |
477
501
  | `min_pitch` | Min pitch adjustment rate |
478
502
 
@@ -480,6 +504,44 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
480
504
  best = call_harmony(params, ctrl=(20, 400, 0.9, 0.6, 0.85, 0.3), id_num=1)
481
505
  ```
482
506
 
507
+ ### SA + PBIL (`call_sapbil` / `algorithm='sapbil'`)
508
+
509
+ Population-Based Incremental Learning coupled with Simulated Annealing.
510
+ Maintains a probability matrix over the search decisions (inclusion,
511
+ randomness, distribution, correlation, Box-Cox) and updates it from
512
+ accepted solutions using a temperature-coupled learning rate.
513
+
514
+ ```python
515
+ best = call_sapbil(params, id_num=1)
516
+ ```
517
+
518
+ ### HS + PBIL (`call_harmony_pbil` / `algorithm='hspbil'`)
519
+
520
+ Harmony Search with PBIL-guided pitch adjustment. Instead of uniform-random
521
+ perturbations during pitch adjustment, the PBIL probability matrix guides
522
+ add/remove decisions based on learned probabilities.
523
+
524
+ ```python
525
+ best = call_harmony_pbil(params, id_num=1)
526
+ ```
527
+
528
+ ### Bandit-guided SA (`call_banditsa` / `algorithm='banditsa'`)
529
+
530
+ Uses Thompson Sampling over a multi-armed bandit of perturbation actions
531
+ (add asvar, remove asvar, change distribution, ...). Actions that
532
+ historically led to accepted solutions are more likely to be chosen again.
533
+
534
+ ```python
535
+ best = call_banditsa(params, id_num=1)
536
+ ```
537
+
538
+ ### Parallel SA variants
539
+
540
+ ```python
541
+ best = call_parsa(params, nthrds=4) # 4 independent SA solvers
542
+ best = call_parcopsa(params, nthrds=8) # 8 SA solvers sharing best periodically
543
+ ```
544
+
483
545
  ### Auto hyperparameter estimation
484
546
 
485
547
  If `ctrl` is omitted, the library estimates appropriate defaults from the
@@ -489,8 +551,6 @@ problem complexity (`n_vars × n_alts × n_models`, doubled for random params):
489
551
  from SearchLibrium import estimate_ctrl
490
552
  ctrl_sa = estimate_ctrl(params, algorithm='sa')
491
553
  ctrl_hs = estimate_ctrl(params, algorithm='hs')
492
- print('SA ctrl:', ctrl_sa)
493
- print('HS ctrl:', ctrl_hs)
494
554
  ```
495
555
 
496
556
  Complexity buckets:
@@ -504,6 +564,64 @@ Complexity buckets:
504
564
 
505
565
 
506
566
 
567
+ ---
568
+
569
+ ## Constraints
570
+
571
+ Use `ConstraintBuilder` to enforce model structure rules during the search:
572
+
573
+ ```python
574
+ from SearchLibrium import Parameters, ConstraintBuilder, call_siman
575
+
576
+ constraints = ConstraintBuilder()
577
+
578
+ # ── Inclusion / exclusion ──
579
+ constraints.force_include('TIME', 'COST') # must always appear
580
+ constraints.never_include('ID', 'DUMMY') # must never appear
581
+
582
+ # ── Mutually exclusive groups ──
583
+ # At most ONE variable from each group may appear in any solution.
584
+ # The search will never add a partner if a group member is already
585
+ # present, and will remove extras if they slip through.
586
+ constraints.mutually_exclusive('SPEED', 'TIME') # speed OR time, never both
587
+ constraints.mutually_exclusive('LOG_INCOME', 'INCOME_DUMMY') # independent group
588
+
589
+ # ── Minimum behavioural content ──
590
+ # Require at least N variables from a pool without locking in which ones.
591
+ constraints.min_behavioral(2, 'PRICE', 'BIKELANE', 'DIST6', 'RECRE')
592
+
593
+ # ── Random-parameter rules ──
594
+ constraints.force_random('TIME', distribution='n') # must have random parameter
595
+ constraints.exclude_random('HEADWAY') # must never be random
596
+
597
+ # ── Attach constraints to the Parameters object ──
598
+ params = Parameters(
599
+ criterions = [("bic", -1)],
600
+ df = df,
601
+ varnames = varnames,
602
+ asvarnames = varnames,
603
+ choice_set = choice_set,
604
+ choices = df["CHOICE"].values,
605
+ alt_var = df["alt"].values,
606
+ choice_id = df["custom_id"].values,
607
+ ind_id = df["ID"].values,
608
+ base_alt = "SM",
609
+ models = ["multinomial", "mixed_logit"],
610
+ allow_random = True,
611
+ p_val = 0.05,
612
+ pre_spec_constraints = constraints.dict(), # ◄ attach here
613
+ )
614
+
615
+ best = call_siman(params, id_num=1)
616
+ ```
617
+
618
+ The constraint enforcement happens at every stage:
619
+ - **Solution generation** — filtered so violations are never created
620
+ - **Perturbation / mutation** — blocked from adding violating variables
621
+ - **Post-evaluation cleanup** — extra members are removed if somehow present
622
+
623
+ ---
624
+
507
625
  ## License
508
626
 
509
627
  MIT — see [LICENSE](LICENSE) for details.
@@ -4,8 +4,9 @@
4
4
  [![Python](https://img.shields.io/pypi/pyversions/SearchLibrium.svg)](https://pypi.org/project/SearchLibrium/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
  [![CI](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml/badge.svg)](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml)
7
+ [![GitHub](https://img.shields.io/badge/GitHub-HypothesisX-lightgrey?logo=github)](https://github.com/zahern/HypothesisX)
7
8
 
8
- **Automated discrete choice model search powered by Simulated Annealing, Harmony Search, and JAX-accelerated MLE.**
9
+ **Automated discrete choice model search powered by Simulated Annealing, Bandit-guided SA, Harmony Search, SA+PBIL, HS+PBIL, Parallel SA, and JAX-accelerated MLE.**
9
10
 
10
11
  SearchLibrium searches over model specifications — which variables to include, whether parameters should be random, which transformations to apply, and which model class to use — and returns the best converged, all-significant model according to your chosen criterion (BIC, AIC, log-likelihood, MAE, or multi-objective combinations).
11
12
 
@@ -85,16 +86,19 @@ A **run dashboard** is printed automatically at the end of every search, showing
85
86
 
86
87
  ## How the search works
87
88
 
88
- The search uses **Simulated Annealing (SA)** to explore the space of model specifications:
89
+ The search uses **metaheuristic optimisation** (SA, BanditSA, Harmony Search,
90
+ SA+PBIL, HS+PBIL, or parallel variants) to explore the space of model
91
+ specifications:
89
92
 
90
93
  ```text
91
94
  generate starting solution
92
- └─ for each SA temperature step
95
+ └─ for each iteration / temperature step / improvisation
93
96
  └─ perturb current specification → guaranteed distinct from current
94
97
  ├─ fit model with JAX-accelerated MLE
95
98
  ├─ run backward elimination (remove insignificant vars, refit)
96
- ├─ accept if converged + Metropolis criterion satisfied
97
- └─ update best solution
99
+ ├─ accept if converged + acceptance criterion satisfied
100
+ │ (Metropolis, dominance, or pitch adjustment)
101
+ └─ update best solution / harmony memory
98
102
  print dashboard
99
103
  ```
100
104
 
@@ -396,27 +400,42 @@ python -m SearchLibrium --test_search_nest # run nested logit search
396
400
 
397
401
  ## Search algorithms
398
402
 
399
- Both algorithms share a **consistent interface** through `call_search`:
403
+ All algorithms share a **consistent interface**. The library auto-estimates
404
+ hyperparameters from problem size when you omit `ctrl`:
400
405
 
401
406
  ```python
402
- from SearchLibrium import call_search, estimate_ctrl
403
-
404
- # Auto-estimate hyperparameters from problem size (recommended)
405
- best = call_search(params) # SA by default
406
- best = call_search(params, algorithm='hs') # Harmony Search
407
-
408
- # Manual hyperparameters
409
- best = call_search(params, ctrl=(1000, 0.001, 100, 20)) # SA
410
- best = call_search(params, algorithm='hs',
411
- ctrl=(20, 500, 0.9, 0.6, 0.85, 0.3)) # HS
412
-
413
- # Inspect auto-estimated ctrl before running
407
+ from SearchLibrium import (call_search, call_siman, call_harmony, call_sapbil,
408
+ call_banditsa, call_harmony_pbil,
409
+ call_parsa, call_parcopsa, estimate_ctrl)
410
+
411
+ # Unified entry point auto hyperparameters (recommended)
412
+ best = call_search(params) # SA (default)
413
+ best = call_search(params, algorithm='hs') # Harmony Search
414
+ best = call_search(params, algorithm='sapbil') # SA + PBIL
415
+ best = call_search(params, algorithm='banditsa') # Bandit-guided SA
416
+ best = call_search(params, algorithm='hspbil') # HS + PBIL
417
+
418
+ # Inspect auto-estimated hyperparameters before running
414
419
  ctrl = estimate_ctrl(params, algorithm='sa')
415
420
  print(ctrl)
416
421
  ```
417
422
 
423
+ ### Algorithm reference
424
+
425
+ | Function | Description | Population-based | Key idea |
426
+ | -------- | ----------- | :---: | -------- |
427
+ | `call_siman` | Simulated Annealing | | Metropolis acceptance with temperature cooling |
428
+ | `call_banditsa` | Bandit-guided SA | | Thompson Sampling to choose perturbation actions |
429
+ | `call_sapbil` | SA + PBIL | ✓ | Probability matrix learns from accepted solutions to bias perturbations |
430
+ | `call_harmony` | Harmony Search | ✓ | Memory-based improvisation with pitch adjustment |
431
+ | `call_harmony_pbil` | HS + PBIL | ✓ | Harmony Search with PBIL-guided pitch perturbation |
432
+ | `call_parsa` | Parallel SA | | Multiple independent SA solvers running in parallel |
433
+ | `call_parcopsa` | Parallel Cooperative SA | ✓ | Parallel SA with periodic best-solution sharing |
434
+
418
435
  ### Simulated Annealing (`call_siman` / `algorithm='sa'`)
419
436
 
437
+ Control tuple: `(tI, tF, max_temp_steps, max_iter)`
438
+
420
439
  | Parameter | Meaning |
421
440
  | --------- | ------- |
422
441
  | `tI` | Initial temperature — higher → more exploration |
@@ -430,12 +449,14 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
430
449
 
431
450
  ### Harmony Search (`call_harmony` / `algorithm='hs'`)
432
451
 
452
+ Control tuple: `(max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)`
453
+
433
454
  | Parameter | Meaning |
434
455
  | --------- | ------- |
435
456
  | `max_mem` | Harmony memory size (population) |
436
457
  | `maxiter` | Improvisation iterations |
437
- | `max_harm` | Max harmony consideration rate |
438
- | `min_harm` | Min harmony consideration rate |
458
+ | `max_harm` | Max harmony memory consideration rate |
459
+ | `min_harm` | Min harmony memory consideration rate |
439
460
  | `max_pitch` | Max pitch adjustment rate |
440
461
  | `min_pitch` | Min pitch adjustment rate |
441
462
 
@@ -443,6 +464,44 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
443
464
  best = call_harmony(params, ctrl=(20, 400, 0.9, 0.6, 0.85, 0.3), id_num=1)
444
465
  ```
445
466
 
467
+ ### SA + PBIL (`call_sapbil` / `algorithm='sapbil'`)
468
+
469
+ Population-Based Incremental Learning coupled with Simulated Annealing.
470
+ Maintains a probability matrix over the search decisions (inclusion,
471
+ randomness, distribution, correlation, Box-Cox) and updates it from
472
+ accepted solutions using a temperature-coupled learning rate.
473
+
474
+ ```python
475
+ best = call_sapbil(params, id_num=1)
476
+ ```
477
+
478
+ ### HS + PBIL (`call_harmony_pbil` / `algorithm='hspbil'`)
479
+
480
+ Harmony Search with PBIL-guided pitch adjustment. Instead of uniform-random
481
+ perturbations during pitch adjustment, the PBIL probability matrix guides
482
+ add/remove decisions based on learned probabilities.
483
+
484
+ ```python
485
+ best = call_harmony_pbil(params, id_num=1)
486
+ ```
487
+
488
+ ### Bandit-guided SA (`call_banditsa` / `algorithm='banditsa'`)
489
+
490
+ Uses Thompson Sampling over a multi-armed bandit of perturbation actions
491
+ (add asvar, remove asvar, change distribution, ...). Actions that
492
+ historically led to accepted solutions are more likely to be chosen again.
493
+
494
+ ```python
495
+ best = call_banditsa(params, id_num=1)
496
+ ```
497
+
498
+ ### Parallel SA variants
499
+
500
+ ```python
501
+ best = call_parsa(params, nthrds=4) # 4 independent SA solvers
502
+ best = call_parcopsa(params, nthrds=8) # 8 SA solvers sharing best periodically
503
+ ```
504
+
446
505
  ### Auto hyperparameter estimation
447
506
 
448
507
  If `ctrl` is omitted, the library estimates appropriate defaults from the
@@ -452,8 +511,6 @@ problem complexity (`n_vars × n_alts × n_models`, doubled for random params):
452
511
  from SearchLibrium import estimate_ctrl
453
512
  ctrl_sa = estimate_ctrl(params, algorithm='sa')
454
513
  ctrl_hs = estimate_ctrl(params, algorithm='hs')
455
- print('SA ctrl:', ctrl_sa)
456
- print('HS ctrl:', ctrl_hs)
457
514
  ```
458
515
 
459
516
  Complexity buckets:
@@ -467,6 +524,64 @@ Complexity buckets:
467
524
 
468
525
 
469
526
 
527
+ ---
528
+
529
+ ## Constraints
530
+
531
+ Use `ConstraintBuilder` to enforce model structure rules during the search:
532
+
533
+ ```python
534
+ from SearchLibrium import Parameters, ConstraintBuilder, call_siman
535
+
536
+ constraints = ConstraintBuilder()
537
+
538
+ # ── Inclusion / exclusion ──
539
+ constraints.force_include('TIME', 'COST') # must always appear
540
+ constraints.never_include('ID', 'DUMMY') # must never appear
541
+
542
+ # ── Mutually exclusive groups ──
543
+ # At most ONE variable from each group may appear in any solution.
544
+ # The search will never add a partner if a group member is already
545
+ # present, and will remove extras if they slip through.
546
+ constraints.mutually_exclusive('SPEED', 'TIME') # speed OR time, never both
547
+ constraints.mutually_exclusive('LOG_INCOME', 'INCOME_DUMMY') # independent group
548
+
549
+ # ── Minimum behavioural content ──
550
+ # Require at least N variables from a pool without locking in which ones.
551
+ constraints.min_behavioral(2, 'PRICE', 'BIKELANE', 'DIST6', 'RECRE')
552
+
553
+ # ── Random-parameter rules ──
554
+ constraints.force_random('TIME', distribution='n') # must have random parameter
555
+ constraints.exclude_random('HEADWAY') # must never be random
556
+
557
+ # ── Attach constraints to the Parameters object ──
558
+ params = Parameters(
559
+ criterions = [("bic", -1)],
560
+ df = df,
561
+ varnames = varnames,
562
+ asvarnames = varnames,
563
+ choice_set = choice_set,
564
+ choices = df["CHOICE"].values,
565
+ alt_var = df["alt"].values,
566
+ choice_id = df["custom_id"].values,
567
+ ind_id = df["ID"].values,
568
+ base_alt = "SM",
569
+ models = ["multinomial", "mixed_logit"],
570
+ allow_random = True,
571
+ p_val = 0.05,
572
+ pre_spec_constraints = constraints.dict(), # ◄ attach here
573
+ )
574
+
575
+ best = call_siman(params, id_num=1)
576
+ ```
577
+
578
+ The constraint enforcement happens at every stage:
579
+ - **Solution generation** — filtered so violations are never created
580
+ - **Perturbation / mutation** — blocked from adding violating variables
581
+ - **Post-evaluation cleanup** — extra members are removed if somehow present
582
+
583
+ ---
584
+
470
585
  ## License
471
586
 
472
587
  MIT — see [LICENSE](LICENSE) for details.
@@ -65,12 +65,15 @@ dev = [
65
65
 
66
66
  [project.urls]
67
67
  Homepage = "https://pypi.org/project/SearchLibrium/"
68
+ Repository = "https://github.com/zahern/HypothesisX"
69
+ Issues = "https://github.com/zahern/HypothesisX/issues"
70
+ Documentation = "https://pypi.org/project/SearchLibrium/"
68
71
 
69
72
  [project.scripts]
70
73
  realpython = "SearchLibrium.__main__:main"
71
74
 
72
75
  [tool.bumpver]
73
- current_version = "0.0.126"
76
+ current_version = "0.0.128"
74
77
  version_pattern = "MAJOR.MINOR.PATCH"
75
78
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
76
79
  commit = true
@@ -113,7 +113,7 @@ try:
113
113
  from . import misc
114
114
  from .sapbil import SAPBIL, ProbabilityMatrix
115
115
  from .hspbil import HSPBIL
116
- from .call_meta import call_harmony, call_harmony_pbil, call_siman, call_parsa, call_search, call_sapbil, estimate_ctrl
116
+ from .call_meta import call_harmony, call_harmony_pbil, call_siman, call_parsa, call_search, call_sapbil, call_banditsa, call_parcopsa, estimate_ctrl
117
117
  from .predict import DestinationPredictor, predict_destination_components, predict_aggregate_destination_flows
118
118
 
119
119
  except ImportError as e:
@@ -135,7 +135,7 @@ except ImportError as e:
135
135
  from search import Parameters
136
136
  from sapbil import SAPBIL, ProbabilityMatrix
137
137
  from hspbil import HSPBIL
138
- from call_meta import call_siman, call_harmony, call_harmony_pbil, call_search, call_sapbil, estimate_ctrl
138
+ from call_meta import call_siman, call_harmony, call_harmony_pbil, call_search, call_sapbil, call_banditsa, call_parsa, call_parcopsa, estimate_ctrl
139
139
  from predict import DestinationPredictor, predict_destination_components, predict_aggregate_destination_flows
140
140
  try:
141
141
  from .main import print_ascii_art_logo
@@ -104,6 +104,37 @@ class ConstraintBuilder:
104
104
  self._print(f"Never include: {variables}")
105
105
  return self
106
106
 
107
+ def mutually_exclusive(self, *variables: str) -> 'ConstraintBuilder':
108
+ """
109
+ Declare that at most one variable from this group may be included
110
+ in any model specification. The search will never add a variable
111
+ from the group if another member of the group is already present,
112
+ and will remove extra members if they slip in.
113
+
114
+ You may call this method multiple times to define several
115
+ independent groups. Each call creates a separate exclusion set.
116
+
117
+ Parameters
118
+ ----------
119
+ *variables : str
120
+ Variable names forming a mutually exclusive group
121
+
122
+ Returns
123
+ -------
124
+ ConstraintBuilder
125
+ Self for method chaining
126
+
127
+ Example
128
+ -------
129
+ >>> constraints.mutually_exclusive('SPEED', 'TIME')
130
+ >>> constraints.mutually_exclusive('LOG_INCOME', 'INCOME_DUMMY')
131
+ """
132
+ if 'mutually_exclusive' not in self._constraints:
133
+ self._constraints['mutually_exclusive'] = []
134
+ self._constraints['mutually_exclusive'].append(list(variables))
135
+ self._print(f"Mutually exclusive group: {variables}")
136
+ return self
137
+
107
138
  def min_behavioral(self, min_count: int, *variables: str) -> 'ConstraintBuilder':
108
139
  """
109
140
  Require at least ``min_count`` variables from a behavioural pool
@@ -1718,6 +1718,7 @@ class Search():
1718
1718
  asvars = [self.param.avail_asvars[i] for i in asvar_select_pos]
1719
1719
  asvars.extend(self.param.ps_asvars)
1720
1720
  asvars = self.remove_redundant_asvars(asvars, self.param.trans_asvars, self.param.asvarnames)
1721
+ asvars = self._apply_mutual_exclusion_filter(asvars)
1721
1722
  return asvars
1722
1723
  # }
1723
1724
 
@@ -1731,7 +1732,7 @@ class Search():
1731
1732
  isvar_select_pos = [i for i, x in enumerate(ind_availisvar) if x == 1]
1732
1733
  isvars = [self.param.avail_isvars[i] for i in isvar_select_pos]
1733
1734
  isvars.extend(self.param.ps_isvars)
1734
- #asvars = self.remove_redundant_asvars(asvars, self.param.trans_isvars, self.param.avarnames)
1735
+ isvars = self._apply_mutual_exclusion_filter(isvars)
1735
1736
  return isvars
1736
1737
  # }
1737
1738
 
@@ -2025,9 +2026,12 @@ class Search():
2025
2026
  if 'randvars' in solution:
2026
2027
  solution['randvars'] = {k: v for k, v in solution['randvars'].items() if k not in never_rand}
2027
2028
 
2029
+ # ── Mutual-exclusion constraint ──
2030
+ self._enforce_mutual_exclusion(solution)
2031
+
2028
2032
  # ── Min-behavioural constraint (soft: at least n from a pool) ──
2029
2033
  self._enforce_min_behavioral(solution)
2030
-
2034
+
2031
2035
  return solution
2032
2036
 
2033
2037
  def _get_forced_vars(self):
@@ -2087,6 +2091,75 @@ class Search():
2087
2091
  protected.update(current)
2088
2092
  return protected
2089
2093
 
2094
+ def _apply_mutual_exclusion_filter(self, varlist):
2095
+ """Given a list of variables, keep at most one per mutual-exclusion group.
2096
+
2097
+ For each group the first variable encountered is kept; subsequent
2098
+ members are dropped.
2099
+ """
2100
+ groups = self._get_mutual_exclusion_groups()
2101
+ if not groups:
2102
+ return varlist
2103
+ blocked = set()
2104
+ for group in groups:
2105
+ seen_first = False
2106
+ for v in varlist:
2107
+ if v in group:
2108
+ if seen_first:
2109
+ blocked.add(v)
2110
+ else:
2111
+ seen_first = True
2112
+ return [v for v in varlist if v not in blocked]
2113
+
2114
+ def _get_mutual_exclusion_groups(self):
2115
+ """Return the list of mutually-exclusive variable groups, or [].
2116
+
2117
+ Each group is a list[str]; at most one member of each group may
2118
+ appear in a solution.
2119
+ """
2120
+ if not hasattr(self, 'pres_spec_constr') or self.pres_spec_constr is None:
2121
+ return []
2122
+ return self.pres_spec_constr.get('mutually_exclusive', [])
2123
+
2124
+ def _get_excluded_by_mutual_group(self, already_present: set):
2125
+ """Return the set of variables that are blocked because a partner
2126
+ from the same mutually-exclusive group is *already_present*.
2127
+ """
2128
+ excluded = set()
2129
+ for group in self._get_mutual_exclusion_groups():
2130
+ gset = set(group)
2131
+ if gset & already_present:
2132
+ excluded.update(gset)
2133
+ return excluded
2134
+
2135
+ def _enforce_mutual_exclusion(self, solution):
2136
+ """Ensure at most one variable per mutually-exclusive group is present.
2137
+
2138
+ If more than one is found, keep the first (arbitrary) and
2139
+ remove the rest from asvars, isvars, and class/member specs.
2140
+ """
2141
+ groups = self._get_mutual_exclusion_groups()
2142
+ if not groups:
2143
+ return
2144
+ all_v = self._all_vars_in_solution(solution)
2145
+ for group in groups:
2146
+ present = [v for v in group if v in all_v]
2147
+ if len(present) <= 1:
2148
+ continue
2149
+ keep = set(present[:1])
2150
+ remove = set(present[1:])
2151
+ for v in remove:
2152
+ if 'asvars' in solution and v in solution['asvars']:
2153
+ solution['asvars'] = [x for x in solution['asvars'] if x != v]
2154
+ if 'isvars' in solution and v in solution['isvars']:
2155
+ solution['isvars'] = [x for x in solution['isvars'] if x != v]
2156
+ if 'randvars' in solution and v in solution['randvars']:
2157
+ del solution['randvars'][v]
2158
+ if 'bcvars' in solution and v in solution['bcvars']:
2159
+ solution['bcvars'] = [x for x in solution['bcvars'] if x != v]
2160
+ if 'corvars' in solution and v in solution['corvars']:
2161
+ solution['corvars'] = [x for x in solution['corvars'] if x != v]
2162
+
2090
2163
  def _apply_latent_class_constraints(self, solution, lc_constraints):
2091
2164
  """Apply constraints specific to latent class models."""
2092
2165
  # Example: force variables to appear only in certain classes
@@ -2415,6 +2488,8 @@ class Search():
2415
2488
  def perturb_add_asfeature(self, solution):
2416
2489
  # {
2417
2490
  candidate = [var for var in self.param.asvarnames if var not in solution['asvars']]
2491
+ blocked = self._get_excluded_by_mutual_group(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2492
+ candidate = [v for v in candidate if v not in blocked]
2418
2493
  if len(candidate) > 0:
2419
2494
  new_asvar = self.random_choice(candidate)
2420
2495
  solution = self.add_asvar(new_asvar, solution)
@@ -2488,6 +2563,8 @@ class Search():
2488
2563
  def perturb_add_isfeature(self, solution):
2489
2564
  # {
2490
2565
  candidate = [var for var in self.param.isvarnames if var not in solution['isvars']]
2566
+ blocked = self._get_excluded_by_mutual_group(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2567
+ candidate = [v for v in candidate if v not in blocked]
2491
2568
  if len(candidate) > 0:
2492
2569
  # {
2493
2570
  add_isvar = self.random_choice(candidate)
@@ -0,0 +1 @@
1
+ 0.0.128
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.126
3
+ Version: 0.0.128
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
7
7
  Project-URL: Homepage, https://pypi.org/project/SearchLibrium/
8
+ Project-URL: Repository, https://github.com/zahern/HypothesisX
9
+ Project-URL: Issues, https://github.com/zahern/HypothesisX/issues
10
+ Project-URL: Documentation, https://pypi.org/project/SearchLibrium/
8
11
  Keywords: econometric models,search,discrete choice,logit,probit
9
12
  Classifier: Development Status :: 4 - Beta
10
13
  Classifier: Intended Audience :: Science/Research
@@ -41,8 +44,9 @@ Requires-Dist: pytest; extra == "dev"
41
44
  [![Python](https://img.shields.io/pypi/pyversions/SearchLibrium.svg)](https://pypi.org/project/SearchLibrium/)
42
45
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
43
46
  [![CI](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml/badge.svg)](https://github.com/zahern/HypothesisX/actions/workflows/ci.yml)
47
+ [![GitHub](https://img.shields.io/badge/GitHub-HypothesisX-lightgrey?logo=github)](https://github.com/zahern/HypothesisX)
44
48
 
45
- **Automated discrete choice model search powered by Simulated Annealing, Harmony Search, and JAX-accelerated MLE.**
49
+ **Automated discrete choice model search powered by Simulated Annealing, Bandit-guided SA, Harmony Search, SA+PBIL, HS+PBIL, Parallel SA, and JAX-accelerated MLE.**
46
50
 
47
51
  SearchLibrium searches over model specifications — which variables to include, whether parameters should be random, which transformations to apply, and which model class to use — and returns the best converged, all-significant model according to your chosen criterion (BIC, AIC, log-likelihood, MAE, or multi-objective combinations).
48
52
 
@@ -122,16 +126,19 @@ A **run dashboard** is printed automatically at the end of every search, showing
122
126
 
123
127
  ## How the search works
124
128
 
125
- The search uses **Simulated Annealing (SA)** to explore the space of model specifications:
129
+ The search uses **metaheuristic optimisation** (SA, BanditSA, Harmony Search,
130
+ SA+PBIL, HS+PBIL, or parallel variants) to explore the space of model
131
+ specifications:
126
132
 
127
133
  ```text
128
134
  generate starting solution
129
- └─ for each SA temperature step
135
+ └─ for each iteration / temperature step / improvisation
130
136
  └─ perturb current specification → guaranteed distinct from current
131
137
  ├─ fit model with JAX-accelerated MLE
132
138
  ├─ run backward elimination (remove insignificant vars, refit)
133
- ├─ accept if converged + Metropolis criterion satisfied
134
- └─ update best solution
139
+ ├─ accept if converged + acceptance criterion satisfied
140
+ │ (Metropolis, dominance, or pitch adjustment)
141
+ └─ update best solution / harmony memory
135
142
  print dashboard
136
143
  ```
137
144
 
@@ -433,27 +440,42 @@ python -m SearchLibrium --test_search_nest # run nested logit search
433
440
 
434
441
  ## Search algorithms
435
442
 
436
- Both algorithms share a **consistent interface** through `call_search`:
443
+ All algorithms share a **consistent interface**. The library auto-estimates
444
+ hyperparameters from problem size when you omit `ctrl`:
437
445
 
438
446
  ```python
439
- from SearchLibrium import call_search, estimate_ctrl
440
-
441
- # Auto-estimate hyperparameters from problem size (recommended)
442
- best = call_search(params) # SA by default
443
- best = call_search(params, algorithm='hs') # Harmony Search
444
-
445
- # Manual hyperparameters
446
- best = call_search(params, ctrl=(1000, 0.001, 100, 20)) # SA
447
- best = call_search(params, algorithm='hs',
448
- ctrl=(20, 500, 0.9, 0.6, 0.85, 0.3)) # HS
449
-
450
- # Inspect auto-estimated ctrl before running
447
+ from SearchLibrium import (call_search, call_siman, call_harmony, call_sapbil,
448
+ call_banditsa, call_harmony_pbil,
449
+ call_parsa, call_parcopsa, estimate_ctrl)
450
+
451
+ # Unified entry point auto hyperparameters (recommended)
452
+ best = call_search(params) # SA (default)
453
+ best = call_search(params, algorithm='hs') # Harmony Search
454
+ best = call_search(params, algorithm='sapbil') # SA + PBIL
455
+ best = call_search(params, algorithm='banditsa') # Bandit-guided SA
456
+ best = call_search(params, algorithm='hspbil') # HS + PBIL
457
+
458
+ # Inspect auto-estimated hyperparameters before running
451
459
  ctrl = estimate_ctrl(params, algorithm='sa')
452
460
  print(ctrl)
453
461
  ```
454
462
 
463
+ ### Algorithm reference
464
+
465
+ | Function | Description | Population-based | Key idea |
466
+ | -------- | ----------- | :---: | -------- |
467
+ | `call_siman` | Simulated Annealing | | Metropolis acceptance with temperature cooling |
468
+ | `call_banditsa` | Bandit-guided SA | | Thompson Sampling to choose perturbation actions |
469
+ | `call_sapbil` | SA + PBIL | ✓ | Probability matrix learns from accepted solutions to bias perturbations |
470
+ | `call_harmony` | Harmony Search | ✓ | Memory-based improvisation with pitch adjustment |
471
+ | `call_harmony_pbil` | HS + PBIL | ✓ | Harmony Search with PBIL-guided pitch perturbation |
472
+ | `call_parsa` | Parallel SA | | Multiple independent SA solvers running in parallel |
473
+ | `call_parcopsa` | Parallel Cooperative SA | ✓ | Parallel SA with periodic best-solution sharing |
474
+
455
475
  ### Simulated Annealing (`call_siman` / `algorithm='sa'`)
456
476
 
477
+ Control tuple: `(tI, tF, max_temp_steps, max_iter)`
478
+
457
479
  | Parameter | Meaning |
458
480
  | --------- | ------- |
459
481
  | `tI` | Initial temperature — higher → more exploration |
@@ -467,12 +489,14 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
467
489
 
468
490
  ### Harmony Search (`call_harmony` / `algorithm='hs'`)
469
491
 
492
+ Control tuple: `(max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)`
493
+
470
494
  | Parameter | Meaning |
471
495
  | --------- | ------- |
472
496
  | `max_mem` | Harmony memory size (population) |
473
497
  | `maxiter` | Improvisation iterations |
474
- | `max_harm` | Max harmony consideration rate |
475
- | `min_harm` | Min harmony consideration rate |
498
+ | `max_harm` | Max harmony memory consideration rate |
499
+ | `min_harm` | Min harmony memory consideration rate |
476
500
  | `max_pitch` | Max pitch adjustment rate |
477
501
  | `min_pitch` | Min pitch adjustment rate |
478
502
 
@@ -480,6 +504,44 @@ best = call_siman(params, ctrl=(1000, 0.001, 100, 20), id_num=1)
480
504
  best = call_harmony(params, ctrl=(20, 400, 0.9, 0.6, 0.85, 0.3), id_num=1)
481
505
  ```
482
506
 
507
+ ### SA + PBIL (`call_sapbil` / `algorithm='sapbil'`)
508
+
509
+ Population-Based Incremental Learning coupled with Simulated Annealing.
510
+ Maintains a probability matrix over the search decisions (inclusion,
511
+ randomness, distribution, correlation, Box-Cox) and updates it from
512
+ accepted solutions using a temperature-coupled learning rate.
513
+
514
+ ```python
515
+ best = call_sapbil(params, id_num=1)
516
+ ```
517
+
518
+ ### HS + PBIL (`call_harmony_pbil` / `algorithm='hspbil'`)
519
+
520
+ Harmony Search with PBIL-guided pitch adjustment. Instead of uniform-random
521
+ perturbations during pitch adjustment, the PBIL probability matrix guides
522
+ add/remove decisions based on learned probabilities.
523
+
524
+ ```python
525
+ best = call_harmony_pbil(params, id_num=1)
526
+ ```
527
+
528
+ ### Bandit-guided SA (`call_banditsa` / `algorithm='banditsa'`)
529
+
530
+ Uses Thompson Sampling over a multi-armed bandit of perturbation actions
531
+ (add asvar, remove asvar, change distribution, ...). Actions that
532
+ historically led to accepted solutions are more likely to be chosen again.
533
+
534
+ ```python
535
+ best = call_banditsa(params, id_num=1)
536
+ ```
537
+
538
+ ### Parallel SA variants
539
+
540
+ ```python
541
+ best = call_parsa(params, nthrds=4) # 4 independent SA solvers
542
+ best = call_parcopsa(params, nthrds=8) # 8 SA solvers sharing best periodically
543
+ ```
544
+
483
545
  ### Auto hyperparameter estimation
484
546
 
485
547
  If `ctrl` is omitted, the library estimates appropriate defaults from the
@@ -489,8 +551,6 @@ problem complexity (`n_vars × n_alts × n_models`, doubled for random params):
489
551
  from SearchLibrium import estimate_ctrl
490
552
  ctrl_sa = estimate_ctrl(params, algorithm='sa')
491
553
  ctrl_hs = estimate_ctrl(params, algorithm='hs')
492
- print('SA ctrl:', ctrl_sa)
493
- print('HS ctrl:', ctrl_hs)
494
554
  ```
495
555
 
496
556
  Complexity buckets:
@@ -504,6 +564,64 @@ Complexity buckets:
504
564
 
505
565
 
506
566
 
567
+ ---
568
+
569
+ ## Constraints
570
+
571
+ Use `ConstraintBuilder` to enforce model structure rules during the search:
572
+
573
+ ```python
574
+ from SearchLibrium import Parameters, ConstraintBuilder, call_siman
575
+
576
+ constraints = ConstraintBuilder()
577
+
578
+ # ── Inclusion / exclusion ──
579
+ constraints.force_include('TIME', 'COST') # must always appear
580
+ constraints.never_include('ID', 'DUMMY') # must never appear
581
+
582
+ # ── Mutually exclusive groups ──
583
+ # At most ONE variable from each group may appear in any solution.
584
+ # The search will never add a partner if a group member is already
585
+ # present, and will remove extras if they slip through.
586
+ constraints.mutually_exclusive('SPEED', 'TIME') # speed OR time, never both
587
+ constraints.mutually_exclusive('LOG_INCOME', 'INCOME_DUMMY') # independent group
588
+
589
+ # ── Minimum behavioural content ──
590
+ # Require at least N variables from a pool without locking in which ones.
591
+ constraints.min_behavioral(2, 'PRICE', 'BIKELANE', 'DIST6', 'RECRE')
592
+
593
+ # ── Random-parameter rules ──
594
+ constraints.force_random('TIME', distribution='n') # must have random parameter
595
+ constraints.exclude_random('HEADWAY') # must never be random
596
+
597
+ # ── Attach constraints to the Parameters object ──
598
+ params = Parameters(
599
+ criterions = [("bic", -1)],
600
+ df = df,
601
+ varnames = varnames,
602
+ asvarnames = varnames,
603
+ choice_set = choice_set,
604
+ choices = df["CHOICE"].values,
605
+ alt_var = df["alt"].values,
606
+ choice_id = df["custom_id"].values,
607
+ ind_id = df["ID"].values,
608
+ base_alt = "SM",
609
+ models = ["multinomial", "mixed_logit"],
610
+ allow_random = True,
611
+ p_val = 0.05,
612
+ pre_spec_constraints = constraints.dict(), # ◄ attach here
613
+ )
614
+
615
+ best = call_siman(params, id_num=1)
616
+ ```
617
+
618
+ The constraint enforcement happens at every stage:
619
+ - **Solution generation** — filtered so violations are never created
620
+ - **Perturbation / mutation** — blocked from adding violating variables
621
+ - **Post-evaluation cleanup** — extra members are removed if somehow present
622
+
623
+ ---
624
+
507
625
  ## License
508
626
 
509
627
  MIT — see [LICENSE](LICENSE) for details.
@@ -1 +0,0 @@
1
- 0.0.126