Workflow: Abstraction Refinement¶
We want to compute an optimal policy for a continuous environment using a model checker.
For that we use verigym’s discretization and abstraction learning features.
We discretize the original environment (
CartPoleenvironment)Learn abstract model (discrete MDP)
Compute optimal policy using
stormpyEvaluate the computed policy on the original environment
If the performance is not yet sufficient, refine the abstraction parameters (discretization parameters) and restart.
We start by importing necessary building blocks used in this workflow: abstraction mappers, exploration policies, the stormpy MDP builder and the abstraction_refinement loop itself.
import os
# checks if env var is set, defaults to True
QUICK = os.environ.get("QUICK", "1") != "0"
# uncomment line below for full run
# QUICK = False
import os
import random
from typing import Any
import gymnasium as gym
import numpy as np
import stormpy
from tqdm.auto import tqdm
import verigym
from verigym.abstraction.abstractionmapper import linspace_mapper, AbstractionMapper
from verigym.policy.randomized import RandomizedPolicy
from verigym.policy.qvalue import EntropyLearningPolicy
from verigym.frameworks.stormpy.stormpy_utils import build_stormpy_mdp
from verigym.abstraction.abstraction_refinement import abstraction_refinement
from verigym.policy.utils import evaluate_policy
from workflow_utils.utils import OUTPATH_WORKFLOWS as OUTPATH
from workflow_utils import plot_style
SUBPATH = "abstraction_refinement"
PLOT_PATH = f"{OUTPATH}/figures/{SUBPATH}"
os.makedirs(PLOT_PATH, exist_ok=True)
To make the experiments reproducible, we define a small helper that seeds Python, NumPy and the environment (including its observation and action spaces).
def set_seeds(seed, original_env: verigym.VeriGymEnv):
"""Set the seeds."""
random.seed(seed)
np.random.seed(seed)
original_env.reset(seed=seed)
original_env.action_space.seed(seed)
original_env.observation_space.seed(seed)
We need to provide the abstraction refinement loop with a callback that decides if we should stop refining.
For that we first define a function that copmutes a policy for an abstracted environment.
Given a learned abstract model, we convert it to a stormpy MDP and let the model checker compute a scheduler that maximizes the discounted cumulative reward. We wrap this scheduler into two StormpyPolicy objects:
one that maps original observations to abstract states via the abstraction map (to run on the original environment), and
one using an identity mapper (to run on the abstracted model).
def optimal_policy_via_model_checking(env: verigym.AbstractedExplicitEnv, discount_factor=0.95):
"""We compute the optimal policy using the model checker `stormpy`."""
stormpy_mdp = build_stormpy_mdp(env)
prop = stormpy.parse_properties(f"Rmax=?[Cdiscount={discount_factor}]")[
0
] # checking for cumulative discounted reward
# Run the model checking:
result = stormpy.check_model_sparse(stormpy_mdp, prop, extract_scheduler=True)
scheduler = result.scheduler
# policy that can be execured on original model
policy_on_original = verigym.StormpyPolicy(scheduler, env.abstraction_map, stormpy_mdp)
# policy that can be execute on the abstracted model
identity_mapper = verigym.AbstractionMapper.initialize_identity_mapper(env.observation_space, env.action_space)
policy_on_abstracted = verigym.StormpyPolicy(
policy=scheduler,
abstraction_mapper=identity_mapper,
mdp=stormpy_mdp
)
return policy_on_original, policy_on_abstracted
The refinement loop calls a metric callback after each abstraction step to decide whether to stop. Here, it
computes the optimal policy via model checking,
evaluates it on the original environment, and
reports the task as complete once the mean return reaches
THRESHOLD.
Note: We want to illustrate what it looks like when continue the abstraction refinement until the end. For the early stopping to take place, set THRESHOLD=300.
def metric_callback(original_env: verigym.VeriGymEnv, abstract_env: verigym.AbstractedExplicitEnv) -> tuple[bool, Any]:
"""
After an abstraction, takes the results and checks whether the result is good enough wrt to the metric of interest. Here, performance on original environment.
"""
EVAL_STEPS = 1000
THRESHOLD = 600 # set to 300 for early stopping to actually take place
DISCOUNT_FACTOR = 0.95
policy_on_original, policy_on_abstracted = optimal_policy_via_model_checking(abstract_env, DISCOUNT_FACTOR)
mean, std = evaluate_policy(original_env, policy_on_original, EVAL_STEPS)
metric_result = mean, std
task_complete = mean >= THRESHOLD
return task_complete, metric_result
Now we run the experiment on CartPole-v1, refining the abstraction by increasing the number of bins per state dimension in mapper_schedule (set QUICK = False for the full run). We compare two exploration techniques across several seeds:
a random policy, whose dataset is collected once in the original space and reused for every mapper, and
a max-entropy policy, which is rebuilt for each mapper and alternates exploration and learning over
ENTROPY_ITERATIONSrounds.
N_BINS = range(1,20,2) if not QUICK else range(1,3)
NUM_STEPS = int(1e6) if not QUICK else int(1e4)
EVAL_STEPS = int(1e4)
# interleaved exploration/learning rounds for the entropy policy (same total NUM_STEPS budget)
ENTROPY_ITERATIONS = 5 if not QUICK else 2
SEEDS = range(1000,1005) if not QUICK else [1000]
env = gym.make("CartPole-v1")
# tightening the obs space a bit more according to the CartPole's description
env.observation_space = gym.spaces.Box(low=np.array([-2.5,-0.5,-0.2,-0.5]), high=np.array([2.5,0.5,0.2,0.5]))
original_env = verigym.GenerativeEnv.from_gymnasium(env)
mapper_schedule = [linspace_mapper(env, n_bin, 2) for n_bin in N_BINS]
def entropy_policy_initializer(mapper):
"""Max-entropy exploration policy over the abstract states of `mapper`, acting on the original env."""
policy = EntropyLearningPolicy(
original_env,
mapper
)
policy.abstraction_mapper = mapper # map original observations to abstract states (and actions back)
return policy
# Exploration techniques to compare, each with the kwargs passed to `abstraction_refinement`
EXPLORATION = {
"Random Policy": dict(make_exploration_policy=lambda mapper: RandomizedPolicy(original_env), n_iterations=1, reuse_dataset=True),
"Max-entropy Policy": dict(make_exploration_policy=entropy_policy_initializer, n_iterations=ENTROPY_ITERATIONS, reuse_dataset=False),
}
all_results = {name: [] for name in EXPLORATION} # per exploration technique: one `results` list per seed
for name, kwargs in EXPLORATION.items():
for i, seed in tqdm(enumerate(SEEDS), desc=f"Experiment ({name})", total=len(SEEDS)):
print(f"\n########## {name} | SEED {seed} {i}/{len(SEEDS)} ##########")
set_seeds(seed, original_env)
all_results[name].append(abstraction_refinement(original_env, mapper_schedule, NUM_STEPS, metric_callback, seed=seed, multithreading=False, **kwargs)) # policy_results, abstracted_model, policy_on_original
# Per technique, per-seed policy results: shape (n_seeds, n_iterations, 2) -> (mean, std)
all_policy_results = {name: [[x[0] for x in res] for res in results] for name, results in all_results.items()}
# Print results aggregated over seeds (mean of per-seed means, std across seeds)
print("\n====== RESULTS ======")
for name, policy_results in all_policy_results.items():
print(f"--- {name} ---")
n_iters = min(len(r) for r in policy_results)
seed_means = np.array([[r[i][0] for i in range(n_iters)] for r in policy_results])
for i in range(n_iters):
print(f"Iteration {i}:\tN_BINS={N_BINS[i]}\t-> {seed_means[:, i].mean():.2f} ± {seed_means[:, i].std():.2f} (over {len(SEEDS)} seeds)")
# Save the results (per technique: array of shape (n_seeds, n_iterations, 2) -> mean, std) for re-creating the figure
RESULTS_FILE = f"{OUTPATH}/results/{SUBPATH}/workflow_abstraction_refinement_cartpole.npz"
os.makedirs(os.path.dirname(RESULTS_FILE), exist_ok=True)
n_iters = min(len(r) for res in all_policy_results.values() for r in res)
np.savez(RESULTS_FILE, n_bins=list(N_BINS), **{k: np.array([r[:n_iters] for r in v]) for k, v in all_policy_results.items()})
Finally, we plot the mean return on the original environment against the number of bins per dimension, with the shaded area showing the min/max range across seeds. A red marker indicates the first refinement step at which an exploration technique would have reached the (illustrative) early stopping threshold.
import matplotlib.pyplot as plt
# Load the saved results (written by the experiment cell above)
saved = np.load(RESULTS_FILE)
N_BINS = saved["n_bins"]
all_policy_results = {k: saved[k] for k in saved.files if k != "n_bins"}
THRESHOLD = 300 # early stopping threshold on the mean return (not used during refinement)
fig, ax = plt.subplots(figsize=(4, 3))
threshold_labeled = False # label the threshold marker only once in the legend
for j, (name, policy_results) in enumerate(all_policy_results.items()):
# Per-seed mean returns, shape (n_seeds, n_iterations)
n_iters = min(len(r) for r in policy_results)
seed_means = np.array([[r[i][0] for i in range(n_iters)] for r in policy_results])
means = seed_means.mean(axis=0)
mins = seed_means.min(axis=0)
maxs = seed_means.max(axis=0)
bins = N_BINS[:n_iters]
# Mean return with min/max range across seeds
color = f"C{j}"
ax.plot(bins, means, color=color, label=name)
ax.fill_between(bins, mins, maxs, color=color, alpha=0.3)
# First refinement step where the mean return reaches the threshold (None if never)
above = np.flatnonzero(means >= THRESHOLD)
if above.size > 0:
first = above[0]
ax.scatter(bins[first], means[first], color="red", zorder=3, label=None if threshold_labeled else "Threshold reached")
threshold_labeled = True
# Early stopping threshold
ax.axhline(THRESHOLD, color="gray", linestyle="--", linewidth=1, label="Early stopping threshold")
ax.set(xlabel="# bins per dimension", ylabel="Return")
ax.set_xticks(range(0, max(N_BINS) + 1))
ax.set_xticks(range(0, max(N_BINS) + 1), minor=True)
ax.legend(frameon=True, facecolor="white", edgecolor="none", framealpha=0.8)
fig.savefig(f"{PLOT_PATH}/workflow_abstraction_refinement_cartpole.pdf", pad_inches=0, bbox_inches="tight")