Spaces:
Paused
Paused
File size: 8,069 Bytes
670ccf0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """Reward and final score computation for the HR Productivity Environment.
Per-quarter rewards are sparse (given at advance_quarter).
Final episode score is computed at the end of Q6, normalized to [0, 1].
CALIBRATION (v2 β anti-gaming):
The Fitz-enz efficiency ratios HCVA (per-FTE) and HCROI (per-employment-cost)
can be trivially inflated by shedding headcount: a do-nothing agent that lets
attrition collapse the workforce 300 -> 41 drives HCVA/HCROI *up* ~75% while
QIPS and engagement crater. Under the original sigmoid-centred scoring this
produced a flat ~0.66 floor where do-nothing, random, and heuristic policies
were indistinguishable.
v2 fixes this by (1) using one-sided ramps so "no improvement" scores 0 (not
0.5), (2) gating efficiency credit by workforce health so you cannot win by
downsizing, and (3) promoting workforce sustainability (headcount + engagement
retention) to a first-class scoring term. Measured calibration after the fix:
do-nothing ~0.15, random ~0.20-0.30, heuristic ~0.50, leaving headroom for a
trained strategic agent at ~0.65+.
"""
from __future__ import annotations
import math
from typing import Any, Dict, List
# ββ Targets (full credit at these improvement levels) βββββββββββββββ
HCVA_TARGET = 0.25 # +25% HCVA over the episode = full efficiency credit
HCROI_TARGET = 0.25 # +25% HCROI
QIPS_TARGET = 0.05 # +5% QIPS (operational quality is hard to move)
EV_TARGET = 0.05 # +5% employee-value composite
RETENTION_FULL = 0.60 # retain >=60% of starting headcount for full health gate
def compute_quarterly_reward(
current_metrics: Dict[str, Any],
previous_metrics: Dict[str, Any],
) -> float:
"""Per-quarter reward in roughly [-1, 1], anti-gaming.
Efficiency gains (HCVA/HCROI) are gated by workforce health this quarter so an
agent cannot farm reward by letting people leave. Headcount and engagement
changes are first-class signals (a quarter that bleeds staff is punished).
Weights:
- Gated efficiency (HCVA + HCROI improvement): 40%
- Operational quality (QIPS improvement): 25%
- Workforce (headcount + engagement change): 35%
"""
hcva_change = _pct_change(current_metrics.get("hcva", 0), previous_metrics.get("hcva", 0))
hcroi_change = _pct_change(current_metrics.get("hcroi", 0), previous_metrics.get("hcroi", 0))
qips_change = _pct_change(
current_metrics.get("qips", {}).get("composite", 0),
previous_metrics.get("qips", {}).get("composite", 0),
)
cur_snap = current_metrics.get("snapshot", {})
prev_snap = previous_metrics.get("snapshot", {})
hc_change = _pct_change(cur_snap.get("headcount", 0), prev_snap.get("headcount", 0))
eng_change = _pct_change(cur_snap.get("avg_engagement", 0), prev_snap.get("avg_engagement", 0))
# Health gate: a quarter that loses >=20% of headcount zeroes efficiency credit;
# growth is capped at full credit (1.0).
health = min(1.0, max(0.0, 1.0 + min(0.0, hc_change) / 0.20))
efficiency = (
0.5 * _sigmoid_reward(hcva_change, scale=5.0)
+ 0.5 * _sigmoid_reward(hcroi_change, scale=5.0)
) * health
quality = _sigmoid_reward(qips_change, scale=5.0)
workforce = (
0.5 * _sigmoid_reward(hc_change, scale=5.0)
+ 0.5 * _sigmoid_reward(eng_change, scale=5.0)
)
reward = 0.40 * efficiency + 0.25 * quality + 0.35 * workforce
return round(reward, 4)
def compute_final_score(metric_history: List[Dict[str, Any]]) -> float:
"""Final episode score at end of Q6, normalized to [0, 1].
Weights:
- Gated efficiency (HCVA + HCROI trajectory): 20%
- Operational quality (QIPS level + growth): 20%
- Employee-value composite (level + growth): 10%
- Workforce sustainability (retention): 30%
- Financial solvency (gated by health): 20%
"""
if len(metric_history) < 2:
return 0.0
first, last = metric_history[0], metric_history[-1]
# --- Workforce health (the anti-gaming backbone) ---
# When snapshot data is absent (synthetic inputs), assume sustained: no
# headcount information must not be read as a workforce collapse.
hc0 = first.get("snapshot", {}).get("headcount")
hcL = last.get("snapshot", {}).get("headcount")
if hc0 and hcL is not None:
retention = min(1.0, max(0.0, hcL / hc0))
else:
retention = 1.0
eng0 = first.get("snapshot", {}).get("avg_engagement")
engL = last.get("snapshot", {}).get("avg_engagement")
if eng0 and engL is not None:
eng_retention = min(1.0, max(0.0, engL / eng0))
else:
eng_retention = 1.0
# Full efficiency credit only if >=RETENTION_FULL of the workforce is kept.
health_gate = min(1.0, retention / RETENTION_FULL)
# --- Efficiency trajectory (gated) ---
hcva_imp = _pct_change(last.get("hcva", 0), first.get("hcva", 0))
hcroi_imp = _pct_change(last.get("hcroi", 0), first.get("hcroi", 0))
efficiency = (0.5 * _ramp(hcva_imp, HCVA_TARGET) + 0.5 * _ramp(hcroi_imp, HCROI_TARGET)) * health_gate
# --- Operational quality: both sustained level and growth ---
q0 = first.get("qips", {}).get("composite", 0)
qL = last.get("qips", {}).get("composite", 0)
qips_imp = _pct_change(qL, q0)
qips_score = 0.5 * _ramp(qips_imp, QIPS_TARGET) + 0.5 * min(1.0, max(0.0, qL))
# --- Workforce capability ---
ev0 = first.get("employee_value", 0.5)
evL = last.get("employee_value", 0.5)
ev_score = 0.6 * _ramp(evL - ev0, EV_TARGET) + 0.4 * min(1.0, max(0.0, evL))
# --- Sustainability: headcount retention scaled by engagement retention ---
sustainability = retention * (0.5 + 0.5 * eng_retention)
# --- Financial solvency, gated by health (a profitable shell is not healthy) ---
profits = [m.get("profit", 0) for m in metric_history if "profit" in m]
if profits:
solvency = sum(1 for p in profits if p > 0) / len(profits)
else:
solvency = 0.5
financial = solvency * health_gate
score = (
0.20 * efficiency
+ 0.20 * qips_score
+ 0.10 * ev_score
+ 0.30 * sustainability
+ 0.20 * financial
)
return round(min(1.0, max(0.0, score)), 4)
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _pct_change(curr: float, prev: float) -> float:
"""Signed fractional change; 0 when previous is 0."""
if prev == 0:
return 0.0
return (curr - prev) / abs(prev)
def _ramp(x: float, target: float) -> float:
"""One-sided linear ramp: 0 at x<=0, 1 at x>=target, linear between.
Unlike a sigmoid, "no improvement" maps to 0 rather than 0.5, so a do-nothing
policy collects no free credit.
"""
if target <= 0:
return 0.0
return max(0.0, min(1.0, x / target))
def _sigmoid_reward(x: float, scale: float = 1.0) -> float:
"""Map a change value to [-1, 1] using sigmoid (for signed per-quarter rewards)."""
return 2.0 / (1.0 + math.exp(-scale * x)) - 1.0
def _bounded_score(x: float, scale: float = 1.0) -> float:
"""Map a value to [0, 1] using sigmoid. Retained for compatibility."""
return 1.0 / (1.0 + math.exp(-scale * x))
def _trend_slope(values: List[float]) -> float:
"""Linear regression slope for a series of values. Retained for compatibility."""
n = len(values)
if n < 2:
return 0.0
x_mean = (n - 1) / 2.0
y_mean = sum(values) / n
numerator = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(values))
denominator = sum((i - x_mean) ** 2 for i in range(n))
if denominator == 0:
return 0.0
return numerator / denominator
|