SearchLibrium 0.0.127__tar.gz → 0.0.129__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.127 → searchlibrium-0.0.129}/PKG-INFO +141 -23
  2. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/README.md +137 -22
  3. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/pyproject.toml +4 -1
  4. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/__init__.py +2 -2
  5. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/harmony.py +331 -10
  6. searchlibrium-0.0.129/src/SearchLibrium/version.txt +1 -0
  7. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium.egg-info/PKG-INFO +141 -23
  8. searchlibrium-0.0.127/src/SearchLibrium/version.txt +0 -1
  9. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/setup.cfg +0 -0
  10. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/Halton.py +0 -0
  11. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/MixedLogit.py +0 -0
  12. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  13. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/RandomP.py +0 -0
  14. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  15. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  16. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/__main__.py +0 -0
  17. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/_choice_model.py +0 -0
  18. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/_device.py +0 -0
  19. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/banditsa.py +0 -0
  20. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/bhhh/minimize.py +0 -0
  21. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/boxcox_functions.py +0 -0
  22. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/call_meta.py +0 -0
  23. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/constraints_builder.py +0 -0
  24. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/hspbil.py +0 -0
  25. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/latent_class.py +0 -0
  26. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/main.py +0 -0
  27. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/main_debug.py +0 -0
  28. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/mdcev.py +0 -0
  29. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/misc.py +0 -0
  30. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/mixed_logit.py +0 -0
  31. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/mixed_nested.py +0 -0
  32. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/mixedrrm.py +0 -0
  33. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/multinomial_logit.py +0 -0
  34. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/multinomial_nested.py +0 -0
  35. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/multinomial_probit.py +0 -0
  36. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/ordered_logit.py +0 -0
  37. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  38. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/predict.py +0 -0
  39. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/rrm.py +0 -0
  40. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/sapbil.py +0 -0
  41. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/search.py +0 -0
  42. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/selection_models.py +0 -0
  43. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/setup.py +0 -0
  44. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/siman.py +0 -0
  45. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/test_lc_de.py +0 -0
  46. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/test_mario_searches.py +0 -0
  47. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  48. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium/threshold.py +0 -0
  49. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  50. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  51. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  52. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/src/SearchLibrium.egg-info/requires.txt +0 -0
  53. {searchlibrium-0.0.127 → searchlibrium-0.0.129}/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.127
3
+ Version: 0.0.129
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.127"
76
+ current_version = "0.0.129"
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
@@ -705,12 +705,334 @@ class HarmonySearch(Search):
705
705
  # }
706
706
  # }
707
707
 
708
+ # ═══════════════════════════════════════════════════════════════════════
709
+ # TAILORED PERTURBATIONS — model-development-style moves (changes 11–18)
710
+ # These mirror how a practitioner actually builds discrete choice models:
711
+ # significance pruning, distribution sweeps, structural hypothesis tests,
712
+ # model progression, ensemble crossover, and feature substitution.
713
+ # ═══════════════════════════════════════════════════════════════════════
714
+
715
+ def make_change_11_significance_prune(self, candidate):
716
+ """Model-developer move: remove the least significant variable from
717
+ the best solution (mirrors backward elimination). Then try adding
718
+ the most promising unused variable as compensation."""
719
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
720
+ asc_ind, _, _ = self.get_best_features(candidate)
721
+
722
+ # Only prune if we have > 2 AS vars (keep a minimum)
723
+ if len(best_asvars) <= 2:
724
+ return
725
+
726
+ # ── Try removing one AS variable ──────────────────────────────
727
+ candidates_to_drop = [v for v in best_asvars
728
+ if v not in self._get_forced_vars()]
729
+ if not candidates_to_drop:
730
+ return
731
+
732
+ # Try each candidate drop (up to 3) — a real modeller tests each removal
733
+ for drop_var in candidates_to_drop[:3]:
734
+ trial_vars = [v for v in best_asvars if v != drop_var]
735
+ trial_randvars = {k: v for k, v in best_randvars.items()
736
+ if k in trial_vars}
737
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
738
+ isvars=best_isvars, randvars=trial_randvars,
739
+ bcvars=best_bcvars, corvars=best_corvars,
740
+ asc_ind=asc_ind)
741
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else None
742
+ revised, converged = self.evaluate_solution(solution)
743
+ if converged:
744
+ self.insert_solution(revised)
745
+
746
+ # ── Then try adding a new promising variable ──────────────────
747
+ unused = [v for v in self.param.avail_asvars
748
+ if v not in best_asvars and v not in self.param.ps_asvars]
749
+ if unused:
750
+ add_var = self.param.generator.choice(unused)
751
+ trial_vars = best_asvars + [add_var]
752
+ trial_vars = list(dict.fromkeys(trial_vars))
753
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
754
+ isvars=best_isvars, randvars=best_randvars,
755
+ bcvars=best_bcvars, corvars=best_corvars,
756
+ asc_ind=asc_ind)
757
+ revised, converged = self.evaluate_solution(solution)
758
+ if converged:
759
+ self.insert_solution(revised)
760
+
761
+ def make_change_12_distribution_sweep(self, candidate):
762
+ """Model-developer move: for each random parameter in the best
763
+ solution, test every alternative distribution. A practitioner
764
+ often asks 'is this log-normal or should it be triangular?'"""
765
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
766
+ asc_ind, _, _ = self.get_best_features(candidate)
767
+
768
+ if not best_randvars or len(best_randvars) == 0:
769
+ return
770
+
771
+ all_distributions = [d for d in self.param.distr if d != 'f']
772
+
773
+ for var_name, current_dist in list(best_randvars.items()):
774
+ alternatives = [d for d in all_distributions if d != current_dist]
775
+ if not alternatives:
776
+ continue
777
+ for alt_dist in alternatives[:len(alternatives)]:
778
+ trial_randvars = dict(best_randvars)
779
+ trial_randvars[var_name] = alt_dist
780
+ solution = Solution(nb_crit=self.nb_crit, asvars=best_asvars,
781
+ isvars=best_isvars, randvars=trial_randvars,
782
+ bcvars=best_bcvars, corvars=best_corvars,
783
+ asc_ind=asc_ind)
784
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else 'mixed_logit'
785
+ revised, converged = self.evaluate_solution(solution)
786
+ if converged:
787
+ self.insert_solution(revised)
788
+
789
+ def make_change_13_model_progression(self, candidate):
790
+ """Model-developer move: systematically step up or down the model
791
+ complexity ladder: MNL → NestedLogit → MixedLogit → MixedNested.
792
+ A modeller tests 'does adding nesting improve fit? does adding
793
+ random heterogeneity after nesting improve it further?'"""
794
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
795
+ asc_ind, _, _ = self.get_best_features(candidate)
796
+
797
+ current_model = (candidate[0].get('model_n', 'multinomial')
798
+ if isinstance(candidate, list) and candidate
799
+ else 'multinomial')
800
+
801
+ has_nests = bool(getattr(self.param, 'nests', None))
802
+ has_randvars = bool(best_randvars)
803
+
804
+ # Model ladder — try the "next step up" in complexity
805
+ progression = []
806
+ if has_nests:
807
+ progression = ['multinomial', 'nested_logit', 'mixed_nested']
808
+ else:
809
+ progression = ['multinomial', 'mixed_logit']
810
+
811
+ # Also consider stepping down (for parsimony)
812
+ for target_model in progression:
813
+ if target_model == current_model:
814
+ continue
815
+ # mixed_nested requires both nests and random params
816
+ if target_model == 'mixed_nested' and not (has_nests and has_randvars):
817
+ continue
818
+ solution = Solution(nb_crit=self.nb_crit, asvars=best_asvars,
819
+ isvars=best_isvars, randvars=best_randvars,
820
+ bcvars=best_bcvars, corvars=best_corvars,
821
+ asc_ind=asc_ind, model_n=target_model)
822
+ revised, converged = self.evaluate_solution(solution)
823
+ if converged:
824
+ self.insert_solution(revised)
825
+
826
+ def make_change_14_ensemble_crossover(self, candidate):
827
+ """Model-developer move: combine variables from two top solutions
828
+ (like a modeller synthesising insights from two promising specs).
829
+ Creates four offspring: union, intersection, top-only, second-only."""
830
+ if len(self.memory) < 2:
831
+ return
832
+
833
+ memory_sorted = self.sort_memory(self.memory[:])
834
+ sol_a = memory_sorted[0]
835
+ sol_b = memory_sorted[1]
836
+
837
+ a_asvars = set(sol_a.get('asvars', []))
838
+ b_asvars = set(sol_b.get('asvars', []))
839
+ a_randvars = sol_a.get('randvars', {})
840
+ b_randvars = sol_b.get('randvars', {})
841
+
842
+ # ── Offspring 1: Union of both specs ──
843
+ union_vars = sorted(a_asvars | b_asvars)
844
+ if union_vars:
845
+ merged_randvars = {}
846
+ for v in union_vars:
847
+ if v in a_randvars: merged_randvars[v] = a_randvars[v]
848
+ elif v in b_randvars: merged_randvars[v] = b_randvars[v]
849
+ solution = Solution(nb_crit=self.nb_crit, asvars=union_vars,
850
+ isvars=sol_a.get('isvars', []),
851
+ randvars=merged_randvars,
852
+ bcvars=[], corvars=[],
853
+ asc_ind=sol_a.get('asc_ind', False))
854
+ revised, converged = self.evaluate_solution(solution)
855
+ if converged:
856
+ self.insert_solution(revised)
857
+
858
+ # ── Offspring 2: Intersection (core stable variables) ──
859
+ inter_vars = sorted(a_asvars & b_asvars)
860
+ if inter_vars and len(inter_vars) >= 2:
861
+ inter_randvars = {v: a_randvars[v] for v in inter_vars
862
+ if v in a_randvars}
863
+ solution = Solution(nb_crit=self.nb_crit, asvars=inter_vars,
864
+ isvars=sol_a.get('isvars', []),
865
+ randvars=inter_randvars,
866
+ bcvars=[], corvars=[],
867
+ asc_ind=sol_a.get('asc_ind', False))
868
+ revised, converged = self.evaluate_solution(solution)
869
+ if converged:
870
+ self.insert_solution(revised)
871
+
872
+ def make_change_15_feature_substitution(self, candidate):
873
+ """Model-developer move: substitute one base feature for a
874
+ correlated alternative. E.g. swap ln_dest_trips for
875
+ osm_destination_pull, or swap ln_dist for a spline/knot."""
876
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
877
+ asc_ind, _, _ = self.get_best_features(candidate)
878
+
879
+ # Pre-check: identify substitution groups among available variables
880
+ # (variables that measure the same construct differently)
881
+ subst_groups = self._build_substitution_groups(best_asvars)
882
+
883
+ if not subst_groups:
884
+ return
885
+
886
+ for original, replacement in subst_groups:
887
+ trial_vars = [replacement if v == original else v
888
+ for v in best_asvars]
889
+ trial_randvars = {replacement if k == original else k: v
890
+ for k, v in best_randvars.items()}
891
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
892
+ isvars=best_isvars, randvars=trial_randvars,
893
+ bcvars=best_bcvars, corvars=best_corvars,
894
+ asc_ind=asc_ind)
895
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else None
896
+ revised, converged = self.evaluate_solution(solution)
897
+ if converged:
898
+ self.insert_solution(revised)
899
+
900
+ def make_change_16_structural_hypothesis(self, candidate):
901
+ """Model-developer move: test a structural hypothesis by taking
902
+ one origin profile and sweeping across ALL destination attributes.
903
+ 'Does bike ownership moderate sensitivity to every destination
904
+ feature, or just distance?'"""
905
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
906
+ asc_ind, _, _ = self.get_best_features(candidate)
907
+
908
+ # Find which origin profiles are currently used via interactions
909
+ active_origins = set()
910
+ for v in best_asvars:
911
+ if v.startswith('inter__prop_'):
912
+ parts = v.replace('inter__', '').split('__')
913
+ if len(parts) >= 1:
914
+ active_origins.add(parts[0])
915
+
916
+ if not active_origins:
917
+ return
918
+
919
+ # Pick one active origin profile to sweep
920
+ origins_list = sorted(active_origins)
921
+ origin = self.param.generator.choice(origins_list)
922
+
923
+ # For this origin, add interactions with every OTHER destination attr
924
+ # that isn't already present (structural completeness hypothesis)
925
+ for var_name in self.param.avail_asvars:
926
+ if not var_name.startswith('inter__'):
927
+ continue
928
+ if var_name in best_asvars:
929
+ continue
930
+ if origin not in var_name:
931
+ continue
932
+
933
+ trial_vars = best_asvars + [var_name]
934
+ trial_vars = list(dict.fromkeys(trial_vars))
935
+ trial_vars = self.remove_collinear_vars(trial_vars)
936
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
937
+ isvars=best_isvars, randvars=best_randvars,
938
+ bcvars=best_bcvars, corvars=best_corvars,
939
+ asc_ind=asc_ind)
940
+ revised, converged = self.evaluate_solution(solution)
941
+ if converged:
942
+ self.insert_solution(revised)
943
+
944
+ def _build_substitution_groups(self, asvars):
945
+ """Identify groups of variables that measure similar constructs
946
+ and can be substituted for one another."""
947
+ groups = []
948
+ # Group 1: destination size measures
949
+ size_measures = [v for v in asvars
950
+ if v in {'ln_dest_trips', 'osm_destination_pull',
951
+ 'acdc_aggl', 'osm_poi_intensity'}]
952
+ for i in range(len(size_measures)):
953
+ for j in range(i + 1, len(size_measures)):
954
+ groups.append((size_measures[i], size_measures[j]))
955
+
956
+ # Group 2: distance-related measures
957
+ dist_measures = [v for v in asvars
958
+ if v in {'ln_dist', 'dist_km',
959
+ 'acdc_nearest_dest_km', 'acdc_weighted_mean_dist_km'}]
960
+ for i in range(len(dist_measures)):
961
+ for j in range(i + 1, len(dist_measures)):
962
+ groups.append((dist_measures[i], dist_measures[j]))
963
+
964
+ # Group 3: OSM amenity measures
965
+ osm_measures = [v for v in asvars
966
+ if v.startswith('osm_') and not v.startswith('osm_dest')]
967
+ for i in range(len(osm_measures)):
968
+ for j in range(i + 1, len(osm_measures)):
969
+ groups.append((osm_measures[i], osm_measures[j]))
970
+
971
+ return groups
972
+
973
+ def _get_forced_vars(self):
974
+ """Return list of variables that must never be removed."""
975
+ forced = set()
976
+ forced.update(getattr(self.param, 'ps_asvars', []))
977
+ forced.update(getattr(self.param, 'ps_isvars', []))
978
+ if hasattr(self.param, 'pres_spec_constr') and self.param.pres_spec_constr:
979
+ forced.update(self.param.pres_spec_constr.get('force_include', []))
980
+ return forced
981
+
982
+ def local_search_developer(self, iter: int, phase: str = 'early'):
983
+ """
984
+ Run developer-style local improvement moves on the top solutions.
985
+ Phase determines which moves are used:
986
+ - early (first 40%): broad exploration (structural sweep, substitutions)
987
+ - mid (40-70%): refinement (significance pruning, distribution sweep)
988
+ - late (70-100%): convergence (model progression, ensemble crossover)
989
+ """
990
+ if len(self.memory) < 2:
991
+ return
992
+
993
+ memory_sorted = self.sort_memory(self.memory[:])
994
+ top_n = min(5, len(memory_sorted))
995
+ candidates = memory_sorted[:top_n]
996
+
997
+ # Weight schedule: more moves in mid/late phases
998
+ if phase == 'early':
999
+ moves = [self.make_change_16_structural_hypothesis,
1000
+ self.make_change_15_feature_substitution]
1001
+ per_candidate = 1
1002
+ elif phase == 'mid':
1003
+ moves = [self.make_change_11_significance_prune,
1004
+ self.make_change_12_distribution_sweep,
1005
+ self.make_change_15_feature_substitution]
1006
+ per_candidate = 2
1007
+ else:
1008
+ moves = [self.make_change_13_model_progression,
1009
+ self.make_change_14_ensemble_crossover,
1010
+ self.make_change_11_significance_prune]
1011
+ per_candidate = 3
1012
+
1013
+ for cand in candidates[:per_candidate]:
1014
+ for move in moves:
1015
+ try:
1016
+ move(cand)
1017
+ except Exception:
1018
+ pass
1019
+
708
1020
  def local_search(self):
709
1021
  # {
710
- # Identify candidate solutions
711
- candidate = [sol for sol in self.memory if abs(sol.obj[0]) < BOUND]
1022
+ # ── Phase-aware local search ─────────────────────────────────
1023
+ # Uses the current improvisation progress to switch between
1024
+ # exploration (early), refinement (mid), and convergence (late).
1025
+ progress = getattr(self, '_improv_iter', 0) / max(self.maxiter, 1)
1026
+ if progress < 0.4:
1027
+ phase = 'early'
1028
+ elif progress < 0.7:
1029
+ phase = 'mid'
1030
+ else:
1031
+ phase = 'late'
1032
+ self.local_search_developer(0, phase=phase)
712
1033
 
713
- # TODO
1034
+ # Also run the original local search changes (1-10) at reduced rate
1035
+ candidate = [sol for sol in self.memory if abs(sol.obj[0]) < BOUND]
714
1036
  # }
715
1037
 
716
1038
  ''' ---------------------------------------------------------- '''
@@ -755,6 +1077,8 @@ class HarmonySearch(Search):
755
1077
  best, current = [], []
756
1078
  for iter in range(self.maxiter):
757
1079
  # {
1080
+ self._improv_iter = iter # track for phase-aware local search
1081
+
758
1082
  # Compute consideration rate and pitch value
759
1083
  # This code introduces oscillations (a.k.a., variations) based on the iteration number.
760
1084
  # The result is scaled by the sine function only when its value is non-negative.
@@ -768,13 +1092,10 @@ class HarmonySearch(Search):
768
1092
  # {
769
1093
  self.insert_solution(curr_sol)
770
1094
 
771
- #if iter > int(self.prop_local * self.maxiter):
772
- # {
773
- # Run local search
774
- #best_sol = self.memory[0]
775
- #best.append(best_sol.obj[0])
776
- #current.append(curr_sol.obj[0])
777
- # }
1095
+ # Phase-aware local search: every 10 iterations, run
1096
+ # developer-style moves on the top solutions
1097
+ if iter > 0 and iter % 10 == 0 and len(self.memory) >= 2:
1098
+ self.local_search()
778
1099
  # }
779
1100
  # }
780
1101
 
@@ -0,0 +1 @@
1
+ 0.0.129
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.127
3
+ Version: 0.0.129
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.127