rlshiftlab 0.2.0__tar.gz → 0.3.0__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.
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/PKG-INFO +1 -1
- rlshiftlab-0.3.0/examples/lab8_qlearning_vs_sarsa.py +150 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/pyproject.toml +1 -1
- rlshiftlab-0.3.0/src/rlshiftlab/__init__.py +59 -0
- rlshiftlab-0.3.0/src/rlshiftlab/envs/__init__.py +25 -0
- rlshiftlab-0.3.0/src/rlshiftlab/envs/gridworld.py +233 -0
- rlshiftlab-0.3.0/src/rlshiftlab/tabular/__init__.py +17 -0
- rlshiftlab-0.3.0/src/rlshiftlab/tabular/algorithms.py +247 -0
- rlshiftlab-0.3.0/src/rlshiftlab/tabular/history.py +121 -0
- rlshiftlab-0.3.0/src/rlshiftlab/viz/__init__.py +15 -0
- rlshiftlab-0.3.0/src/rlshiftlab/viz/grid.py +243 -0
- rlshiftlab-0.3.0/tests/test_gridworld.py +136 -0
- rlshiftlab-0.3.0/tests/test_gridworld_suite_integration.py +66 -0
- rlshiftlab-0.3.0/tests/test_tabular.py +112 -0
- rlshiftlab-0.3.0/tests/test_viz.py +46 -0
- rlshiftlab-0.2.0/src/rlshiftlab/__init__.py +0 -23
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/.github/workflows/ci.yml +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/.gitignore +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/CHANGELOG.md +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/LICENSE +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/PLAN.md +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/PLAN_V0.2.md +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/README.md +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/docs/.gitkeep +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/docs/cliffwalking_degradation.png +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/examples/.gitkeep +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/examples/cartpole_heuristic.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/examples/cliffwalking_multiseed.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/examples/cliffwalking_q_vs_sarsa.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/examples/custom_metrics.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/comparisons.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/exports.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/groups.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/py.typed +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/records.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/report.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/results.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/runner.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/shifts.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/src/rlshiftlab/stats.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/.gitkeep +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_comparisons.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_crossed_stats.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_groups.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_metrics.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_results.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_runner.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_shifts.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_stats.py +0 -0
- {rlshiftlab-0.2.0 → rlshiftlab-0.3.0}/tests/test_v02_outputs.py +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Complete Lab 8 Experiment: Q-Learning vs SARSA in 5x5 GridWorld with RLShiftLab."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
|
|
8
|
+
from rlshiftlab import (
|
|
9
|
+
ActionSlip,
|
|
10
|
+
GridWorldEnv,
|
|
11
|
+
Suite,
|
|
12
|
+
Transition,
|
|
13
|
+
frozen_policy,
|
|
14
|
+
plot_grid_policy,
|
|
15
|
+
plot_q_values,
|
|
16
|
+
plot_training_curves,
|
|
17
|
+
train_q_learning,
|
|
18
|
+
train_sarsa,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
OUTPUT_DIR = ROOT / "artifacts" / "lab8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
started = perf_counter()
|
|
27
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
print("=== RL Lab 8: Q-Learning vs SARSA in 5x5 GridWorld ===")
|
|
29
|
+
|
|
30
|
+
# 1. Initialize 5x5 GridWorld matching Lab 8 specifications
|
|
31
|
+
env = GridWorldEnv(
|
|
32
|
+
rows=5,
|
|
33
|
+
cols=5,
|
|
34
|
+
start_state=20, # S20 (bottom-left)
|
|
35
|
+
goal_state=4, # S4 (top-right)
|
|
36
|
+
obstacles=[6, 11, 13, 17],
|
|
37
|
+
step_reward=-1.0,
|
|
38
|
+
obstacle_reward=-10.0,
|
|
39
|
+
wall_reward=-10.0,
|
|
40
|
+
goal_reward=10.0,
|
|
41
|
+
max_episode_steps=100,
|
|
42
|
+
)
|
|
43
|
+
print("\nInitial Grid World Layout:")
|
|
44
|
+
print(env.render_ascii())
|
|
45
|
+
|
|
46
|
+
# 2. Train Q-Learning (off-policy TD control)
|
|
47
|
+
print("\nTraining Q-Learning agent (500 episodes)...")
|
|
48
|
+
q_table, q_hist = train_q_learning(
|
|
49
|
+
env=env,
|
|
50
|
+
episodes=500,
|
|
51
|
+
alpha=0.5,
|
|
52
|
+
gamma=0.9,
|
|
53
|
+
epsilon=0.1,
|
|
54
|
+
seed=42,
|
|
55
|
+
)
|
|
56
|
+
print(q_hist.summary())
|
|
57
|
+
|
|
58
|
+
# 3. Train SARSA (on-policy TD control)
|
|
59
|
+
print("\nTraining SARSA agent (500 episodes)...")
|
|
60
|
+
s_table, s_hist = train_sarsa(
|
|
61
|
+
env=env,
|
|
62
|
+
episodes=500,
|
|
63
|
+
alpha=0.5,
|
|
64
|
+
gamma=0.9,
|
|
65
|
+
epsilon=0.1,
|
|
66
|
+
seed=42,
|
|
67
|
+
)
|
|
68
|
+
print(s_hist.summary())
|
|
69
|
+
|
|
70
|
+
# 4. Generate Lab 8 Visualizations for Submission
|
|
71
|
+
print("\nGenerating Lab 8 Visualizations...")
|
|
72
|
+
plot_training_curves(
|
|
73
|
+
{"Q-learning": q_hist, "SARSA": s_hist},
|
|
74
|
+
window=15,
|
|
75
|
+
title="Lab 8: Training Convergence Comparison",
|
|
76
|
+
filepath=OUTPUT_DIR / "training_curves.png",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
plot_grid_policy(
|
|
80
|
+
env=env,
|
|
81
|
+
q_table=q_table,
|
|
82
|
+
title="Q-Learning Optimal Policy (Hugs Obstacles)",
|
|
83
|
+
filepath=OUTPUT_DIR / "q_policy.png",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
plot_grid_policy(
|
|
87
|
+
env=env,
|
|
88
|
+
q_table=s_table,
|
|
89
|
+
title="SARSA Optimal Policy (Safer Detour)",
|
|
90
|
+
filepath=OUTPUT_DIR / "sarsa_policy.png",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
plot_q_values(
|
|
94
|
+
env=env,
|
|
95
|
+
q_table=q_table,
|
|
96
|
+
title="Q-Learning State Values V(s)",
|
|
97
|
+
filepath=OUTPUT_DIR / "q_values.png",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
plot_q_values(
|
|
101
|
+
env=env,
|
|
102
|
+
q_table=s_table,
|
|
103
|
+
title="SARSA State Values V(s)",
|
|
104
|
+
filepath=OUTPUT_DIR / "sarsa_values.png",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 5. RLShiftLab Robustness Evaluation
|
|
108
|
+
print("\nRunning RLShiftLab Robustness Evaluation under Action Slips...")
|
|
109
|
+
q_policy = frozen_policy(q_table)
|
|
110
|
+
sarsa_policy = frozen_policy(s_table)
|
|
111
|
+
|
|
112
|
+
def count_obstacles(transition: Transition) -> float:
|
|
113
|
+
return float(transition.info.get("hit_obstacle", False))
|
|
114
|
+
|
|
115
|
+
suite = Suite(
|
|
116
|
+
env_fn=lambda: GridWorldEnv(
|
|
117
|
+
rows=5,
|
|
118
|
+
cols=5,
|
|
119
|
+
start_state=20,
|
|
120
|
+
goal_state=4,
|
|
121
|
+
obstacles=[6, 11, 13, 17],
|
|
122
|
+
max_episode_steps=100,
|
|
123
|
+
),
|
|
124
|
+
shifts=[
|
|
125
|
+
ActionSlip(levels=[0.05, 0.1, 0.2, 0.3]),
|
|
126
|
+
],
|
|
127
|
+
n_episodes=100,
|
|
128
|
+
seed=123,
|
|
129
|
+
success_fn=lambda ep: ep.terminated,
|
|
130
|
+
step_metrics={"obstacle_hits": count_obstacles},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
results = suite.evaluate({"Q-learning": q_policy, "SARSA": sarsa_policy})
|
|
134
|
+
print("\nRobustness Evaluation Summary:")
|
|
135
|
+
print(results.summary())
|
|
136
|
+
|
|
137
|
+
results.to_csv(OUTPUT_DIR / "robustness_results.csv")
|
|
138
|
+
results.plot(OUTPUT_DIR / "robustness_degradation.png")
|
|
139
|
+
results.to_html(OUTPUT_DIR / "lab8_report.html")
|
|
140
|
+
|
|
141
|
+
contrast = results.compare("Q-learning", "SARSA", metric="return")
|
|
142
|
+
contrast.to_csv(OUTPUT_DIR / "comparison_return.csv")
|
|
143
|
+
contrast.plot(OUTPUT_DIR / "comparison_return.png")
|
|
144
|
+
|
|
145
|
+
print(f"\nAll artifacts generated in: {OUTPUT_DIR}")
|
|
146
|
+
print(f"Total execution time: {perf_counter() - started:.2f}s")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rlshiftlab"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Stress-test frozen RL policies under controlled shifts and get degradation curves with confidence intervals."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Stress-test frozen reinforcement learning policies under controlled shifts."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from rlshiftlab.comparisons import ComparisonResults
|
|
6
|
+
from rlshiftlab.envs.gridworld import (
|
|
7
|
+
ACTION_DOWN,
|
|
8
|
+
ACTION_LEFT,
|
|
9
|
+
ACTION_NAMES,
|
|
10
|
+
ACTION_RIGHT,
|
|
11
|
+
ACTION_SYMBOLS,
|
|
12
|
+
ACTION_UP,
|
|
13
|
+
DEFAULT_OBSTACLES,
|
|
14
|
+
GridWorldEnv,
|
|
15
|
+
)
|
|
16
|
+
from rlshiftlab.groups import GroupResults
|
|
17
|
+
from rlshiftlab.results import Results
|
|
18
|
+
from rlshiftlab.runner import EpisodeRecord, EpisodeResult, Suite, Transition
|
|
19
|
+
from rlshiftlab.shifts import ActionSlip, ObservationNoise, StickyActions
|
|
20
|
+
from rlshiftlab.tabular.algorithms import (
|
|
21
|
+
frozen_policy,
|
|
22
|
+
train_q_learning,
|
|
23
|
+
train_sarsa,
|
|
24
|
+
)
|
|
25
|
+
from rlshiftlab.tabular.history import TrainingHistory
|
|
26
|
+
from rlshiftlab.viz.grid import (
|
|
27
|
+
plot_grid_policy,
|
|
28
|
+
plot_q_values,
|
|
29
|
+
plot_training_curves,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ACTION_DOWN",
|
|
34
|
+
"ACTION_LEFT",
|
|
35
|
+
"ACTION_NAMES",
|
|
36
|
+
"ACTION_RIGHT",
|
|
37
|
+
"ACTION_SYMBOLS",
|
|
38
|
+
"ACTION_UP",
|
|
39
|
+
"DEFAULT_OBSTACLES",
|
|
40
|
+
"ActionSlip",
|
|
41
|
+
"ComparisonResults",
|
|
42
|
+
"EpisodeRecord",
|
|
43
|
+
"EpisodeResult",
|
|
44
|
+
"GridWorldEnv",
|
|
45
|
+
"GroupResults",
|
|
46
|
+
"ObservationNoise",
|
|
47
|
+
"Results",
|
|
48
|
+
"StickyActions",
|
|
49
|
+
"Suite",
|
|
50
|
+
"TrainingHistory",
|
|
51
|
+
"Transition",
|
|
52
|
+
"__version__",
|
|
53
|
+
"frozen_policy",
|
|
54
|
+
"plot_grid_policy",
|
|
55
|
+
"plot_q_values",
|
|
56
|
+
"plot_training_curves",
|
|
57
|
+
"train_q_learning",
|
|
58
|
+
"train_sarsa",
|
|
59
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom environments for RLShiftLab experiments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rlshiftlab.envs.gridworld import (
|
|
6
|
+
ACTION_DOWN,
|
|
7
|
+
ACTION_LEFT,
|
|
8
|
+
ACTION_NAMES,
|
|
9
|
+
ACTION_RIGHT,
|
|
10
|
+
ACTION_SYMBOLS,
|
|
11
|
+
ACTION_UP,
|
|
12
|
+
DEFAULT_OBSTACLES,
|
|
13
|
+
GridWorldEnv,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ACTION_DOWN",
|
|
18
|
+
"ACTION_LEFT",
|
|
19
|
+
"ACTION_NAMES",
|
|
20
|
+
"ACTION_RIGHT",
|
|
21
|
+
"ACTION_SYMBOLS",
|
|
22
|
+
"ACTION_UP",
|
|
23
|
+
"DEFAULT_OBSTACLES",
|
|
24
|
+
"GridWorldEnv",
|
|
25
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Gymnasium-compliant 5x5 GridWorld environment for RL Lab 8."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
import gymnasium as gym
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
ACTION_UP = 0
|
|
12
|
+
ACTION_DOWN = 1
|
|
13
|
+
ACTION_LEFT = 2
|
|
14
|
+
ACTION_RIGHT = 3
|
|
15
|
+
|
|
16
|
+
ACTION_NAMES: dict[int, str] = {
|
|
17
|
+
ACTION_UP: "UP",
|
|
18
|
+
ACTION_DOWN: "DOWN",
|
|
19
|
+
ACTION_LEFT: "LEFT",
|
|
20
|
+
ACTION_RIGHT: "RIGHT",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ACTION_SYMBOLS: dict[int, str] = {
|
|
24
|
+
ACTION_UP: "^",
|
|
25
|
+
ACTION_DOWN: "v",
|
|
26
|
+
ACTION_LEFT: "<",
|
|
27
|
+
ACTION_RIGHT: ">",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Default obstacles matching Lab 8:
|
|
31
|
+
# Row 1 Col 1 -> S6
|
|
32
|
+
# Row 2 Col 1 -> S11
|
|
33
|
+
# Row 2 Col 3 -> S13
|
|
34
|
+
# Row 3 Col 2 -> S17
|
|
35
|
+
DEFAULT_OBSTACLES: frozenset[int] = frozenset({6, 11, 13, 17})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GridWorldEnv(gym.Env):
|
|
39
|
+
"""A 5x5 (or N x M) navigation grid with obstacles, boundary walls, and goals.
|
|
40
|
+
|
|
41
|
+
Conforms to Gymnasium 0.29+ Env interface with Discrete observation and
|
|
42
|
+
action spaces.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
metadata: ClassVar[dict[str, Any]] = {"render_modes": ["ansi", "human"]}
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
rows: int = 5,
|
|
50
|
+
cols: int = 5,
|
|
51
|
+
start_state: int = 20,
|
|
52
|
+
goal_state: int = 4,
|
|
53
|
+
obstacles: Sequence[int] | set[int] | frozenset[int] | None = None,
|
|
54
|
+
step_reward: float = -1.0,
|
|
55
|
+
obstacle_reward: float = -10.0,
|
|
56
|
+
wall_reward: float = -10.0,
|
|
57
|
+
goal_reward: float = 10.0,
|
|
58
|
+
obstacle_bounce: bool = True,
|
|
59
|
+
max_episode_steps: int = 100,
|
|
60
|
+
render_mode: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
super().__init__()
|
|
63
|
+
if rows < 2 or cols < 2:
|
|
64
|
+
raise ValueError(f"rows and cols must be >= 2, got {rows}x{cols}")
|
|
65
|
+
n_states = rows * cols
|
|
66
|
+
if not (0 <= start_state < n_states):
|
|
67
|
+
raise ValueError(f"start_state {start_state} must be in [0, {n_states})")
|
|
68
|
+
if not (0 <= goal_state < n_states):
|
|
69
|
+
raise ValueError(f"goal_state {goal_state} must be in [0, {n_states})")
|
|
70
|
+
if start_state == goal_state:
|
|
71
|
+
raise ValueError("start_state and goal_state must be distinct")
|
|
72
|
+
|
|
73
|
+
self.rows = rows
|
|
74
|
+
self.cols = cols
|
|
75
|
+
self.n_states = n_states
|
|
76
|
+
self.start_state = int(start_state)
|
|
77
|
+
self.goal_state = int(goal_state)
|
|
78
|
+
|
|
79
|
+
if obstacles is None:
|
|
80
|
+
self.obstacles = frozenset(DEFAULT_OBSTACLES)
|
|
81
|
+
else:
|
|
82
|
+
self.obstacles = frozenset(int(s) for s in obstacles)
|
|
83
|
+
|
|
84
|
+
if self.start_state in self.obstacles:
|
|
85
|
+
raise ValueError(f"start_state {self.start_state} cannot be an obstacle")
|
|
86
|
+
if self.goal_state in self.obstacles:
|
|
87
|
+
raise ValueError(f"goal_state {self.goal_state} cannot be an obstacle")
|
|
88
|
+
|
|
89
|
+
self.step_reward = float(step_reward)
|
|
90
|
+
self.obstacle_reward = float(obstacle_reward)
|
|
91
|
+
self.wall_reward = float(wall_reward)
|
|
92
|
+
self.goal_reward = float(goal_reward)
|
|
93
|
+
self.obstacle_bounce = bool(obstacle_bounce)
|
|
94
|
+
self.max_episode_steps = int(max_episode_steps)
|
|
95
|
+
self.render_mode = render_mode
|
|
96
|
+
|
|
97
|
+
self.observation_space = gym.spaces.Discrete(self.n_states)
|
|
98
|
+
self.action_space = gym.spaces.Discrete(4)
|
|
99
|
+
|
|
100
|
+
self._state = self.start_state
|
|
101
|
+
self._step_count = 0
|
|
102
|
+
self._np_random: np.random.Generator = np.random.default_rng()
|
|
103
|
+
|
|
104
|
+
def to_state(self, row: int, col: int) -> int:
|
|
105
|
+
"""Convert 2D (row, col) coordinates to 1D state index."""
|
|
106
|
+
return row * self.cols + col
|
|
107
|
+
|
|
108
|
+
def to_coord(self, state: int) -> tuple[int, int]:
|
|
109
|
+
"""Convert 1D state index to 2D (row, col) coordinates."""
|
|
110
|
+
return divmod(state, self.cols)
|
|
111
|
+
|
|
112
|
+
def reset(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
seed: int | None = None,
|
|
116
|
+
options: dict[str, Any] | None = None,
|
|
117
|
+
) -> tuple[int, dict[str, Any]]:
|
|
118
|
+
"""Reset the environment to the start state."""
|
|
119
|
+
super().reset(seed=seed)
|
|
120
|
+
if seed is not None:
|
|
121
|
+
self._np_random = np.random.default_rng(seed)
|
|
122
|
+
|
|
123
|
+
self._state = self.start_state
|
|
124
|
+
self._step_count = 0
|
|
125
|
+
|
|
126
|
+
info = {
|
|
127
|
+
"row": self.to_coord(self._state)[0],
|
|
128
|
+
"col": self.to_coord(self._state)[1],
|
|
129
|
+
"hit_wall": False,
|
|
130
|
+
"hit_obstacle": False,
|
|
131
|
+
"reached_goal": False,
|
|
132
|
+
}
|
|
133
|
+
return self._state, info
|
|
134
|
+
|
|
135
|
+
def step(self, action: int) -> tuple[int, float, bool, bool, dict[str, Any]]:
|
|
136
|
+
"""Execute one action transition."""
|
|
137
|
+
action = int(action)
|
|
138
|
+
if not self.action_space.contains(action):
|
|
139
|
+
raise ValueError(f"Invalid action {action!r}; must be in [0, 3]")
|
|
140
|
+
|
|
141
|
+
self._step_count += 1
|
|
142
|
+
r, c = self.to_coord(self._state)
|
|
143
|
+
|
|
144
|
+
# Compute candidate target coordinate
|
|
145
|
+
if action == ACTION_UP:
|
|
146
|
+
nr, nc = r - 1, c
|
|
147
|
+
elif action == ACTION_DOWN:
|
|
148
|
+
nr, nc = r + 1, c
|
|
149
|
+
elif action == ACTION_LEFT:
|
|
150
|
+
nr, nc = r, c - 1
|
|
151
|
+
elif action == ACTION_RIGHT:
|
|
152
|
+
nr, nc = r, c + 1
|
|
153
|
+
else:
|
|
154
|
+
nr, nc = r, c
|
|
155
|
+
|
|
156
|
+
hit_wall = False
|
|
157
|
+
hit_obstacle = False
|
|
158
|
+
reached_goal = False
|
|
159
|
+
terminated = False
|
|
160
|
+
|
|
161
|
+
# 1. Boundary wall check: outside bounds
|
|
162
|
+
if nr < 0 or nr >= self.rows or nc < 0 or nc >= self.cols:
|
|
163
|
+
hit_wall = True
|
|
164
|
+
next_state = self._state # remains in the same state
|
|
165
|
+
reward = self.wall_reward
|
|
166
|
+
else:
|
|
167
|
+
candidate_state = self.to_state(nr, nc)
|
|
168
|
+
# 2. Obstacle check
|
|
169
|
+
if candidate_state in self.obstacles:
|
|
170
|
+
hit_obstacle = True
|
|
171
|
+
reward = self.obstacle_reward
|
|
172
|
+
next_state = self._state if self.obstacle_bounce else candidate_state
|
|
173
|
+
# 3. Goal check
|
|
174
|
+
elif candidate_state == self.goal_state:
|
|
175
|
+
reached_goal = True
|
|
176
|
+
next_state = candidate_state
|
|
177
|
+
reward = self.goal_reward
|
|
178
|
+
terminated = True
|
|
179
|
+
# 4. Standard move
|
|
180
|
+
else:
|
|
181
|
+
next_state = candidate_state
|
|
182
|
+
reward = self.step_reward
|
|
183
|
+
|
|
184
|
+
self._state = next_state
|
|
185
|
+
truncated = self._step_count >= self.max_episode_steps and not terminated
|
|
186
|
+
|
|
187
|
+
info = {
|
|
188
|
+
"row": self.to_coord(self._state)[0],
|
|
189
|
+
"col": self.to_coord(self._state)[1],
|
|
190
|
+
"hit_wall": hit_wall,
|
|
191
|
+
"hit_obstacle": hit_obstacle,
|
|
192
|
+
"reached_goal": reached_goal,
|
|
193
|
+
"step_count": self._step_count,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if self.render_mode == "human":
|
|
197
|
+
print(self.render_ascii())
|
|
198
|
+
|
|
199
|
+
return self._state, reward, terminated, truncated, info
|
|
200
|
+
|
|
201
|
+
def render_ascii(self) -> str:
|
|
202
|
+
"""Render grid as a formatted ASCII string."""
|
|
203
|
+
lines = []
|
|
204
|
+
for r in range(self.rows):
|
|
205
|
+
row_chars = []
|
|
206
|
+
for c in range(self.cols):
|
|
207
|
+
s = self.to_state(r, c)
|
|
208
|
+
if s == self._state:
|
|
209
|
+
if s == self.start_state:
|
|
210
|
+
row_chars.append("A(S)")
|
|
211
|
+
elif s == self.goal_state:
|
|
212
|
+
row_chars.append("A(G)")
|
|
213
|
+
else:
|
|
214
|
+
row_chars.append(" A ")
|
|
215
|
+
elif s == self.goal_state:
|
|
216
|
+
row_chars.append(" G ")
|
|
217
|
+
elif s == self.start_state:
|
|
218
|
+
row_chars.append(" S ")
|
|
219
|
+
elif s in self.obstacles:
|
|
220
|
+
row_chars.append(" X ")
|
|
221
|
+
else:
|
|
222
|
+
row_chars.append(f"{s:2d} ")
|
|
223
|
+
lines.append(" ".join(row_chars))
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
def render(self) -> str | None:
|
|
227
|
+
"""Gymnasium render entry point."""
|
|
228
|
+
if self.render_mode == "ansi":
|
|
229
|
+
return self.render_ascii()
|
|
230
|
+
if self.render_mode == "human":
|
|
231
|
+
print(self.render_ascii())
|
|
232
|
+
return None
|
|
233
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tabular reinforcement learning algorithms and history utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rlshiftlab.tabular.algorithms import (
|
|
6
|
+
frozen_policy,
|
|
7
|
+
train_q_learning,
|
|
8
|
+
train_sarsa,
|
|
9
|
+
)
|
|
10
|
+
from rlshiftlab.tabular.history import TrainingHistory
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"TrainingHistory",
|
|
14
|
+
"frozen_policy",
|
|
15
|
+
"train_q_learning",
|
|
16
|
+
"train_sarsa",
|
|
17
|
+
]
|