Skip to content

Commit 32656f3

Browse files
authored
Merge pull request #7 from omereliy/MCTS
Mcts
2 parents 7aa56cc + 63c42f9 commit 32656f3

15 files changed

Lines changed: 783 additions & 82 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# CI workflow - runs tests on pull requests to master
12
name: CI
23

34
on:

CHANGELOG.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,21 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
---
99

10-
## [Unreleased]
10+
## [0.3.0] — 2026-03-15
11+
12+
### Added
13+
- `BoundedLookaheadSelector` — depth-limited lookahead action selection with discounted future gain estimation
14+
- `IGMCTSSelector` — full UCT-based MCTS action selection (selection, expansion, greedy rollout, backpropagation)
15+
- `simulate_action()` — transition simulation using partial model (confirmed effects + stochastic applicability)
16+
- `selection_strategy='lookahead'` option with configurable depth, top_k, discount
17+
- `selection_strategy='mcts'` option with configurable iterations and rollout depth
18+
- Selection strategy and MCTS/lookahead CLI flags for AMLGym experiment script
19+
20+
### Changed
21+
- Extract `_apply_injective_binding_filter()` to deduplicate binding filter logic in sequential/parallel gain computation
22+
23+
### Notes
24+
- Full UCT MCTS (`selection_strategy='mcts'`) is significantly slower than other strategies due to SAT solving during rollouts. Performance improvements planned for a future release. Use `lookahead` for a balance between exploration depth and speed.
1125

1226
---
1327

@@ -98,17 +112,26 @@ Planned features and improvements for upcoming releases.
98112
| Refactor | Extract literal conversion helper and deduplicate model counting | Done |
99113
| Quality | Fix mypy errors and modernize type annotations | Done |
100114

101-
### 0.3.0-beta.N — MCTS Pre-releases
115+
### 0.3.0-beta.1 — MCTS Phase 1: Bounded Lookahead
116+
117+
| Type | Description | Status |
118+
|------|-------------|--------|
119+
| Feature | `BoundedLookaheadSelector` — depth-limited lookahead action selection using discounted information gain | Done |
120+
| Feature | `simulate_action()` — transition simulation using partial model (confirmed effects + stochastic applicability) | Done |
121+
| Feature | `selection_strategy='lookahead'` option with configurable depth, top_k, discount | Done |
122+
123+
### 0.3.0-beta.2 — MCTS Phase 2: Full UCT
102124

103125
| Type | Description | Status |
104126
|------|-------------|--------|
105-
| Feature | MCTS-based action selection (unstable, iterating toward 0.3.0) | Planned |
127+
| Feature | `IGMCTSSelector` with UCT selection, expansion, greedy rollout, backpropagation | Done |
128+
| Feature | `selection_strategy='mcts'` with configurable iterations and rollout depth | Done |
106129

107-
### 0.3.0 — MCTS
130+
### 0.3.0 — MCTS (Stable)
108131

109132
| Type | Description | Status |
110133
|------|-------------|--------|
111-
| Feature | Full MCTS-based action selection (stable) | Planned |
134+
| Feature | Stable MCTS-based action selection with lookahead and full UCT variants | Done |
112135

113136
### 0.4.0 — Planned Action Sequences
114137

@@ -129,7 +152,8 @@ Planned features and improvements for upcoming releases.
129152

130153
---
131154

132-
[Unreleased]: https://github.com/omereliy/online_model_learning/compare/v0.2.1...HEAD
155+
[Unreleased]: https://github.com/omereliy/online_model_learning/compare/v0.3.0...HEAD
156+
[0.3.0]: https://github.com/omereliy/online_model_learning/compare/v0.2.1...v0.3.0
133157
[0.2.1]: https://github.com/omereliy/online_model_learning/compare/v0.2.0...v0.2.1
134158
[0.2.0]: https://github.com/omereliy/online_model_learning/compare/v0.1.1...v0.2.0
135159
[0.1.1]: https://github.com/omereliy/online_model_learning/compare/v0.1.0...v0.1.1

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,17 @@ Current: multi-module with Unified Planning. Target: 3-class design importable b
3838
- `docs/releasing.md` — PyPI release procedure
3939
- `.claude/rules/` — Simplification, migration, testing, experiment, releasing rules
4040

41+
## Code Conventions
42+
- **Lazy imports for optional deps**: When adding integrations with external packages, import inside methods — not at module top level. Top-level imports break the AMLGym registry when the dep isn't installed.
43+
- **Wire through all entry points**: When adding CLI flags or features, check ALL scripts in `scripts/` and `experiments/` that use the same functionality. Don't wire a flag into only one script.
44+
4145
## Integration
4246
- Target: importable from AMLGym via `OnlineAlgorithmAdapter`
4347
- Currently: standalone with Unified Planning environment
4448
- Never write custom PDDL parsers — use library APIs
49+
50+
## Prompting Tips
51+
When requesting changes, front-load constraints to avoid wrong approaches:
52+
- **Algorithm/SAT work**: "Reference `docs/information_gain_algorithm/INFORMATION_GAIN_ALGORITHM.md` for formulas. Relevant code is in [file]. Run pytest after changes."
53+
- **Experiment work**: "Check ALL scripts in `scripts/` and `experiments/` that invoke this. Start from the error trace, don't explore broadly."
54+
- **MCTS/optimization**: "Profile first before proposing changes. Run the experiment script to verify no regression."

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ learner.update_model()
3434
- **Lifted learning** -- learns at the operator level, generalizing across object instances
3535
- **Object subset selection** -- scales to large domains by focusing on relevant object subsets
3636
- **Parallel gain computation** -- optional multiprocessing for large action spaces
37+
- **MCTS-based action selection** -- lookahead and full UCT strategies for deeper exploration
38+
39+
## Action Selection Strategies
40+
41+
| Strategy | Description | Speed |
42+
|----------|-------------|-------|
43+
| `greedy` | Selects the action with the highest immediate information gain | Fast (default) |
44+
| `lookahead` | Bounded depth-limited lookahead with discounted future gain | Moderate |
45+
| `mcts` | Full UCT-based Monte Carlo Tree Search | Slow (see note below) |
46+
47+
> **Performance note:** The `mcts` strategy performs SAT solving during rollouts, which makes it significantly slower than other strategies. For large domains it may be impractical. Performance improvements are planned for a future release. Use `lookahead` for a balance between exploration depth and speed.
3748
3849
## Configuration
3950

@@ -46,6 +57,10 @@ learner = InformationGainLearner(
4657
spare_objects_per_type=2, # extra objects per type beyond minimum
4758
num_workers=None, # parallel workers (None=auto, 0=sequential)
4859
learn_negative_preconditions=True, # include negative precondition candidates
60+
selection_strategy="greedy", # "greedy", "lookahead", or "mcts"
61+
lookahead_depth=2, # depth for lookahead strategy
62+
mcts_iterations=50, # iterations for mcts strategy
63+
mcts_rollout_depth=5, # rollout depth for mcts strategy
4964
)
5065
```
5166

information_gain_aml/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Information Gain-based Online Action Model Learning."""
22

3-
__version__ = "0.2.1"
3+
__version__ = "0.3.0"

information_gain_aml/algorithms/information_gain.py

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,19 @@ def __init__(self,
126126
self._base_cnf_count_cache: Dict[str, int] = {}
127127

128128
# Phase 3: Action selection strategy
129-
self.selection_strategy = kwargs.get('selection_strategy', 'greedy') # 'greedy', 'epsilon_greedy', 'boltzmann'
129+
self.selection_strategy = kwargs.get('selection_strategy', 'greedy') # 'greedy', 'epsilon_greedy', 'boltzmann', 'lookahead', 'mcts'
130130
self.epsilon = kwargs.get('epsilon', 0.1) # For epsilon-greedy
131131
self.temperature = kwargs.get('temperature', 1.0) # For Boltzmann
132132

133+
# Lookahead parameters (for selection_strategy='lookahead')
134+
self.lookahead_depth = kwargs.get('lookahead_depth', 2)
135+
self.lookahead_top_k = kwargs.get('lookahead_top_k', 5)
136+
self.lookahead_discount = kwargs.get('lookahead_discount', 0.9)
137+
138+
# MCTS parameters (for selection_strategy='mcts')
139+
self.mcts_iterations = kwargs.get('mcts_iterations', 50)
140+
self.mcts_rollout_depth = kwargs.get('mcts_rollout_depth', 5)
141+
133142
# Convergence tracking
134143
self._last_max_gain: float = float('inf') # Track maximum information gain
135144

@@ -467,6 +476,9 @@ def select_action(self, state: Any) -> Tuple[str, List[str]]:
467476
# Ensure state is in set format
468477
state = self._ensure_state_is_set(state)
469478

479+
# Store for use by lookahead selector (needs state for simulation)
480+
self._current_state = state
481+
470482
# Handle object subset selection with state awareness
471483
if self.use_object_subset and self.subset_manager:
472484
# First iteration: select initial subset using state information
@@ -683,42 +695,7 @@ def _calculate_all_action_gains_sequential(self, grounded_actions: List, state:
683695

684696
# Sort by gain (highest first)
685697
action_gains.sort(key=lambda x: x[2], reverse=True)
686-
687-
# Filter/prioritize injective bindings based on action execution history
688-
# Actions without successful observations: filter out non-injective (strict)
689-
# Actions with successful observations: prioritize injective (lenient)
690-
filtered_gains = []
691-
for action_name, objects, gain in action_gains:
692-
is_injective = is_injective_binding(objects)
693-
has_success = self._has_successful_observation(action_name)
694-
695-
if has_success:
696-
# Action already executed successfully - prioritize injective but allow non-injective
697-
filtered_gains.append((action_name, objects, gain, is_injective))
698-
else:
699-
# Action not yet executed - filter out non-injective (unless no alternative)
700-
if is_injective:
701-
filtered_gains.append((action_name, objects, gain, is_injective))
702-
# Non-injective for unexecuted action: defer (handled in fallback below)
703-
704-
# Separate by injectivity, preserving gain ordering within each group
705-
injective_actions = [(a, o, g) for a, o, g, inj in filtered_gains if inj]
706-
non_injective_actions = [(a, o, g) for a, o, g, inj in filtered_gains if not inj]
707-
708-
# Fallback: For action types with NO injective bindings (unexecuted), add non-injective
709-
actions_with_injective = {a for a, _, _, inj in filtered_gains if inj}
710-
for action_name, objects, gain in action_gains:
711-
if not is_injective_binding(objects) and not self._has_successful_observation(action_name):
712-
# Check if this action type has any injective options
713-
if action_name not in actions_with_injective:
714-
non_injective_actions.append((action_name, objects, gain))
715-
# TODO: use ESAM effects logic on non-injective bindings
716-
717-
if non_injective_actions:
718-
logger.debug(f"Injective binding filter: {len(injective_actions)} injective, "
719-
f"{len(non_injective_actions)} non-injective actions")
720-
721-
action_gains = injective_actions + non_injective_actions
698+
action_gains = self._apply_injective_binding_filter(action_gains)
722699

723700
# Log top action after sorting
724701
if action_gains:
@@ -823,49 +800,51 @@ def _calculate_all_action_gains_parallel(self, grounded_actions: List, state: Se
823800

824801
# Sort by gain (highest first)
825802
action_gains.sort(key=lambda x: x[2], reverse=True)
803+
action_gains = self._apply_injective_binding_filter(action_gains)
804+
805+
# Log top action after sorting
806+
if action_gains:
807+
top_action, top_objects, top_gain = action_gains[0]
808+
logger.debug(f"\nTop action after sorting (parallel): {top_action}({','.join(top_objects)}) with E[gain]={top_gain:.6f}")
826809

827-
# Filter/prioritize injective bindings based on action execution history
828-
# Actions without successful observations: filter out non-injective (strict)
829-
# Actions with successful observations: prioritize injective (lenient)
810+
return action_gains
811+
812+
def _apply_injective_binding_filter(
813+
self, action_gains: List[Tuple[str, List[str], float]]
814+
) -> List[Tuple[str, List[str], float]]:
815+
"""
816+
Filter/prioritize injective bindings based on action execution history.
817+
818+
Actions without successful observations: filter out non-injective (strict).
819+
Actions with successful observations: prioritize injective (lenient).
820+
"""
830821
filtered_gains = []
831822
for action_name, objects, gain in action_gains:
832823
is_injective = is_injective_binding(objects)
833824
has_success = self._has_successful_observation(action_name)
834825

835826
if has_success:
836-
# Action already executed successfully - prioritize injective but allow non-injective
837827
filtered_gains.append((action_name, objects, gain, is_injective))
838828
else:
839-
# Action not yet executed - filter out non-injective (unless no alternative)
840829
if is_injective:
841830
filtered_gains.append((action_name, objects, gain, is_injective))
842-
# Non-injective for unexecuted action: defer (handled in fallback below)
843831

844-
# Separate by injectivity, preserving gain ordering within each group
845832
injective_actions = [(a, o, g) for a, o, g, inj in filtered_gains if inj]
846833
non_injective_actions = [(a, o, g) for a, o, g, inj in filtered_gains if not inj]
847834

848835
# Fallback: For action types with NO injective bindings (unexecuted), add non-injective
849836
actions_with_injective = {a for a, _, _, inj in filtered_gains if inj}
850837
for action_name, objects, gain in action_gains:
851838
if not is_injective_binding(objects) and not self._has_successful_observation(action_name):
852-
# Check if this action type has any injective options
853839
if action_name not in actions_with_injective:
854840
non_injective_actions.append((action_name, objects, gain))
855841
# TODO: use ESAM effects logic on non-injective bindings
856842

857843
if non_injective_actions:
858-
logger.debug(f"Injective binding filter (parallel): {len(injective_actions)} injective, "
844+
logger.debug(f"Injective binding filter: {len(injective_actions)} injective, "
859845
f"{len(non_injective_actions)} non-injective actions")
860846

861-
action_gains = injective_actions + non_injective_actions
862-
863-
# Log top action after sorting
864-
if action_gains:
865-
top_action, top_objects, top_gain = action_gains[0]
866-
logger.debug(f"\nTop action after sorting (parallel): {top_action}({','.join(top_objects)}) with E[gain]={top_gain:.6f}")
867-
868-
return action_gains
847+
return injective_actions + non_injective_actions
869848

870849
def _create_parallel_context(self, state: Set[str]) -> "ActionGainContext":
871850
"""
@@ -1072,6 +1051,36 @@ def _select_by_strategy(self, action_gains: List[Tuple[str, List[str], float]])
10721051
# Fallback to last action
10731052
return action_gains[-1][0], action_gains[-1][1]
10741053

1054+
elif self.selection_strategy == 'lookahead':
1055+
# Bounded lookahead: evaluate top-k actions with depth-limited future gain
1056+
from information_gain_aml.algorithms.mcts_selector import BoundedLookaheadSelector
1057+
selector = BoundedLookaheadSelector(
1058+
self,
1059+
depth=self.lookahead_depth,
1060+
top_k=self.lookahead_top_k,
1061+
discount=self.lookahead_discount,
1062+
)
1063+
# _select_by_strategy receives action_gains but not state;
1064+
# we need the current state for simulation — stored from select_action()
1065+
best = selector.select_action(self._current_state, action_gains)
1066+
logger.debug(f"Lookahead: Selected {best[0]}({','.join(best[1])})")
1067+
return best[0], best[1]
1068+
1069+
elif self.selection_strategy == 'mcts':
1070+
# Full UCT-based MCTS action selection
1071+
from information_gain_aml.algorithms.mcts_selector import IGMCTSSelector
1072+
mcts_selector = IGMCTSSelector(
1073+
self,
1074+
iterations=self.mcts_iterations,
1075+
rollout_depth=self.mcts_rollout_depth,
1076+
)
1077+
best = mcts_selector.select_action(self._current_state, action_gains)
1078+
logger.debug(
1079+
f"MCTS: Selected {best[0]}({','.join(best[1])}) "
1080+
f"after {self.mcts_iterations} iterations"
1081+
)
1082+
return best[0], best[1]
1083+
10751084
else:
10761085
# Unknown strategy, default to greedy
10771086
logger.warning(f"Unknown selection strategy '{self.selection_strategy}', defaulting to greedy")
@@ -2107,6 +2116,15 @@ def export_model_snapshot(self, iteration: int, output_dir: Path) -> None:
21072116
"constraint_sets": [sorted(list(cs)) for cs in self.pre_constraints.get(action_name, set())]
21082117
}
21092118

2119+
# Add action model statistics for intermediate analysis
2120+
snapshot["statistics"] = {
2121+
"iterations": self.iteration_count,
2122+
"observations": self.observation_count,
2123+
"converged": self._converged,
2124+
"max_information_gain": self._last_max_gain,
2125+
"action_model_metrics": self.get_action_model_metrics()
2126+
}
2127+
21102128
# Write to file
21112129
output_path = models_dir / f"model_iter_{iteration:03d}.json"
21122130
with open(output_path, 'w') as f:

0 commit comments

Comments
 (0)