r"""Human-in-the-loop environments for reinforcement learning (Gymnasium API).
The simulated people follow the package's quantum-like models, so an agent trained here faces the
effects that classical simulators leave out: answers that depend on the order of questions, and trust
that changes when it is asked about. All people are simulated; the parameters are illustrative.
Every environment follows the Gymnasium interface: ``reset(seed=None) -> (observation, info)`` and
``step(action) -> (observation, reward, terminated, truncated, info)``.
Examples
--------
>>> from quantum_mind.envs import ClarificationEnv
>>> env = ClarificationEnv()
>>> obs, info = env.reset(seed=0)
>>> obs.shape, int(env.action_space.n)
((3,), 6)
>>> obs, reward, terminated, truncated, info = env.step(0) # ask question 0
>>> reward, terminated
(-0.3, False)
"""
from __future__ import annotations
import numpy as np
from ..core import projector, angles_to_unit
from ..applications.questioning import ProjectiveAnswerModel
from ..families.dynamics import OpenSystemBelief
try: # Gymnasium is optional
import gymnasium as _gym
from gymnasium import spaces as _spaces
_Base = _gym.Env
except ImportError: # pragma: no cover - exercised only without gymnasium
_gym = None
_spaces = None
class _Base:
"""Minimal stand-in for gymnasium.Env."""
metadata = {}
def reset(self, seed=None, options=None):
if seed is not None:
self.np_random = np.random.default_rng(seed)
__all__ = ['ClarificationEnv', 'TrustHandoverEnv', 'register_envs', 'sample_people']
class _Discrete:
"""Stand-in for gymnasium.spaces.Discrete when Gymnasium is not installed."""
def __init__(self, n):
self.n = n
def sample(self):
return int(np.random.default_rng().integers(self.n))
def contains(self, x):
return 0 <= int(x) < self.n
def _discrete(n):
"""A Discrete space (Gymnasium's when available)."""
return _spaces.Discrete(n) if _spaces is not None else _Discrete(n)
def _box(low, high, shape):
"""A Box space (Gymnasium's when available), otherwise just the shape."""
if _spaces is not None:
return _spaces.Box(low, high, shape, dtype=np.float32)
return type('Box', (), {'shape': shape, 'low': low, 'high': high})()
[docs]
def sample_people(source, n, rng=None):
"""Draw simulated people whose parameters reflect what is known, and not known, about real people.
An agent trained against one simulated person learns that person. Drawing a new person for every
episode from the uncertainty of the fitted human model (domain randomisation over people) trains
a policy that holds up across the people the model is unsure about.
Parameters
----------
source : BootstrapResult, OnlinePersonModel, PopulationPrior or Model
:class:`~quantum_mind.core.uncertainty.BootstrapResult` (refits of a fitted model),
:class:`~quantum_mind.core.online.OnlinePersonModel` (particles weighted by the posterior),
:class:`~quantum_mind.applications.personalisation.PopulationPrior` (Gaussian on the free
parameters), or a single model (returned ``n`` times).
n : int
Number of people.
rng : numpy.random.Generator, optional
Returns
-------
list of Model
"""
rng = np.random.default_rng() if rng is None else rng
if hasattr(source, 'samples') and hasattr(source, 'fit'): # BootstrapResult
cls, opts = type(source.fit.model), source.fit.options
idx = rng.integers(source.n_boot, size=n)
return [cls(**{k: float(v[i]) for k, v in source.samples.items()}, **opts) for i in idx]
if hasattr(source, 'particles'): # OnlinePersonModel
idx = rng.choice(len(source.particles), size=n, p=source.weights)
return [source.model_cls.from_vector(source.particles[i], **source.options) for i in idx]
if hasattr(source, 'cov') and hasattr(source, 'mean'): # PopulationPrior
xs = rng.multivariate_normal(source.mean, source.cov, size=n)
return [source.model_cls.from_vector(x) for x in xs]
return [source] * n
def _draw_person(people, rng):
"""One person from a list or a callable ``rng -> model``."""
if callable(people):
return people(rng)
return people[int(rng.integers(len(people)))]
def default_domain(seed=7):
"""An object-clarification domain: three objects, three yes/no questions, order-dependent answers.
Parameters
----------
seed : int, optional
Seed of the random belief states and question subspaces.
Returns
-------
ProjectiveAnswerModel
"""
rng = np.random.default_rng(seed)
states = {h: angles_to_unit(rng.uniform(0, np.pi, 3)) for h in ('red cup', 'blue cup', 'red bowl')}
projs = {q: projector(angles_to_unit(rng.uniform(0, np.pi, 3))) for q in ('red?', 'cup?', 'left?')}
return ProjectiveAnswerModel(states, projs)
[docs]
class ClarificationEnv(_Base):
"""Resolve an ambiguous request by asking questions, then act.
Actions ``0 .. Q-1`` ask a question; actions ``Q .. Q+H-1`` act on a hypothesis (ending the
episode). The observation has one entry per question: 0 if not asked yet, +1 for "yes", -1 for
"no". The person's answers follow a :class:`~quantum_mind.applications.questioning.ProjectiveAnswerModel`,
so they depend on the questions asked before.
Parameters
----------
model : ProjectiveAnswerModel or IndependentAnswerModel, optional
How the simulated person answers (default: :func:`default_domain`).
ask_cost : float, optional
Reward penalty per question.
success_reward : float, optional
Reward for acting on the right hypothesis.
error_cost : float, optional
Penalty for acting on a wrong hypothesis.
max_questions : int, optional
After this many questions the episode is truncated with the error cost.
people : list or callable, optional
A population of answer models (same questions and hypotheses) or a function ``rng -> model``;
a new person is drawn at every :meth:`reset`.
"""
metadata = {'render_modes': []}
def __init__(self, model=None, ask_cost=0.3, success_reward=1.0, error_cost=5.0, max_questions=6, people=None):
self.people = people
self.model = model or (_draw_person(people, np.random.default_rng(0)) if people is not None else default_domain())
self.Q, self.H = len(self.model.questions), len(self.model.hypotheses)
self.ask_cost, self.success_reward, self.error_cost, self.max_questions = ask_cost, success_reward, error_cost, max_questions
self.action_space = _discrete(self.Q + self.H)
self.observation_space = _box(-1.0, 1.0, (self.Q,))
self.np_random = np.random.default_rng()
self.history, self.target = [], None
def _obs(self):
o = np.zeros(self.Q, np.float32)
for q, a in self.history:
o[self.model.questions.index(q)] = 1.0 if a else -1.0
return o
[docs]
def reset(self, seed=None, options=None):
"""Start an episode with a new hidden intent.
Parameters
----------
seed : int, optional
options : dict, optional
``{'target': hypothesis}`` fixes the hidden intent.
Returns
-------
observation : numpy.ndarray
info : dict
``target`` (for evaluation only).
"""
super().reset(seed=seed)
if seed is not None:
self.np_random = np.random.default_rng(seed)
if self.people is not None:
self.model = _draw_person(self.people, self.np_random)
self.history = []
self.target = (options or {}).get('target') or self.model.hypotheses[int(self.np_random.integers(self.H))]
return self._obs(), {'target': self.target}
[docs]
def step(self, action):
"""Ask a question or act.
Parameters
----------
action : int
Returns
-------
observation, reward, terminated, truncated, info
"""
action = int(action)
if action < self.Q:
q = self.model.questions[action]
a = int(self.np_random.random() < self.model.p_yes(self.target, self.history, q))
self.history.append((q, a))
truncated = len(self.history) >= self.max_questions
reward = -self.ask_cost - (self.error_cost if truncated else 0.0)
return self._obs(), reward, False, truncated, {'question': q, 'answer': a}
h = self.model.hypotheses[action - self.Q]
correct = h == self.target
return self._obs(), self.success_reward if correct else -self.error_cost, True, False, {'correct': correct}
[docs]
class TrustHandoverEnv(_Base):
"""Hand objects to a simulated person whose trust follows the trust qubit.
Actions: 0 hand over, 1 hand over slowly, 2 ask about trust, 3 wait. Observation: the last
hand-over outcome (+1 success, -1 failure, 0 none), the last answer to the trust question (+1, -1,
0) and the fraction of the episode elapsed. A hand-over fails with probability
1 - P(trust); asking collapses the person's trust as in
:class:`~quantum_mind.families.dynamics.OpenSystemBelief`.
Parameters
----------
model : OpenSystemBelief, optional
Trust model of the simulated person.
n_steps : int, optional
Episode length.
fail_cost, slow_cost, ask_cost, wait_cost : float, optional
Costs (rewards are their negatives); a successful hand-over gives +1.
slow_factor : float, optional
Failure probability of a slow hand-over relative to a normal one.
people : list or callable, optional
A population of trust models (for example from :func:`sample_people`) or a function
``rng -> model``; a new person is drawn at every :meth:`reset`.
"""
metadata = {'render_modes': []}
def __init__(self, model=None, n_steps=20, fail_cost=10.0, slow_cost=1.0, ask_cost=0.5, wait_cost=0.3, slow_factor=0.5,
people=None):
self.people = people
self.model = model or OpenSystemBelief(phi0=1.6, a_pos=0.8, a_neg=1.2, gamma=0.3)
self.n_steps, self.fail_cost, self.slow_cost = n_steps, fail_cost, slow_cost
self.ask_cost, self.wait_cost, self.slow_factor = ask_cost, wait_cost, slow_factor
self.action_space = _discrete(4)
self.observation_space = _box(-1.0, 1.0, (3,))
self.np_random = np.random.default_rng()
def _obs(self):
return np.array([self.last_outcome, self.last_answer, self.t / self.n_steps], np.float32)
def _rotate(self, event):
a = -self.model.a_pos if event else self.model.a_neg
c, s = np.cos(a / 2), np.sin(a / 2)
U = np.array([[c, -s], [s, c]])
r = U @ self.rho @ U.T
f = 1 - self.model.gamma
self.rho = np.array([[r[0, 0], f * r[0, 1]], [f * r[1, 0], r[1, 1]]])
@property
def p_trust(self):
"""float: the simulated person's current P(trust)."""
return float(np.clip(self.rho[0, 0], 0, 1))
[docs]
def reset(self, seed=None, options=None):
"""Start an episode at the model's initial trust.
Parameters
----------
seed : int, optional
options : dict, optional
Returns
-------
observation : numpy.ndarray
info : dict
"""
super().reset(seed=seed)
if seed is not None:
self.np_random = np.random.default_rng(seed)
if self.people is not None:
self.model = _draw_person(self.people, self.np_random)
v = np.array([np.cos(self.model.phi0 / 2), np.sin(self.model.phi0 / 2)])
self.rho = np.outer(v, v)
self.t, self.last_outcome, self.last_answer = 0, 0.0, 0.0
return self._obs(), {'p_trust': self.p_trust}
[docs]
def step(self, action):
"""Take one action.
Parameters
----------
action : int
0 hand over, 1 slow hand-over, 2 ask, 3 wait.
Returns
-------
observation, reward, terminated, truncated, info
"""
action = int(action)
reward = 0.0
if action in (0, 1):
p_fail = (1 - self.p_trust) * (self.slow_factor if action == 1 else 1.0)
ok = self.np_random.random() >= p_fail
reward = (1.0 if ok else -self.fail_cost) - (self.slow_cost if action == 1 else 0.0)
self._rotate(int(ok))
self.last_outcome = 1.0 if ok else -1.0
elif action == 2:
yes = self.np_random.random() < self.p_trust
self.rho = np.diag([1.0, 0.0]) if yes else np.diag([0.0, 1.0])
self.last_answer = 1.0 if yes else -1.0
reward = -self.ask_cost
else:
f = 1 - self.model.gamma
self.rho = np.array([[self.rho[0, 0], f * self.rho[0, 1]], [f * self.rho[1, 0], self.rho[1, 1]]])
reward = -self.wait_cost
self.t += 1
return self._obs(), reward, False, self.t >= self.n_steps, {'p_trust': self.p_trust}
[docs]
def register_envs():
"""Register the environments with Gymnasium.
Returns
-------
list of str
The registered ids (empty when Gymnasium is not installed).
"""
if _gym is None:
return []
ids = {'quantum_mind/Clarification-v0': ClarificationEnv, 'quantum_mind/TrustHandover-v0': TrustHandoverEnv}
for i, cls in ids.items():
if i not in _gym.registry:
_gym.register(id=i, entry_point=cls)
return list(ids)