Ekrem-the-second commited on
Commit
d92d8cf
·
verified ·
1 Parent(s): facc5f2

Initial release: UGTC - Uncertainty-Gated Temporal Credit

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, develop]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: Unit Tests — Python ${{ matrix.python-version }}
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.10", "3.11", "3.12"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+
26
+ - name: Cache pip
27
+ uses: actions/cache@v4
28
+ with:
29
+ path: ~/.cache/pip
30
+ key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}-${{ matrix.python-version }}
31
+
32
+ - name: Install core dependencies (CPU-only torch)
33
+ run: |
34
+ pip install --upgrade pip
35
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
36
+ pip install numpy
37
+ pip install -e ".[dev]"
38
+
39
+ - name: Lint with ruff
40
+ run: ruff check ugtc/ tests/
41
+
42
+ - name: Type check with mypy
43
+ run: mypy ugtc/ --ignore-missing-imports
44
+
45
+ - name: Run tests
46
+ run: pytest tests/ -v --tb=short --cov=ugtc --cov-report=term-missing
47
+
48
+ - name: Upload coverage
49
+ if: matrix.python-version == '3.11'
50
+ uses: codecov/codecov-action@v4
51
+ with:
52
+ fail_ci_if_error: false
53
+
54
+ lint:
55
+ name: Code Quality
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+ - uses: actions/setup-python@v5
60
+ with:
61
+ python-version: "3.11"
62
+ - name: Install lint tools
63
+ run: pip install ruff black isort
64
+ - name: Check formatting (black)
65
+ run: black --check ugtc/ tests/ examples/
66
+ - name: Check imports (isort)
67
+ run: isort --check ugtc/ tests/ examples/
68
+ - name: Lint (ruff)
69
+ run: ruff check ugtc/ tests/ examples/
.github/workflows/pages.yml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ pages: write
11
+ id-token: write
12
+
13
+ concurrency:
14
+ group: "pages"
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ build:
19
+ name: Build Documentation
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Setup Pages
25
+ uses: actions/configure-pages@v5
26
+
27
+ - name: Upload artifact
28
+ uses: actions/upload-pages-artifact@v3
29
+ with:
30
+ path: "./docs"
31
+
32
+ deploy:
33
+ name: Deploy to GitHub Pages
34
+ environment:
35
+ name: github-pages
36
+ url: ${{ steps.deployment.outputs.page_url }}
37
+ runs-on: ubuntu-latest
38
+ needs: build
39
+ steps:
40
+ - name: Deploy to GitHub Pages
41
+ id: deployment
42
+ uses: actions/deploy-pages@v4
CITATION.cff ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use UGTC in your research, please cite it using this metadata."
3
+ type: software
4
+ title: "UGTC: Uncertainty-Gated Temporal Credit"
5
+ abstract: >
6
+ UGTC is a backbone-agnostic advantage estimator for actor-critic reinforcement
7
+ learning. It maintains dual critics with different GAE lambda values and blends
8
+ their estimates using a sigmoid uncertainty gate driven by ensemble disagreement,
9
+ resolving the bias-variance trade-off in temporal credit assignment.
10
+ authors:
11
+ - family-names: Dalar
12
+ given-names: Yağız Ekrem
13
+ affiliation: "Ethosoft AI"
14
+ repository-code: "https://github.com/ethosoftai/ugtc"
15
+ url: "https://ethosoftai.github.io/ugtc"
16
+ license: MIT
17
+ version: "1.0.0"
18
+ date-released: "2026-06-15"
19
+ doi: "10.5281/zenodo.19715116"
20
+ keywords:
21
+ - reinforcement learning
22
+ - advantage estimation
23
+ - temporal credit assignment
24
+ - uncertainty quantification
25
+ - actor-critic
26
+ - PPO
27
+ - TD3
28
+ - SAC
29
+ - DDPG
30
+ - ensemble methods
31
+ preferred-citation:
32
+ type: article
33
+ authors:
34
+ - family-names: Dalar
35
+ given-names: Yağız Ekrem
36
+ affiliation: "Ethosoft AI"
37
+ title: "UGTC: Uncertainty-Gated Temporal Credit"
38
+ year: 2026
39
+ journal: "Ulysseus Young Explorers in Science (UYES)"
40
+ doi: "10.5281/zenodo.19715116"
41
+ url: "https://doi.org/10.5281/zenodo.19715116"
42
+ note: "Journal DOI forthcoming upon publication."
CONTRIBUTING.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to UGTC
2
+
3
+ Thank you for your interest in contributing to UGTC!
4
+
5
+ ## Getting Started
6
+
7
+ ```bash
8
+ git clone https://github.com/ethosoftai/ugtc.git
9
+ cd ugtc
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Development Setup
14
+
15
+ ```bash
16
+ pip install ruff black isort mypy pytest pytest-cov
17
+ ```
18
+
19
+ ## Running Tests
20
+
21
+ ```bash
22
+ pytest tests/ -v
23
+ pytest tests/ -v --cov=ugtc --cov-report=html
24
+ ```
25
+
26
+ ## Code Style
27
+
28
+ We use:
29
+ - **black** for formatting (line length 100)
30
+ - **isort** for import ordering
31
+ - **ruff** for linting
32
+ - **mypy** for type checking
33
+
34
+ ```bash
35
+ black ugtc/ tests/ examples/
36
+ isort ugtc/ tests/ examples/
37
+ ruff check ugtc/ tests/ examples/
38
+ mypy ugtc/ --ignore-missing-imports
39
+ ```
40
+
41
+ ## Types of Contributions
42
+
43
+ ### Bug Reports
44
+ Open an issue with:
45
+ - Minimal reproducible example
46
+ - Expected vs actual behavior
47
+ - Python/PyTorch/OS version
48
+
49
+ ### New RL Algorithm Integrations
50
+ When adding a new RL backbone integration (e.g., UGTC-IMPALA):
51
+ 1. Create `ugtc/impala.py` following the pattern of `ugtc/ppo.py`
52
+ 2. Add integration tests to `tests/test_integrations.py`
53
+ 3. Add an example to `examples/`
54
+ 4. Clearly label if the integration is from the paper or a proposed extension
55
+
56
+ ### New Benchmark Scripts
57
+ Add to `benchmarks/` with:
58
+ - Clear docstring explaining the experiment
59
+ - Config at the top of the file
60
+ - Full standalone runnable script
61
+
62
+ ### Documentation
63
+ - Improve `docs/index.html`
64
+ - Fix typos in README or pseudocode
65
+
66
+ ## Transparency Requirements
67
+
68
+ UGTC is a published research codebase. Please:
69
+
70
+ 1. **Do not fabricate benchmark numbers.** If you run experiments and get results, share them as your own empirical findings, not as paper claims.
71
+ 2. **Label assumptions.** If you implement an extension not described in the paper, mark it with `# NOTE: implementation extension — not in paper`.
72
+ 3. **Cite correctly.** Reference [the paper](https://doi.org/10.5281/zenodo.19715116) when using this work.
73
+
74
+ ## Pull Request Process
75
+
76
+ 1. Fork the repository
77
+ 2. Create a feature branch: `git checkout -b feature/my-feature`
78
+ 3. Commit changes with clear messages
79
+ 4. Ensure all tests pass and linting is clean
80
+ 5. Open a PR with a description of what changed and why
81
+
82
+ ## Code of Conduct
83
+
84
+ This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md).
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yağız Ekrem Dalar / Ethosoft AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - reinforcement-learning
7
+ - advantage-estimation
8
+ - temporal-credit
9
+ - uncertainty
10
+ - actor-critic
11
+ - PPO
12
+ - TD3
13
+ - SAC
14
+ - DDPG
15
+ - pytorch
16
+ pipeline_tag: reinforcement-learning
17
+ ---
18
+
19
+ # UGTC: Uncertainty-Gated Temporal Credit
20
+
21
+ [![Paper](https://img.shields.io/badge/Paper-Zenodo%2019715116-blue)](https://doi.org/10.5281/zenodo.19715116)
22
+ [![UYES](https://img.shields.io/badge/Published-UYES%20Journal-green)](https://doi.org/10.5281/zenodo.19715116)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
24
+ [![GitHub](https://img.shields.io/badge/GitHub-ethosoftai%2Fugtc-black)](https://github.com/ethosoftai/ugtc)
25
+
26
+ > **Accepted — Ulysseus Young Explorers in Science (UYES) Journal**
27
+ > Preprint DOI: [10.5281/zenodo.19715116](https://doi.org/10.5281/zenodo.19715116) · Journal DOI forthcoming
28
+ > Author: Yağız Ekrem Dalar | Ethosoft AI
29
+
30
+ ---
31
+
32
+ ## What is UGTC?
33
+
34
+ **UGTC** is a backbone-agnostic plug-in advantage estimator for actor-critic reinforcement learning. It resolves the bias–variance trade-off in temporal credit assignment by maintaining two critics with different GAE λ values and blending their estimates using a sigmoid uncertainty gate:
35
+
36
+ ```
37
+ A^UGTC_t = u(sₜ) · A^slow_t + (1 - u(sₜ)) · A^fast_t
38
+
39
+ u(s) = sigmoid(-β · (σ̂(s) - 1))
40
+ σ̂(s) = std(V¹_slow, ..., Vᴹ_slow)(s) / σ_EMA
41
+ ```
42
+
43
+ - **Low ensemble disagreement** → `u → 1` → use slow critic (accurate, λ=0.99)
44
+ - **High ensemble disagreement** → `u → 0` → use fast critic (stable, λ=0.80)
45
+
46
+ ## Fixed Hyperparameters (same across all benchmarks)
47
+
48
+ | Parameter | Value |
49
+ |-----------|-------|
50
+ | λ_fast | 0.80 |
51
+ | λ_slow | 0.99 |
52
+ | Ensemble size M | 3 |
53
+ | Gate temperature β | 5.0 |
54
+ | EMA momentum | 0.99 |
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ git clone https://github.com/ethosoftai/ugtc.git
60
+ cd ugtc
61
+ pip install -e .
62
+ ```
63
+
64
+ Or from this repo:
65
+
66
+ ```bash
67
+ pip install huggingface_hub
68
+ python -c "from huggingface_hub import snapshot_download; snapshot_download('Ethosoft/ugtc', local_dir='ugtc')"
69
+ cd ugtc && pip install -e .
70
+ ```
71
+
72
+ ## Quick Usage
73
+
74
+ ```python
75
+ from ugtc import UGTCModule
76
+
77
+ ugtc = UGTCModule(obs_dim=17) # e.g. Hopper-v4
78
+
79
+ # In your actor-critic update — replace standard GAE with:
80
+ advantages = ugtc.compute_advantages(
81
+ obs=obs, # (T, obs_dim)
82
+ next_obs=next_obs, # (T, obs_dim)
83
+ rewards=rewards, # (T,)
84
+ dones=dones, # (T,)
85
+ gamma=0.99,
86
+ )
87
+ ```
88
+
89
+ ## Supported Algorithms
90
+
91
+ | Algorithm | Key Change |
92
+ |-----------|-----------|
93
+ | **UGTC-PPO** | A^UGTC replaces standard GAE in clipped surrogate |
94
+ | **UGTC-TD3** | UGTC baseline correction on actor gradient |
95
+ | **UGTC-SAC** | V^UGTC replaces value baseline in actor loss |
96
+ | **UGTC-DDPG** | UGTC advantage scales actor update *(extension)* |
97
+
98
+ ## Repository Structure
99
+
100
+ ```
101
+ ugtc/ Core Python package
102
+ module.py UGTCModule — backbone-agnostic core
103
+ ppo.py UGTC-PPO integration
104
+ td3.py UGTC-TD3 integration
105
+ sac.py UGTC-SAC integration
106
+ ddpg.py UGTC-DDPG integration (extension)
107
+ utils.py Evaluation utilities (IQM, bootstrap CI, AUC)
108
+ examples/ Runnable examples (CartPole, Pendulum, MuJoCo)
109
+ benchmarks/ Procgen + MuJoCo benchmark scripts
110
+ tests/ Unit and integration tests
111
+ implementations/
112
+ cpp/ugtc.hpp C++ header-only reference
113
+ java/UGTCModule.java Java reference
114
+ pseudocode/ Algorithm pseudocode (PPO, TD3, SAC)
115
+ configs/ YAML configs for all benchmarks
116
+ docs/ GitHub Pages documentation source
117
+ ```
118
+
119
+ ## Citation
120
+
121
+ ```bibtex
122
+ @misc{dalar2026ugtc,
123
+ author = {Dalar, Yağız Ekrem},
124
+ title = {{UGTC}: Uncertainty-Gated Temporal Credit},
125
+ year = {2026},
126
+ publisher = {Zenodo},
127
+ doi = {10.5281/zenodo.19715116},
128
+ url = {https://doi.org/10.5281/zenodo.19715116},
129
+ note = {Accepted — Ulysseus Young Explorers in Science (UYES) Journal.
130
+ Journal DOI forthcoming.}
131
+ }
132
+ ```
133
+
134
+ ## Links
135
+
136
+ - **Paper:** https://doi.org/10.5281/zenodo.19715116
137
+ - **GitHub:** https://github.com/ethosoftai/ugtc
138
+ - **Docs:** https://ethosoftai.github.io/ugtc
139
+ - **Demo Space:** https://huggingface.co/spaces/Ethosoft/ugtc
benchmarks/__init__.py ADDED
File without changes
benchmarks/mujoco/__init__.py ADDED
File without changes
benchmarks/mujoco/train_ugtc_td3_mujoco.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UGTC-TD3 on MuJoCo continuous control — Ant-v5 default.
4
+
5
+ Fixed UGTC hyperparameters (same across all benchmarks):
6
+ λ_fast = 0.80, λ_slow = 0.99, M = 3, β = 5.0, α_EMA = 0.99
7
+
8
+ NOTE: eta=0.5 (UGTC correction weight) is an implementation default.
9
+ It is not a fixed UGTC hyperparameter and may benefit from tuning.
10
+
11
+ Requirements:
12
+ pip install torch gymnasium mujoco
13
+
14
+ Usage:
15
+ python benchmarks/mujoco/train_ugtc_td3_mujoco.py --env Ant-v5
16
+ python benchmarks/mujoco/train_ugtc_td3_mujoco.py --env Hopper-v4
17
+ """
18
+
19
+ import argparse
20
+ import numpy as np
21
+ import torch
22
+
23
+ import gymnasium as gym
24
+
25
+ from ugtc import UGTCTD3
26
+ from ugtc.td3 import ReplayBuffer
27
+ from ugtc.utils import bootstrap_ci, interquartile_mean
28
+
29
+
30
+ def evaluate(agent, env_name, n_episodes=20):
31
+ env = gym.make(env_name)
32
+ returns = []
33
+ for _ in range(n_episodes):
34
+ obs, _ = env.reset()
35
+ done, ret = False, 0.0
36
+ while not done:
37
+ action = agent.select_action(obs, noise=0.0)
38
+ obs, r, te, tr, _ = env.step(action)
39
+ ret += r
40
+ done = te or tr
41
+ returns.append(ret)
42
+ env.close()
43
+ return returns
44
+
45
+
46
+ def main():
47
+ parser = argparse.ArgumentParser()
48
+ parser.add_argument("--env", type=str, default="Ant-v5")
49
+ parser.add_argument("--total_steps", type=int, default=3_000_000)
50
+ parser.add_argument("--start_steps", type=int, default=25_000)
51
+ parser.add_argument("--batch_size", type=int, default=256)
52
+ parser.add_argument("--eval_freq", type=int, default=50_000)
53
+ parser.add_argument("--eval_episodes", type=int, default=20)
54
+ parser.add_argument("--seed", type=int, default=0)
55
+ parser.add_argument("--hidden", type=int, default=256)
56
+ parser.add_argument("--eta", type=float, default=0.5)
57
+ args = parser.parse_args()
58
+
59
+ torch.manual_seed(args.seed)
60
+ np.random.seed(args.seed)
61
+ device = "cuda" if torch.cuda.is_available() else "cpu"
62
+
63
+ env = gym.make(args.env)
64
+ obs_dim = env.observation_space.shape[0]
65
+ act_dim = env.action_space.shape[0]
66
+ max_action = float(env.action_space.high[0])
67
+
68
+ agent = UGTCTD3(
69
+ obs_dim=obs_dim,
70
+ act_dim=act_dim,
71
+ max_action=max_action,
72
+ hidden=args.hidden,
73
+ eta=args.eta,
74
+ M=3,
75
+ beta=5.0,
76
+ device=device,
77
+ )
78
+ replay = ReplayBuffer(obs_dim, act_dim, capacity=1_000_000)
79
+
80
+ obs, _ = env.reset(seed=args.seed)
81
+ all_returns = []
82
+ best = -1e9
83
+
84
+ print(f"UGTC-TD3 | {args.env} | device={device} | η={args.eta}")
85
+ print(f"{'Steps':>10} {'Mean':>10} {'Std':>8} {'Gate':>8} {'Best':>10}")
86
+ print("-" * 56)
87
+
88
+ gate_metric = {}
89
+ for step in range(args.total_steps):
90
+ if step < args.start_steps:
91
+ action = env.action_space.sample()
92
+ else:
93
+ action = agent.select_action(obs, noise=0.1)
94
+
95
+ next_obs, reward, te, tr, _ = env.step(action)
96
+ done = te or tr
97
+ replay.add(obs, action, reward, next_obs, done)
98
+ obs = next_obs if not done else env.reset()[0]
99
+
100
+ if step >= args.start_steps:
101
+ gate_metric = agent.update(replay, args.batch_size)
102
+
103
+ if (step + 1) % args.eval_freq == 0:
104
+ ep_returns = evaluate(agent, args.env, args.eval_episodes)
105
+ mean_ret = float(np.mean(ep_returns))
106
+ std_ret = float(np.std(ep_returns))
107
+ best = max(best, mean_ret)
108
+ all_returns.append(ep_returns)
109
+ gate = gate_metric.get("gate_mean", float("nan"))
110
+ print(f"{step+1:>10} {mean_ret:>10.1f} {std_ret:>8.1f} {gate:>8.3f} {best:>10.1f}")
111
+
112
+ env.close()
113
+
114
+ if all_returns:
115
+ final_returns = [r[-1] for r in [ep for ep in all_returns[-1:]]]
116
+ flat = [r for ep_list in all_returns[-3:] for r in ep_list]
117
+ iqm = interquartile_mean(flat)
118
+ lo, hi = bootstrap_ci(flat)
119
+ print(f"\nFinal IQM: {iqm:.1f}")
120
+ print(f"95% CI: [{lo:.1f}, {hi:.1f}]")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
benchmarks/procgen/__init__.py ADDED
File without changes
benchmarks/procgen/train_ugtc_ppo_procgen.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UGTC-PPO on Procgen Benchmark (hard mode) — 25M environment steps.
4
+
5
+ Default configuration matches the paper:
6
+ - env: coinrun (hard mode, 500 training levels)
7
+ - evaluation: 500 unseen levels from level 10,000+
8
+ - total timesteps: 25,000,000
9
+ - vectorized envs: 64
10
+
11
+ UGTC hyperparameters (fixed across ALL benchmarks):
12
+ λ_fast = 0.80 (fast critic GAE lambda)
13
+ λ_slow = 0.99 (slow ensemble GAE lambda)
14
+ M = 3 (ensemble size)
15
+ β = 5.0 (gate temperature)
16
+ α_EMA = 0.99 (EMA momentum)
17
+
18
+ This script is derived from the benchmark code used in the paper experiments.
19
+ Results may vary by hardware, random seed, and library version.
20
+
21
+ Requirements:
22
+ pip install torch procgen gymnasium pandas psutil
23
+
24
+ Usage:
25
+ python benchmarks/procgen/train_ugtc_ppo_procgen.py
26
+ python benchmarks/procgen/train_ugtc_ppo_procgen.py --env_name bigfish
27
+ """
28
+
29
+ import json
30
+ import math
31
+ import os
32
+ import random
33
+ import time
34
+ import argparse
35
+ from pathlib import Path
36
+ from typing import Any, Dict
37
+
38
+ import numpy as np
39
+ import torch
40
+ import torch.nn as nn
41
+ import torch.optim as optim
42
+ from torch.distributions.categorical import Categorical
43
+
44
+ import gymnasium as gym
45
+
46
+ try:
47
+ import procgen2 # noqa
48
+ except ImportError:
49
+ try:
50
+ import procgen # noqa
51
+ except ImportError:
52
+ raise ImportError("Install procgen or procgen2: pip install procgen")
53
+
54
+ try:
55
+ import pandas as pd
56
+ HAS_PANDAS = True
57
+ except ImportError:
58
+ HAS_PANDAS = False
59
+
60
+ try:
61
+ import psutil
62
+ HAS_PSUTIL = True
63
+ except ImportError:
64
+ HAS_PSUTIL = False
65
+
66
+
67
+ # ── Default config ────────────────────────────────────────────────────────────
68
+
69
+ DEFAULT_CONFIG: Dict[str, Any] = {
70
+ "env_name": "coinrun",
71
+ "distribution_mode": "hard",
72
+ "num_levels": 500,
73
+ "start_level": 0,
74
+ "eval_start_level": 10000,
75
+ "eval_num_levels": 500,
76
+ "seed": 1,
77
+ "total_timesteps": 25_000_000,
78
+ "num_envs": 64,
79
+ "num_steps": 256,
80
+ "update_epochs": 3,
81
+ "num_minibatches": 8,
82
+ "gamma": 0.999,
83
+ # UGTC hyperparameters (fixed across all benchmarks)
84
+ "gae_lambda_fast": 0.80,
85
+ "gae_lambda_slow": 0.99,
86
+ "slow_critics": 3,
87
+ "uncertainty_beta": 5.0,
88
+ "running_unc_momentum": 0.99,
89
+ # PPO hyperparameters
90
+ "clip_coef": 0.2,
91
+ "ent_coef": 0.01,
92
+ "vf_coef_fast": 0.5,
93
+ "vf_coef_slow": 0.5,
94
+ "max_grad_norm": 0.5,
95
+ "learning_rate": 5e-4,
96
+ "anneal_lr": True,
97
+ "feature_dim": 256,
98
+ "hidden_size": 64,
99
+ # Eval / logging
100
+ "eval_freq": 1_000_000,
101
+ "n_eval_episodes": 20,
102
+ "log_freq": 10,
103
+ "save_model": True,
104
+ "root_dir": "runs_ugtc_ppo_procgen",
105
+ "device": "cuda" if torch.cuda.is_available() else "cpu",
106
+ }
107
+
108
+
109
+ # ── Utils ─────────────────────────────────────────────────────────────────────
110
+
111
+ def set_seed(seed: int):
112
+ random.seed(seed)
113
+ np.random.seed(seed)
114
+ torch.manual_seed(seed)
115
+ if torch.cuda.is_available():
116
+ torch.cuda.manual_seed_all(seed)
117
+
118
+
119
+ def make_procgen_env(env_name, distribution_mode, start_level, num_levels, seed):
120
+ for env_id in [f"procgen:procgen-{env_name}-v0", f"procgen-{env_name}-v0"]:
121
+ try:
122
+ env = gym.make(env_id, start_level=start_level, num_levels=num_levels,
123
+ distribution_mode=distribution_mode)
124
+ try:
125
+ env.reset(seed=seed)
126
+ except TypeError:
127
+ pass
128
+ return env
129
+ except Exception:
130
+ continue
131
+ raise RuntimeError(f"Cannot create Procgen env '{env_name}'")
132
+
133
+
134
+ def make_vector_envs(cfg, train=True):
135
+ start = cfg["start_level"] if train else cfg["eval_start_level"]
136
+ n_levels = cfg["num_levels"] if train else cfg["eval_num_levels"]
137
+ n_envs = cfg["num_envs"] if train else 1
138
+
139
+ def thunk(rank):
140
+ def _init():
141
+ return make_procgen_env(cfg["env_name"], cfg["distribution_mode"],
142
+ start_level=start, num_levels=n_levels,
143
+ seed=cfg["seed"] + rank)
144
+ return _init
145
+
146
+ return gym.vector.SyncVectorEnv([thunk(i) for i in range(n_envs)])
147
+
148
+
149
+ def obs_to_tensor(obs, device):
150
+ x = np.asarray(obs)
151
+ if x.ndim == 3:
152
+ x = x[None, ...]
153
+ return torch.as_tensor(np.transpose(x, (0, 3, 1, 2)).astype(np.float32) / 255.0,
154
+ dtype=torch.float32, device=device)
155
+
156
+
157
+ # ── Model ─────────────────────────────────────────────────────────────────────
158
+
159
+ class ResidualBlock(nn.Module):
160
+ def __init__(self, ch):
161
+ super().__init__()
162
+ self.block = nn.Sequential(
163
+ nn.ReLU(), nn.Conv2d(ch, ch, 3, 1, 1),
164
+ nn.ReLU(), nn.Conv2d(ch, ch, 3, 1, 1),
165
+ )
166
+
167
+ def forward(self, x):
168
+ return x + self.block(x)
169
+
170
+
171
+ class ImpalaBlock(nn.Module):
172
+ def __init__(self, in_ch, out_ch):
173
+ super().__init__()
174
+ self.conv = nn.Conv2d(in_ch, out_ch, 3, 1, 1)
175
+ self.pool = nn.MaxPool2d(3, 2, 1)
176
+ self.res = nn.Sequential(ResidualBlock(out_ch), ResidualBlock(out_ch))
177
+
178
+ def forward(self, x):
179
+ return self.res(self.pool(self.conv(x)))
180
+
181
+
182
+ class VisualEncoder(nn.Module):
183
+ def __init__(self, feature_dim):
184
+ super().__init__()
185
+ self.net = nn.Sequential(
186
+ ImpalaBlock(3, 16), ImpalaBlock(16, 32), ImpalaBlock(32, 32),
187
+ nn.ReLU(), nn.Flatten(),
188
+ )
189
+ with torch.no_grad():
190
+ flat = self.net(torch.zeros(1, 3, 64, 64)).shape[1]
191
+ self.proj = nn.Sequential(nn.Linear(flat, feature_dim), nn.ReLU())
192
+
193
+ def forward(self, x):
194
+ return self.proj(self.net(x))
195
+
196
+
197
+ class ValueHead(nn.Module):
198
+ def __init__(self, feature_dim, hidden):
199
+ super().__init__()
200
+ self.net = nn.Sequential(
201
+ nn.Linear(feature_dim, hidden), nn.Tanh(), nn.Linear(hidden, 1),
202
+ )
203
+ for m in self.modules():
204
+ if isinstance(m, nn.Linear):
205
+ nn.init.orthogonal_(m.weight, math.sqrt(2))
206
+ nn.init.constant_(m.bias, 0)
207
+ nn.init.orthogonal_(self.net[-1].weight, 1.0)
208
+
209
+ def forward(self, feat):
210
+ return self.net(feat).squeeze(-1)
211
+
212
+
213
+ class UGTCPPOAgent(nn.Module):
214
+ def __init__(self, n_actions, feature_dim, hidden, slow_critics):
215
+ super().__init__()
216
+ self.encoder = VisualEncoder(feature_dim)
217
+ self.policy_head = nn.Sequential(
218
+ nn.Linear(feature_dim, hidden), nn.Tanh(), nn.Linear(hidden, n_actions),
219
+ )
220
+ self.fast_value = ValueHead(feature_dim, hidden)
221
+ self.slow_values = nn.ModuleList(
222
+ [ValueHead(feature_dim, hidden) for _ in range(slow_critics)]
223
+ )
224
+ nn.init.orthogonal_(self.policy_head[-1].weight, 0.01)
225
+
226
+ def get_features(self, obs):
227
+ return self.encoder(obs)
228
+
229
+ def get_policy(self, feat):
230
+ return Categorical(logits=self.policy_head(feat))
231
+
232
+ def get_fast_value(self, feat):
233
+ return self.fast_value(feat)
234
+
235
+ def get_slow_values(self, feat):
236
+ return torch.stack([h(feat) for h in self.slow_values], dim=0) # [M, B]
237
+
238
+ def get_action_and_values(self, obs, action=None):
239
+ feat = self.get_features(obs)
240
+ dist = self.get_policy(feat)
241
+ if action is None:
242
+ action = dist.sample()
243
+ return action, dist.log_prob(action), dist.entropy(), self.get_fast_value(feat), self.get_slow_values(feat)
244
+
245
+
246
+ # ── GAE ───────────────────────────────────────────────────────────────────────
247
+
248
+ def compute_gae(rewards, dones, values, next_value, gamma, gae_lambda):
249
+ T, N = rewards.shape
250
+ adv = torch.zeros_like(rewards)
251
+ gae = torch.zeros(N, device=rewards.device)
252
+ for t in reversed(range(T)):
253
+ nxt = next_value if t == T - 1 else values[t + 1]
254
+ nt = 1.0 - (dones[t] if t == T - 1 else dones[t + 1])
255
+ delta = rewards[t] + gamma * nxt * nt - values[t]
256
+ gae = delta + gamma * gae_lambda * nt * gae
257
+ adv[t] = gae
258
+ return adv, adv + values
259
+
260
+
261
+ # ── Eval ──────────────────────────────────────────────────────────────────────
262
+
263
+ @torch.no_grad()
264
+ def evaluate(agent, cfg, device):
265
+ env = make_vector_envs(cfg, train=False)
266
+ returns = []
267
+ for ep in range(cfg["n_eval_episodes"]):
268
+ obs, _ = env.reset(seed=cfg["seed"] + 100000 + ep)
269
+ done = np.array([False])
270
+ ret = 0.0
271
+ while not done[0]:
272
+ obs_t = obs_to_tensor(obs, device)
273
+ feat = agent.get_features(obs_t)
274
+ action = torch.argmax(agent.get_policy(feat).logits, dim=-1)
275
+ out = env.step(action.cpu().numpy())
276
+ obs, reward = out[0], out[1]
277
+ done = np.logical_or(out[2], out[3]) if len(out) == 5 else out[2]
278
+ ret += float(reward[0])
279
+ returns.append(ret)
280
+ env.close()
281
+ return {"eval_mean": float(np.mean(returns)), "eval_std": float(np.std(returns))}
282
+
283
+
284
+ # ── Train ─────────────────────────────────────────────────────────────────────
285
+
286
+ def train(cfg: Dict[str, Any]) -> None:
287
+ set_seed(cfg["seed"])
288
+ device = torch.device(cfg["device"])
289
+ root = Path(cfg["root_dir"])
290
+ root.mkdir(parents=True, exist_ok=True)
291
+
292
+ envs = make_vector_envs(cfg, train=True)
293
+ n_actions = int(envs.single_action_space.n)
294
+ agent = UGTCPPOAgent(n_actions, cfg["feature_dim"], cfg["hidden_size"], cfg["slow_critics"]).to(device)
295
+ optimizer = optim.Adam(agent.parameters(), lr=cfg["learning_rate"], eps=1e-5)
296
+
297
+ num_updates = cfg["total_timesteps"] // (cfg["num_envs"] * cfg["num_steps"])
298
+ mb_size = (cfg["num_envs"] * cfg["num_steps"]) // cfg["num_minibatches"]
299
+
300
+ # Buffers
301
+ obs_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"], 3, 64, 64), device=device)
302
+ actions_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), dtype=torch.long, device=device)
303
+ logp_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device)
304
+ rew_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device)
305
+ done_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device)
306
+ fv_buf = torch.zeros((cfg["num_steps"], cfg["num_envs"]), device=device)
307
+ sv_buf = torch.zeros((cfg["num_steps"], cfg["slow_critics"], cfg["num_envs"]), device=device)
308
+
309
+ obs_raw, _ = envs.reset(seed=cfg["seed"])
310
+ next_obs = obs_to_tensor(obs_raw, device)
311
+ next_done = torch.zeros(cfg["num_envs"], device=device)
312
+
313
+ global_step = 0
314
+ next_eval = cfg["eval_freq"]
315
+ running_unc = 1.0
316
+ start_time = time.time()
317
+ train_logs, eval_logs = [], []
318
+
319
+ print("=" * 80)
320
+ print(f"UGTC-PPO | Procgen {cfg['env_name']} | {cfg['distribution_mode']} | {cfg['total_timesteps']:,} steps")
321
+ print(f"λ_fast={cfg['gae_lambda_fast']} λ_slow={cfg['gae_lambda_slow']} M={cfg['slow_critics']} β={cfg['uncertainty_beta']}")
322
+ print("=" * 80)
323
+
324
+ for update in range(1, num_updates + 1):
325
+ if cfg["anneal_lr"]:
326
+ frac = 1.0 - (update - 1) / num_updates
327
+ optimizer.param_groups[0]["lr"] = frac * cfg["learning_rate"]
328
+
329
+ for step in range(cfg["num_steps"]):
330
+ global_step += cfg["num_envs"]
331
+ obs_buf[step] = next_obs
332
+ done_buf[step] = next_done
333
+ with torch.no_grad():
334
+ action, logp, _, fv, sv = agent.get_action_and_values(next_obs)
335
+ actions_buf[step], logp_buf[step] = action, logp
336
+ fv_buf[step], sv_buf[step] = fv, sv
337
+ out = envs.step(action.cpu().numpy())
338
+ obs_raw, reward = out[0], out[1]
339
+ terminated, truncated = (out[2], out[3]) if len(out) == 5 else (out[2], out[2])
340
+ rew_buf[step] = torch.as_tensor(reward, dtype=torch.float32, device=device)
341
+ next_obs = obs_to_tensor(obs_raw, device)
342
+ next_done = torch.as_tensor(np.logical_or(terminated, truncated), dtype=torch.float32, device=device)
343
+
344
+ with torch.no_grad():
345
+ feat_next = agent.get_features(next_obs)
346
+ nfv = agent.get_fast_value(feat_next)
347
+ nsv = agent.get_slow_values(feat_next).mean(dim=0)
348
+ sv_mean = sv_buf.mean(dim=1)
349
+
350
+ adv_fast, ret_fast = compute_gae(rew_buf, done_buf, fv_buf, nfv, cfg["gamma"], cfg["gae_lambda_fast"])
351
+ adv_slow, ret_slow = compute_gae(rew_buf, done_buf, sv_mean, nsv, cfg["gamma"], cfg["gae_lambda_slow"])
352
+
353
+ sigma = sv_buf.var(dim=1).sqrt()
354
+ cur_unc = float(sigma.mean().item())
355
+ running_unc = cfg["running_unc_momentum"] * running_unc + (1 - cfg["running_unc_momentum"]) * cur_unc
356
+ gate = torch.sigmoid(-cfg["uncertainty_beta"] * (sigma / (running_unc + 1e-8) - 1.0))
357
+ blended_adv = gate * adv_slow + (1.0 - gate) * adv_fast
358
+
359
+ b_obs = obs_buf.reshape(-1, 3, 64, 64)
360
+ b_act = actions_buf.reshape(-1)
361
+ b_lp = logp_buf.reshape(-1)
362
+ b_fret = ret_fast.reshape(-1)
363
+ b_sret = ret_slow.reshape(-1)
364
+ b_adv = blended_adv.reshape(-1)
365
+ b_sv = sv_buf.permute(0, 2, 1).reshape(-1, cfg["slow_critics"])
366
+ b_gate = gate.reshape(-1)
367
+ b_adv = (b_adv - b_adv.mean()) / (b_adv.std() + 1e-8)
368
+ inds = np.arange(cfg["num_envs"] * cfg["num_steps"])
369
+
370
+ for _ in range(cfg["update_epochs"]):
371
+ np.random.shuffle(inds)
372
+ for start in range(0, len(inds), mb_size):
373
+ mb = inds[start:start + mb_size]
374
+ feat = agent.get_features(b_obs[mb])
375
+ dist = agent.get_policy(feat)
376
+ new_lp = dist.log_prob(b_act[mb])
377
+ entropy = dist.entropy()
378
+ fv_new = agent.get_fast_value(feat)
379
+ sv_new = agent.get_slow_values(feat).transpose(0, 1)
380
+
381
+ ratio = (new_lp - b_lp[mb]).exp()
382
+ mb_adv = b_adv[mb]
383
+ pg_loss = torch.max(
384
+ -mb_adv * ratio,
385
+ -mb_adv * torch.clamp(ratio, 1 - cfg["clip_coef"], 1 + cfg["clip_coef"])
386
+ ).mean()
387
+ fv_loss = 0.5 * ((fv_new - b_fret[mb]) ** 2).mean()
388
+ sv_loss = 0.5 * ((sv_new - b_sret[mb].unsqueeze(-1)) ** 2).mean()
389
+ loss = pg_loss - cfg["ent_coef"] * entropy.mean() + cfg["vf_coef_fast"] * fv_loss + cfg["vf_coef_slow"] * sv_loss
390
+ optimizer.zero_grad(set_to_none=True)
391
+ loss.backward()
392
+ nn.utils.clip_grad_norm_(agent.parameters(), cfg["max_grad_norm"])
393
+ optimizer.step()
394
+
395
+ if update % cfg["log_freq"] == 0:
396
+ elapsed = time.time() - start_time
397
+ log = {
398
+ "update": update, "timesteps": global_step,
399
+ "mean_gate": float(b_gate.mean().item()),
400
+ "running_unc": running_unc,
401
+ "sps": int(global_step / max(elapsed, 1e-9)),
402
+ }
403
+ train_logs.append(log)
404
+ print(f"[{update:5d}/{num_updates}] steps={global_step:>9,} gate={log['mean_gate']:.3f} unc={running_unc:.4f} sps={log['sps']}")
405
+
406
+ if global_step >= next_eval or update == num_updates:
407
+ ev = evaluate(agent, cfg, device)
408
+ eval_logs.append({"timesteps": global_step, **ev})
409
+ print(f" ► EVAL mean={ev['eval_mean']:.3f} std={ev['eval_std']:.3f}")
410
+ next_eval += cfg["eval_freq"]
411
+
412
+ envs.close()
413
+
414
+ if HAS_PANDAS:
415
+ pd.DataFrame(eval_logs).to_csv(root / "evals.csv", index=False)
416
+ pd.DataFrame(train_logs).to_csv(root / "train.csv", index=False)
417
+
418
+ summary = {
419
+ "algo": "UGTC-PPO", "env_name": cfg["env_name"],
420
+ "lambda_fast": cfg["gae_lambda_fast"], "lambda_slow": cfg["gae_lambda_slow"],
421
+ "M": cfg["slow_critics"], "beta": cfg["uncertainty_beta"],
422
+ "total_timesteps": global_step,
423
+ "best_eval": max((e["eval_mean"] for e in eval_logs), default=float("nan")),
424
+ "elapsed_sec": time.time() - start_time,
425
+ }
426
+ with open(root / "summary.json", "w") as f:
427
+ json.dump(summary, f, indent=2)
428
+
429
+ if cfg["save_model"]:
430
+ torch.save({"state_dict": agent.state_dict(), "config": cfg}, root / "model.pt")
431
+
432
+ print("\nTraining complete.")
433
+ print(f" Best eval return: {summary['best_eval']:.3f}")
434
+ print(f" Results saved to: {root}/")
435
+
436
+
437
+ if __name__ == "__main__":
438
+ parser = argparse.ArgumentParser()
439
+ parser.add_argument("--env_name", type=str, default="coinrun")
440
+ parser.add_argument("--seed", type=int, default=1)
441
+ parser.add_argument("--total_timesteps", type=int, default=25_000_000)
442
+ parser.add_argument("--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu")
443
+ args = parser.parse_args()
444
+ cfg = {**DEFAULT_CONFIG, **vars(args)}
445
+ train(cfg)
configs/ant.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ env_name: "Ant-v5"
2
+ hidden_dim: 256
3
+ lr: 3e-4
4
+ gamma: 0.99
5
+ batch_size: 2048
6
+ total_steps: 10000000
7
+ eval_interval: 50000
8
+ eval_episodes: 20
configs/carracing.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ env_name: "CarRacing-v3"
2
+ hidden_dim: 128
3
+ lr: 2.5e-4
4
+ gamma: 0.99
5
+ batch_size: 2048
6
+ total_steps: 2200000
7
+ eval_interval: 50000
8
+ eval_episodes: 20
configs/crafter.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ env_name: "Crafter"
2
+ hidden_dim: 128
3
+ lr: 3e-4
4
+ gamma: 0.99
5
+ batch_size: 128
6
+ total_steps: 3000000
7
+ eval_interval: 50000
8
+ eval_episodes: 20
configs/hopper.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ env_name: "Hopper-v4"
2
+ hidden_dim: 64
3
+ lr: 3e-4
4
+ gamma: 0.99
5
+ batch_size: 2048
6
+ total_steps: 15000000
7
+ eval_interval: 50000
8
+ eval_episodes: 20
configs/metaworld.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ env_name: "MetaWorld-ML45"
2
+ hidden_dim: 256
3
+ lr: 3e-4
4
+ gamma: 0.99
5
+ batch_size: 512
6
+ total_steps: 25000000
7
+ eval_interval: 500000
8
+ eval_episodes: 10
9
+ backbone: "dreamerv3"
configs/procgen.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ env_name: "procgen:CoinRun"
2
+ hidden_dim: 256
3
+ lr: 2.5e-4
4
+ gamma: 0.999
5
+ batch_size: 4096
6
+ total_steps: 200000000
7
+ eval_interval: 50000
8
+ eval_episodes: 20
9
+ num_envs: 16
docs/index.html ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>UGTC — Uncertainty-Gated Temporal Credit</title>
7
+ <meta name="description" content="UGTC: A backbone-agnostic advantage estimator for actor-critic reinforcement learning, published at UYES Journal.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js"></script>
11
+ <style>
12
+ :root {
13
+ --bg: #0a0e17;
14
+ --bg2: #111827;
15
+ --bg3: #1f2937;
16
+ --border: #374151;
17
+ --accent: #6366f1;
18
+ --accent2: #8b5cf6;
19
+ --accent3: #06b6d4;
20
+ --text: #f1f5f9;
21
+ --muted: #94a3b8;
22
+ --green: #10b981;
23
+ --orange: #f59e0b;
24
+ --red: #ef4444;
25
+ }
26
+ * { margin: 0; padding: 0; box-sizing: border-box; }
27
+ body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; }
28
+ code, pre, .mono { font-family: 'JetBrains Mono', monospace; }
29
+
30
+ /* Nav */
31
+ nav {
32
+ position: sticky; top: 0; z-index: 100;
33
+ background: rgba(10,14,23,0.95);
34
+ backdrop-filter: blur(12px);
35
+ border-bottom: 1px solid var(--border);
36
+ padding: 0 2rem;
37
+ display: flex; align-items: center; justify-content: space-between;
38
+ height: 60px;
39
+ }
40
+ .nav-logo { font-weight: 700; font-size: 1.1rem; color: var(--accent); letter-spacing: -0.02em; }
41
+ .nav-links { display: flex; gap: 1.5rem; list-style: none; }
42
+ .nav-links a { color: var(--muted); text-decoration: none; font-size: 0.9rem; transition: color 0.2s; }
43
+ .nav-links a:hover { color: var(--text); }
44
+
45
+ /* Hero */
46
+ .hero {
47
+ text-align: center;
48
+ padding: 6rem 2rem 4rem;
49
+ max-width: 900px;
50
+ margin: 0 auto;
51
+ }
52
+ .hero-badge {
53
+ display: inline-flex; align-items: center; gap: 0.5rem;
54
+ background: rgba(99,102,241,0.12);
55
+ border: 1px solid rgba(99,102,241,0.3);
56
+ color: var(--accent);
57
+ padding: 0.4rem 1rem; border-radius: 9999px;
58
+ font-size: 0.8rem; font-weight: 600; letter-spacing: 0.05em;
59
+ text-transform: uppercase; margin-bottom: 1.5rem;
60
+ }
61
+ h1 {
62
+ font-size: clamp(2.2rem, 5vw, 3.5rem);
63
+ font-weight: 700;
64
+ letter-spacing: -0.03em;
65
+ background: linear-gradient(135deg, #fff 0%, #a5b4fc 100%);
66
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
67
+ margin-bottom: 0.5rem;
68
+ }
69
+ .hero-subtitle {
70
+ font-size: 1.2rem; color: var(--muted); margin-bottom: 1.5rem; max-width: 600px; margin-left: auto; margin-right: auto;
71
+ }
72
+ .hero-tagline {
73
+ font-size: 0.95rem; color: var(--muted); margin-bottom: 2rem;
74
+ max-width: 700px; margin-left: auto; margin-right: auto;
75
+ }
76
+ .badges { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-bottom: 2.5rem; }
77
+ .badge { display: inline-block; }
78
+ .badge img { height: 24px; }
79
+
80
+ .btn-group { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
81
+ .btn {
82
+ padding: 0.75rem 1.75rem; border-radius: 8px; text-decoration: none;
83
+ font-weight: 600; font-size: 0.95rem; transition: all 0.2s;
84
+ display: inline-flex; align-items: center; gap: 0.5rem;
85
+ }
86
+ .btn-primary {
87
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
88
+ color: white;
89
+ }
90
+ .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(99,102,241,0.4); }
91
+ .btn-secondary {
92
+ background: var(--bg3); border: 1px solid var(--border); color: var(--text);
93
+ }
94
+ .btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
95
+
96
+ /* Main layout */
97
+ main { max-width: 1100px; margin: 0 auto; padding: 0 2rem 6rem; }
98
+
99
+ /* Section */
100
+ section { margin-bottom: 4rem; }
101
+ h2 {
102
+ font-size: 1.7rem; font-weight: 700; letter-spacing: -0.02em;
103
+ margin-bottom: 1.5rem; color: var(--text);
104
+ padding-bottom: 0.75rem;
105
+ border-bottom: 1px solid var(--border);
106
+ }
107
+ h3 { font-size: 1.2rem; font-weight: 600; margin: 1.5rem 0 0.75rem; color: var(--text); }
108
+
109
+ /* Cards */
110
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.25rem; }
111
+ .card {
112
+ background: var(--bg2); border: 1px solid var(--border);
113
+ border-radius: 12px; padding: 1.5rem;
114
+ transition: border-color 0.2s, transform 0.2s;
115
+ }
116
+ .card:hover { border-color: var(--accent); transform: translateY(-2px); }
117
+ .card-icon { font-size: 1.75rem; margin-bottom: 0.75rem; }
118
+ .card h3 { margin: 0 0 0.5rem; font-size: 1rem; }
119
+ .card p { font-size: 0.9rem; color: var(--muted); margin: 0; }
120
+
121
+ /* Architecture diagram */
122
+ .arch-box {
123
+ background: var(--bg2); border: 1px solid var(--border);
124
+ border-radius: 12px; padding: 2rem; overflow-x: auto;
125
+ }
126
+ .arch-box pre { font-size: 0.82rem; line-height: 1.6; color: var(--text); }
127
+
128
+ /* Math */
129
+ .math-block {
130
+ background: var(--bg2); border: 1px solid var(--border);
131
+ border-radius: 8px; padding: 1.5rem; margin: 1rem 0;
132
+ overflow-x: auto;
133
+ }
134
+
135
+ /* Table */
136
+ table { width: 100%; border-collapse: collapse; }
137
+ th { background: var(--bg3); padding: 0.75rem 1rem; text-align: left; font-size: 0.85rem; color: var(--muted); font-weight: 600; }
138
+ td { padding: 0.75rem 1rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
139
+ tr:hover td { background: var(--bg2); }
140
+ .tag {
141
+ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px;
142
+ font-size: 0.75rem; font-weight: 600; font-family: 'JetBrains Mono', monospace;
143
+ }
144
+ .tag-green { background: rgba(16,185,129,0.12); color: var(--green); }
145
+ .tag-blue { background: rgba(6,182,212,0.12); color: var(--accent3); }
146
+ .tag-purple { background: rgba(99,102,241,0.12); color: var(--accent); }
147
+
148
+ /* Code block */
149
+ .code-block {
150
+ background: #0d1117; border: 1px solid var(--border);
151
+ border-radius: 8px; padding: 1.25rem 1.5rem;
152
+ overflow-x: auto; position: relative;
153
+ }
154
+ .code-block pre { font-size: 0.85rem; line-height: 1.7; color: #e6edf3; }
155
+ .code-label {
156
+ position: absolute; top: 0.75rem; right: 1rem;
157
+ font-size: 0.7rem; color: var(--muted); font-family: 'JetBrains Mono', monospace;
158
+ }
159
+
160
+ /* Gate visualizer */
161
+ .gate-viz {
162
+ display: flex; flex-direction: column; gap: 0.5rem; padding: 1.5rem;
163
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
164
+ }
165
+ .gate-row { display: flex; align-items: center; gap: 1rem; }
166
+ .gate-label { font-size: 0.85rem; color: var(--muted); width: 120px; flex-shrink: 0; }
167
+ .gate-bar-bg { flex: 1; height: 8px; background: var(--bg3); border-radius: 4px; overflow: hidden; }
168
+ .gate-bar { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
169
+ .gate-value { font-size: 0.8rem; font-family: monospace; color: var(--muted); width: 40px; text-align: right; }
170
+
171
+ /* Footer */
172
+ footer {
173
+ border-top: 1px solid var(--border);
174
+ padding: 2rem;
175
+ text-align: center;
176
+ color: var(--muted);
177
+ font-size: 0.85rem;
178
+ }
179
+ footer a { color: var(--accent); text-decoration: none; }
180
+ footer a:hover { text-decoration: underline; }
181
+
182
+ @media (max-width: 640px) {
183
+ nav { padding: 0 1rem; }
184
+ .nav-links { display: none; }
185
+ .hero { padding: 4rem 1rem 3rem; }
186
+ main { padding: 0 1rem 4rem; }
187
+ }
188
+ </style>
189
+ </head>
190
+ <body>
191
+
192
+ <nav>
193
+ <div class="nav-logo">UGTC</div>
194
+ <ul class="nav-links">
195
+ <li><a href="#architecture">Architecture</a></li>
196
+ <li><a href="#math">Mathematics</a></li>
197
+ <li><a href="#algorithms">Algorithms</a></li>
198
+ <li><a href="#quickstart">Quick Start</a></li>
199
+ <li><a href="https://github.com/ethosoftai/ugtc">GitHub</a></li>
200
+ <li><a href="https://doi.org/10.5281/zenodo.19715116">Paper</a></li>
201
+ </ul>
202
+ </nav>
203
+
204
+ <div class="hero">
205
+ <div class="hero-badge">📄 Published · UYES Journal · 2026</div>
206
+ <h1>Uncertainty-Gated Temporal Credit</h1>
207
+ <p class="hero-subtitle">A plug-in advantage estimator for actor-critic reinforcement learning</p>
208
+ <p class="hero-tagline">
209
+ UGTC dynamically blends short-horizon (low-variance) and long-horizon (low-bias) advantage
210
+ estimates using a sigmoid gate driven by critic ensemble disagreement — resolving the
211
+ bias–variance trade-off in temporal credit assignment.
212
+ </p>
213
+ <div class="badges">
214
+ <span class="badge"><img src="https://img.shields.io/badge/Paper-Zenodo%2019715116-blue?style=flat-square&logo=zenodo" alt="Paper"></span>
215
+ <span class="badge"><img src="https://img.shields.io/badge/Published-UYES%20Journal-green?style=flat-square" alt="UYES"></span>
216
+ <span class="badge"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat-square" alt="License"></span>
217
+ <span class="badge"><img src="https://img.shields.io/badge/Python-3.10%2B-blue?style=flat-square&logo=python" alt="Python"></span>
218
+ <span class="badge"><img src="https://img.shields.io/badge/PyTorch-2.2%2B-ee4c2c?style=flat-square&logo=pytorch" alt="PyTorch"></span>
219
+ </div>
220
+ <div class="btn-group">
221
+ <a href="https://github.com/ethosoftai/ugtc" class="btn btn-primary">⭐ View on GitHub</a>
222
+ <a href="https://doi.org/10.5281/zenodo.19715116" class="btn btn-secondary">📄 Read Paper</a>
223
+ <a href="https://huggingface.co/spaces/Ethosoft/ugtc" class="btn btn-secondary">🤗 Live Demo</a>
224
+ </div>
225
+ </div>
226
+
227
+ <main>
228
+
229
+ <!-- Key Features -->
230
+ <section>
231
+ <h2>Key Features</h2>
232
+ <div class="cards">
233
+ <div class="card">
234
+ <div class="card-icon">🔌</div>
235
+ <h3>Backbone-Agnostic</h3>
236
+ <p>Drop UGTC into any actor-critic algorithm by replacing the advantage computation. Tested with PPO, TD3, SAC.</p>
237
+ </div>
238
+ <div class="card">
239
+ <div class="card-icon">🎯</div>
240
+ <h3>Adaptive Credit Assignment</h3>
241
+ <p>Automatically selects between short-horizon and long-horizon GAE estimates based on per-state uncertainty.</p>
242
+ </div>
243
+ <div class="card">
244
+ <div class="card-icon">📐</div>
245
+ <h3>Fixed Hyperparameters</h3>
246
+ <p>λ_fast=0.80, λ_slow=0.99, M=3, β=5.0. Same across all benchmarks — no per-task tuning required.</p>
247
+ </div>
248
+ <div class="card">
249
+ <div class="card-icon">🔬</div>
250
+ <h3>Ensemble Uncertainty</h3>
251
+ <p>Slow critic ensemble disagreement provides calibrated uncertainty estimates without Bayesian inference.</p>
252
+ </div>
253
+ <div class="card">
254
+ <div class="card-icon">⚡</div>
255
+ <h3>Lightweight Overhead</h3>
256
+ <p>Three small MLP value heads. Minimal parameter and compute overhead relative to actor network.</p>
257
+ </div>
258
+ <div class="card">
259
+ <div class="card-icon">🌐</div>
260
+ <h3>Multi-Language</h3>
261
+ <p>Reference implementations in Python, C++ (header-only), and Java for portability.</p>
262
+ </div>
263
+ </div>
264
+ </section>
265
+
266
+ <!-- Architecture -->
267
+ <section id="architecture">
268
+ <h2>Architecture</h2>
269
+ <div class="arch-box">
270
+ <pre>
271
+ ┌─────────────────────────────────────────────────────────────────────────────┐
272
+ │ UGTC MODULE │
273
+ │ │
274
+ │ Input: s (observation) │
275
+ │ │
276
+ │ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │
277
+ │ │ Fast Critic │ │ Slow Ensemble (M=3) │ │
278
+ │ │ V_fast(s) │ │ V¹(s) V²(s) V³(s) │ │
279
+ │ │ λ_fast = 0.80 │ │ (independent parameters, λ = 0.99) │ │
280
+ │ └────────┬─────────┘ └──────────────────┬──────────────────────── ┘ │
281
+ │ │ │ │
282
+ │ │ ┌─────────────┴───────────────┐ │
283
+ │ │ │ σ(s) = std(V¹,V²,V³)(s) │ │
284
+ │ │ │ Ensemble Disagreement │ │
285
+ │ │ └─────────────┬───────────────┘ │
286
+ │ │ │ │
287
+ │ │ ┌─────────────▼───────────────┐ │
288
+ │ │ │ EMA Normalization │ │
289
+ │ │ │ σ_EMA ← α·σ_EMA + (1-α)·σ │ │
290
+ │ │ │ σ̂(s) = σ(s) / (σ_EMA + ε) │ │
291
+ │ │ └─────────────┬───────────────┘ │
292
+ │ │ │ │
293
+ │ │ ┌─────────────▼───────────────┐ │
294
+ │ │ │ Sigmoid Gate │ │
295
+ │ │ │ u(s) = σ(-β·(σ̂(s) - 1)) │ │
296
+ │ │ └─────────────┬───────────────┘ │
297
+ │ │ │ │
298
+ │ ┌────────▼───────────────────────────────────▼─────────────────────────┐ │
299
+ │ │ A^UGTC = u(s) · A^slow + (1 - u(s)) · A^fast │ │
300
+ │ │ Blended Advantage Estimate │ │
301
+ │ └───────────────────────────────────────────────────────────────────────┘ │
302
+ └─────────────────────────────────────────────────────────────────────────────┘
303
+ </pre>
304
+ </div>
305
+
306
+ <h3>Gate Behavior</h3>
307
+ <div class="gate-viz">
308
+ <div class="gate-row">
309
+ <span class="gate-label">Low uncertainty</span>
310
+ <div class="gate-bar-bg"><div class="gate-bar" style="width:92%;background:linear-gradient(90deg,#6366f1,#8b5cf6)"></div></div>
311
+ <span class="gate-value">u → 1</span>
312
+ <span style="font-size:0.8rem;color:#10b981;">→ use A^slow (accurate)</span>
313
+ </div>
314
+ <div class="gate-row">
315
+ <span class="gate-label">Medium uncertainty</span>
316
+ <div class="gate-bar-bg"><div class="gate-bar" style="width:50%;background:linear-gradient(90deg,#6366f1,#06b6d4)"></div></div>
317
+ <span class="gate-value">u = 0.5</span>
318
+ <span style="font-size:0.8rem;color:#94a3b8;">→ equal blend</span>
319
+ </div>
320
+ <div class="gate-row">
321
+ <span class="gate-label">High uncertainty</span>
322
+ <div class="gate-bar-bg"><div class="gate-bar" style="width:8%;background:linear-gradient(90deg,#f59e0b,#ef4444)"></div></div>
323
+ <span class="gate-value">u → 0</span>
324
+ <span style="font-size:0.8rem;color:#f59e0b;">→ use A^fast (stable)</span>
325
+ </div>
326
+ </div>
327
+ </section>
328
+
329
+ <!-- Mathematics -->
330
+ <section id="math">
331
+ <h2>Mathematical Foundation</h2>
332
+
333
+ <h3>Generalized Advantage Estimation</h3>
334
+ <div class="math-block">
335
+ \[
336
+ \delta_t = r_t + \gamma V(s_{t+1})(1 - d_t) - V(s_t)
337
+ \]
338
+ \[
339
+ A_t^{\text{GAE}} = \sum_{k=0}^{\infty} (\gamma\lambda)^k \delta_{t+k}
340
+ \]
341
+ </div>
342
+
343
+ <h3>UGTC Dual-Stream Computation</h3>
344
+ <div class="math-block">
345
+ \[
346
+ A_t^{\text{fast}} = \text{GAE}\!\left(\tau,\, V_{\text{fast}},\, \lambda_{\text{fast}} = 0.80\right)
347
+ \]
348
+ \[
349
+ A_t^{\text{slow}} = \text{GAE}\!\left(\tau,\, \bar{V}_{\text{slow}},\, \lambda_{\text{slow}} = 0.99\right)
350
+ \]
351
+ <p style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem;">
352
+ where \(\bar{V}_{\text{slow}} = \frac{1}{M}\sum_{m=1}^{M} V^m_{\text{slow}}\) (ensemble mean, M = 3)
353
+ </p>
354
+ </div>
355
+
356
+ <h3>Uncertainty Gate</h3>
357
+ <div class="math-block">
358
+ \[
359
+ \sigma(s) = \text{std}\!\left(V^1_{\text{slow}}(s),\, \ldots,\, V^M_{\text{slow}}(s)\right)
360
+ \]
361
+ \[
362
+ \hat{\sigma}(s) = \frac{\sigma(s)}{\sigma_{\text{EMA}} + \varepsilon}, \qquad
363
+ \sigma_{\text{EMA}} \leftarrow \alpha \cdot \sigma_{\text{EMA}} + (1-\alpha)\cdot\mathbb{E}[\sigma(s)]
364
+ \]
365
+ \[
366
+ u(s) = \sigma\!\left(-\beta \cdot (\hat{\sigma}(s) - 1)\right)
367
+ \]
368
+ </div>
369
+
370
+ <h3>Blended Advantage</h3>
371
+ <div class="math-block">
372
+ \[
373
+ \boxed{A_t^{\text{UGTC}} = u(s_t) \cdot A_t^{\text{slow}} + (1 - u(s_t)) \cdot A_t^{\text{fast}}}
374
+ \]
375
+ </div>
376
+
377
+ <h3>Fixed Hyperparameters</h3>
378
+ <table>
379
+ <thead>
380
+ <tr><th>Parameter</th><th>Symbol</th><th>Value</th><th>Description</th></tr>
381
+ </thead>
382
+ <tbody>
383
+ <tr><td>Fast λ</td><td>\(\lambda_{\text{fast}}\)</td><td><span class="tag tag-green">0.80</span></td><td>GAE lambda for fast critic (low variance)</td></tr>
384
+ <tr><td>Slow λ</td><td>\(\lambda_{\text{slow}}\)</td><td><span class="tag tag-green">0.99</span></td><td>GAE lambda for slow ensemble (low bias)</td></tr>
385
+ <tr><td>Ensemble size</td><td>M</td><td><span class="tag tag-blue">3</span></td><td>Number of slow critic heads</td></tr>
386
+ <tr><td>Gate temperature</td><td>β</td><td><span class="tag tag-purple">5.0</span></td><td>Sigmoid sharpness</td></tr>
387
+ <tr><td>EMA momentum</td><td>α</td><td><span class="tag tag-green">0.99</span></td><td>Running uncertainty normalization</td></tr>
388
+ </tbody>
389
+ </table>
390
+ </section>
391
+
392
+ <!-- Algorithms -->
393
+ <section id="algorithms">
394
+ <h2>RL Algorithm Integrations</h2>
395
+ <div class="cards">
396
+ <div class="card">
397
+ <h3>UGTC-PPO</h3>
398
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:0.75rem;">
399
+ <span class="tag tag-green">On-policy</span>
400
+ </p>
401
+ <p>A^UGTC replaces standard GAE in the clipped surrogate objective. All UGTC critics trained via same regression pipeline.</p>
402
+ </div>
403
+ <div class="card">
404
+ <h3>UGTC-TD3</h3>
405
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:0.75rem;">
406
+ <span class="tag tag-blue">Off-policy</span>
407
+ </p>
408
+ <p>UGTC provides baseline correction for the actor: L = -(Q_min + η·A^UGTC). Twin-Q and delayed update preserved.</p>
409
+ </div>
410
+ <div class="card">
411
+ <h3>UGTC-SAC</h3>
412
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:0.75rem;">
413
+ <span class="tag tag-blue">Off-policy</span>
414
+ </p>
415
+ <p>V^UGTC replaces implicit value baseline in the entropy-regularized actor loss. Auto-α entropy tuning unchanged.</p>
416
+ </div>
417
+ <div class="card">
418
+ <h3>UGTC-DDPG</h3>
419
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:0.75rem;">
420
+ <span class="tag tag-purple">Extension</span>
421
+ </p>
422
+ <p>Proposed extension following TD3 integration logic. Not benchmarked in the paper — labeled as implementation assumption.</p>
423
+ </div>
424
+ </div>
425
+ </section>
426
+
427
+ <!-- Quick Start -->
428
+ <section id="quickstart">
429
+ <h2>Quick Start</h2>
430
+
431
+ <h3>Installation</h3>
432
+ <div class="code-block">
433
+ <span class="code-label">bash</span>
434
+ <pre>git clone https://github.com/ethosoftai/ugtc.git
435
+ cd ugtc
436
+ pip install -e .</pre>
437
+ </div>
438
+
439
+ <h3>Minimal Usage</h3>
440
+ <div class="code-block">
441
+ <span class="code-label">python</span>
442
+ <pre>from ugtc import UGTCModule
443
+
444
+ # Create UGTC module (obs_dim=17 for Hopper-v4)
445
+ ugtc = UGTCModule(obs_dim=17)
446
+
447
+ # Replace standard GAE in your PPO update:
448
+ advantages = ugtc.compute_advantages(
449
+ obs=obs, # (T, obs_dim)
450
+ next_obs=next_obs, # (T, obs_dim)
451
+ rewards=rewards, # (T,)
452
+ dones=dones, # (T,)
453
+ gamma=0.99,
454
+ )
455
+
456
+ # Same as before: normalize and use in clipped surrogate
457
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)</pre>
458
+ </div>
459
+
460
+ <h3>Run an Example</h3>
461
+ <div class="code-block">
462
+ <span class="code-label">bash</span>
463
+ <pre># UGTC-PPO on CartPole-v1 (no MuJoCo needed)
464
+ python examples/ugtc_ppo_cartpole.py
465
+
466
+ # UGTC-PPO on Hopper-v4 (requires MuJoCo)
467
+ python examples/ugtc_ppo_mujoco.py --env Hopper-v4
468
+
469
+ # UGTC-TD3 on Pendulum-v1
470
+ python examples/ugtc_td3_pendulum.py</pre>
471
+ </div>
472
+ </section>
473
+
474
+ <!-- Citation -->
475
+ <section>
476
+ <h2>Citation</h2>
477
+ <div class="code-block">
478
+ <pre>@misc{dalar2026ugtc,
479
+ author = {Dalar, Yağız Ekrem},
480
+ title = {{UGTC}: Uncertainty-Gated Temporal Credit},
481
+ year = {2026},
482
+ publisher = {Zenodo},
483
+ doi = {10.5281/zenodo.19715116},
484
+ url = {https://doi.org/10.5281/zenodo.19715116},
485
+ note = {Accepted — Ulysseus Young Explorers in Science (UYES) Journal.
486
+ Journal DOI forthcoming.}
487
+ }</pre>
488
+ </div>
489
+ </section>
490
+
491
+ </main>
492
+
493
+ <footer>
494
+ <p>
495
+ UGTC · <a href="https://github.com/ethosoftai">Ethosoft AI</a> ·
496
+ <a href="https://doi.org/10.5281/zenodo.19715116">Paper</a> ·
497
+ <a href="https://github.com/ethosoftai/ugtc">GitHub</a> ·
498
+ <a href="https://huggingface.co/spaces/Ethosoft/ugtc">HuggingFace</a>
499
+ </p>
500
+ <p style="margin-top:0.5rem;">MIT License · Accepted at Ulysseus Young Explorers in Science (UYES) Journal</p>
501
+ </footer>
502
+
503
+ </body>
504
+ </html>
examples/ugtc_ppo_cartpole.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UGTC-PPO on CartPole-v1 — minimal runnable example.
4
+
5
+ Demonstrates the drop-in nature of UGTC: the only change from vanilla PPO
6
+ is passing advantages through UGTCModule.compute_advantages() instead of
7
+ standard single-critic GAE.
8
+
9
+ Requirements:
10
+ pip install torch gymnasium
11
+
12
+ Usage:
13
+ python examples/ugtc_ppo_cartpole.py
14
+ python examples/ugtc_ppo_cartpole.py --episodes 500 --seed 42
15
+ """
16
+
17
+ import argparse
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.optim as optim
22
+ from torch.distributions import Categorical
23
+
24
+ import gymnasium as gym
25
+
26
+ from ugtc import UGTCModule
27
+
28
+
29
+ # ── Config ──────────────────────────────────────────────────────────────────
30
+
31
+ def get_args():
32
+ p = argparse.ArgumentParser()
33
+ p.add_argument("--episodes", type=int, default=300)
34
+ p.add_argument("--steps_per_update", type=int, default=128)
35
+ p.add_argument("--hidden", type=int, default=64)
36
+ p.add_argument("--lr", type=float, default=3e-4)
37
+ p.add_argument("--gamma", type=float, default=0.99)
38
+ p.add_argument("--clip_eps", type=float, default=0.2)
39
+ p.add_argument("--ppo_epochs", type=int, default=4)
40
+ p.add_argument("--seed", type=int, default=0)
41
+ # UGTC hyperparameters (fixed across all benchmarks in the paper)
42
+ p.add_argument("--lambda_fast", type=float, default=0.80)
43
+ p.add_argument("--lambda_slow", type=float, default=0.99)
44
+ p.add_argument("--M", type=int, default=3)
45
+ p.add_argument("--beta", type=float, default=5.0)
46
+ return p.parse_args()
47
+
48
+
49
+ # ── Policy ───────────────────────────────────────────────────────────────────
50
+
51
+ class DiscretePolicy(nn.Module):
52
+ def __init__(self, obs_dim, n_actions, hidden):
53
+ super().__init__()
54
+ self.net = nn.Sequential(
55
+ nn.Linear(obs_dim, hidden), nn.Tanh(),
56
+ nn.Linear(hidden, hidden), nn.Tanh(),
57
+ nn.Linear(hidden, n_actions),
58
+ )
59
+
60
+ def forward(self, obs):
61
+ return Categorical(logits=self.net(obs))
62
+
63
+
64
+ # ── Training ─────────────────────────────────────────────────────────────────
65
+
66
+ def collect_rollout(env, policy, ugtc, steps, gamma, device):
67
+ obs_list, action_list, reward_list, done_list, logp_list, next_obs_list = [], [], [], [], [], []
68
+ obs, _ = env.reset()
69
+
70
+ for _ in range(steps):
71
+ obs_t = torch.FloatTensor(obs).unsqueeze(0).to(device)
72
+ with torch.no_grad():
73
+ dist = policy(obs_t)
74
+ action = dist.sample()
75
+ log_prob = dist.log_prob(action)
76
+
77
+ next_obs, reward, terminated, truncated, _ = env.step(action.item())
78
+ done = terminated or truncated
79
+
80
+ obs_list.append(obs)
81
+ action_list.append(action.item())
82
+ reward_list.append(reward)
83
+ done_list.append(float(done))
84
+ logp_list.append(log_prob.item())
85
+ next_obs_list.append(next_obs)
86
+
87
+ obs = next_obs if not done else env.reset()[0]
88
+
89
+ return {
90
+ "obs": torch.FloatTensor(np.array(obs_list)).to(device),
91
+ "actions": torch.LongTensor(action_list).to(device),
92
+ "rewards": torch.FloatTensor(reward_list).to(device),
93
+ "dones": torch.FloatTensor(done_list).to(device),
94
+ "log_probs": torch.FloatTensor(logp_list).to(device),
95
+ "next_obs": torch.FloatTensor(np.array(next_obs_list)).to(device),
96
+ }
97
+
98
+
99
+ def main():
100
+ args = get_args()
101
+ torch.manual_seed(args.seed)
102
+ np.random.seed(args.seed)
103
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
104
+
105
+ env = gym.make("CartPole-v1")
106
+ obs_dim = env.observation_space.shape[0]
107
+ n_actions = env.action_space.n
108
+
109
+ policy = DiscretePolicy(obs_dim, n_actions, args.hidden).to(device)
110
+ ugtc = UGTCModule(
111
+ obs_dim=obs_dim,
112
+ hidden_dim=args.hidden,
113
+ M=args.M,
114
+ lambda_fast=args.lambda_fast,
115
+ lambda_slow=args.lambda_slow,
116
+ beta=args.beta,
117
+ ).to(device)
118
+
119
+ optimizer = optim.Adam(
120
+ list(policy.parameters()) + list(ugtc.parameters()), lr=args.lr
121
+ )
122
+
123
+ print(f"UGTC-PPO on CartPole-v1 | device={device}")
124
+ print(f"λ_fast={args.lambda_fast} λ_slow={args.lambda_slow} M={args.M} β={args.beta}")
125
+ print(f"{'Episode':>8} {'Return':>8} {'Gate':>8} {'σ_EMA':>8}")
126
+ print("-" * 44)
127
+
128
+ episode_returns = []
129
+ ep_return = 0.0
130
+ ep_count = 0
131
+
132
+ for update in range(1, args.episodes + 1):
133
+ rollout = collect_rollout(env, policy, ugtc, args.steps_per_update, args.gamma, device)
134
+
135
+ # === KEY CHANGE: UGTC advantages ===
136
+ ugtc.train()
137
+ advantages = ugtc.compute_advantages(
138
+ obs=rollout["obs"],
139
+ next_obs=rollout["next_obs"],
140
+ rewards=rollout["rewards"],
141
+ dones=rollout["dones"],
142
+ gamma=args.gamma,
143
+ )
144
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
145
+
146
+ returns = (advantages + ugtc.get_value_ugtc(rollout["obs"])).detach()
147
+
148
+ for _ in range(args.ppo_epochs):
149
+ dist = policy(rollout["obs"])
150
+ new_log_probs = dist.log_prob(rollout["actions"])
151
+ ratio = (new_log_probs - rollout["log_probs"]).exp()
152
+
153
+ surr1 = ratio * advantages
154
+ surr2 = ratio.clamp(1 - args.clip_eps, 1 + args.clip_eps) * advantages
155
+ policy_loss = -torch.min(surr1, surr2).mean()
156
+
157
+ fast_loss = (ugtc.fast_critic(rollout["obs"]) - returns).pow(2).mean()
158
+ slow_loss = torch.stack([
159
+ (m(rollout["obs"]) - returns).pow(2).mean()
160
+ for m in ugtc.slow_ensemble.members
161
+ ]).mean()
162
+
163
+ loss = policy_loss + 0.5 * (fast_loss + slow_loss)
164
+ optimizer.zero_grad()
165
+ loss.backward()
166
+ nn.utils.clip_grad_norm_(
167
+ list(policy.parameters()) + list(ugtc.parameters()), 0.5
168
+ )
169
+ optimizer.step()
170
+
171
+ # Eval
172
+ if update % 20 == 0:
173
+ eval_returns = []
174
+ for _ in range(10):
175
+ obs_e, _ = env.reset()
176
+ done_e, ret_e = False, 0.0
177
+ while not done_e:
178
+ with torch.no_grad():
179
+ obs_t = torch.FloatTensor(obs_e).unsqueeze(0).to(device)
180
+ action_e = policy(obs_t).probs.argmax().item()
181
+ obs_e, r, te, tr, _ = env.step(action_e)
182
+ ret_e += r
183
+ done_e = te or tr
184
+ eval_returns.append(ret_e)
185
+
186
+ gate_mean = ugtc.get_gate_stats(rollout["obs"])["gate_mean"]
187
+ sigma_ema = ugtc.sigma_ema.item()
188
+ mean_ret = np.mean(eval_returns)
189
+ episode_returns.append(mean_ret)
190
+ print(f"{update:>8} {mean_ret:>8.1f} {gate_mean:>8.3f} {sigma_ema:>8.4f}")
191
+
192
+ env.close()
193
+ print(f"\nFinal mean return (last 5 evals): {np.mean(episode_returns[-5:]):.1f}")
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
examples/ugtc_ppo_mujoco.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UGTC-PPO on MuJoCo continuous control.
4
+
5
+ Targets Hopper-v4 and Ant-v5 by default (as evaluated in the paper).
6
+ Uses the same fixed UGTC hyperparameters as all benchmarks.
7
+
8
+ Requirements:
9
+ pip install torch gymnasium mujoco
10
+
11
+ Usage:
12
+ python examples/ugtc_ppo_mujoco.py --env Hopper-v4
13
+ python examples/ugtc_ppo_mujoco.py --env Ant-v5 --total_steps 5000000
14
+ """
15
+
16
+ import argparse
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.optim as optim
21
+
22
+ import gymnasium as gym
23
+
24
+ from ugtc import UGTCModule
25
+
26
+
27
+ def get_args():
28
+ p = argparse.ArgumentParser()
29
+ p.add_argument("--env", type=str, default="Hopper-v4")
30
+ p.add_argument("--total_steps", type=int, default=1_000_000)
31
+ p.add_argument("--num_steps", type=int, default=2048)
32
+ p.add_argument("--hidden", type=int, default=64)
33
+ p.add_argument("--lr", type=float, default=3e-4)
34
+ p.add_argument("--gamma", type=float, default=0.99)
35
+ p.add_argument("--clip_eps", type=float, default=0.2)
36
+ p.add_argument("--ppo_epochs", type=int, default=10)
37
+ p.add_argument("--seed", type=int, default=0)
38
+ p.add_argument("--eval_interval", type=int, default=50_000)
39
+ return p.parse_args()
40
+
41
+
42
+ class ContinuousPolicy(nn.Module):
43
+ def __init__(self, obs_dim, act_dim, hidden):
44
+ super().__init__()
45
+ self.trunk = nn.Sequential(
46
+ nn.Linear(obs_dim, hidden), nn.Tanh(),
47
+ nn.Linear(hidden, hidden), nn.Tanh(),
48
+ )
49
+ self.mean = nn.Linear(hidden, act_dim)
50
+ self.log_std = nn.Parameter(torch.zeros(act_dim))
51
+ nn.init.orthogonal_(self.mean.weight, 0.01)
52
+
53
+ def forward(self, obs):
54
+ h = self.trunk(obs)
55
+ mean = self.mean(h)
56
+ std = self.log_std.clamp(-4, 2).exp().expand_as(mean)
57
+ return torch.distributions.Normal(mean, std)
58
+
59
+
60
+ def evaluate(policy, env_name, n_eps=20, device="cpu"):
61
+ env = gym.make(env_name)
62
+ rets = []
63
+ for _ in range(n_eps):
64
+ obs, _ = env.reset()
65
+ done, ret = False, 0.0
66
+ while not done:
67
+ with torch.no_grad():
68
+ dist = policy(torch.FloatTensor(obs).to(device))
69
+ action = dist.mean
70
+ obs, r, te, tr, _ = env.step(action.cpu().numpy())
71
+ ret += r
72
+ done = te or tr
73
+ rets.append(ret)
74
+ env.close()
75
+ return np.mean(rets), np.std(rets)
76
+
77
+
78
+ def main():
79
+ args = get_args()
80
+ torch.manual_seed(args.seed)
81
+ np.random.seed(args.seed)
82
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
83
+
84
+ env = gym.make(args.env)
85
+ obs_dim = env.observation_space.shape[0]
86
+ act_dim = env.action_space.shape[0]
87
+
88
+ policy = ContinuousPolicy(obs_dim, act_dim, args.hidden).to(device)
89
+ ugtc = UGTCModule(obs_dim, args.hidden, M=3, lambda_fast=0.80, lambda_slow=0.99, beta=5.0).to(device)
90
+
91
+ optimizer = optim.Adam(
92
+ list(policy.parameters()) + list(ugtc.parameters()), lr=args.lr
93
+ )
94
+
95
+ print(f"UGTC-PPO | {args.env} | device={device} | seed={args.seed}")
96
+ print(f"{'Steps':>10} {'Mean Return':>12} {'Std':>8} {'Gate':>8}")
97
+ print("-" * 48)
98
+
99
+ obs, _ = env.reset(seed=args.seed)
100
+ step = 0
101
+ next_eval = args.eval_interval
102
+
103
+ while step < args.total_steps:
104
+ obs_buf, act_buf, rew_buf, done_buf, logp_buf, nobs_buf = [], [], [], [], [], []
105
+ current_obs = obs
106
+
107
+ for _ in range(args.num_steps):
108
+ obs_t = torch.FloatTensor(current_obs).unsqueeze(0).to(device)
109
+ with torch.no_grad():
110
+ dist = policy(obs_t)
111
+ action = dist.sample()
112
+ log_prob = dist.log_prob(action).sum(-1)
113
+
114
+ next_obs, reward, terminated, truncated, _ = env.step(action.squeeze(0).cpu().numpy())
115
+ done = terminated or truncated
116
+
117
+ obs_buf.append(current_obs)
118
+ act_buf.append(action.squeeze(0).cpu().numpy())
119
+ rew_buf.append(reward)
120
+ done_buf.append(float(done))
121
+ logp_buf.append(log_prob.item())
122
+ nobs_buf.append(next_obs)
123
+
124
+ current_obs = next_obs if not done else env.reset()[0]
125
+ step += 1
126
+
127
+ obs = current_obs
128
+
129
+ obs_t = torch.FloatTensor(np.array(obs_buf)).to(device)
130
+ act_t = torch.FloatTensor(np.array(act_buf)).to(device)
131
+ rew_t = torch.FloatTensor(rew_buf).to(device)
132
+ done_t = torch.FloatTensor(done_buf).to(device)
133
+ logp_t = torch.FloatTensor(logp_buf).to(device)
134
+ nobs_t = torch.FloatTensor(np.array(nobs_buf)).to(device)
135
+
136
+ ugtc.train()
137
+ advantages = ugtc.compute_advantages(obs_t, nobs_t, rew_t, done_t, args.gamma)
138
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
139
+ returns = (advantages + ugtc.get_value_ugtc(obs_t)).detach()
140
+
141
+ for _ in range(args.ppo_epochs):
142
+ dist = policy(obs_t)
143
+ new_lp = dist.log_prob(act_t).sum(-1)
144
+ ratio = (new_lp - logp_t).exp()
145
+ surr1 = ratio * advantages
146
+ surr2 = ratio.clamp(1 - args.clip_eps, 1 + args.clip_eps) * advantages
147
+ policy_loss = -torch.min(surr1, surr2).mean()
148
+ fast_loss = (ugtc.fast_critic(obs_t) - returns).pow(2).mean()
149
+ slow_loss = torch.stack([
150
+ (m(obs_t) - returns).pow(2).mean() for m in ugtc.slow_ensemble.members
151
+ ]).mean()
152
+ loss = policy_loss + 0.5 * (fast_loss + slow_loss)
153
+ optimizer.zero_grad()
154
+ loss.backward()
155
+ nn.utils.clip_grad_norm_(
156
+ list(policy.parameters()) + list(ugtc.parameters()), 0.5
157
+ )
158
+ optimizer.step()
159
+
160
+ if step >= next_eval:
161
+ mean_ret, std_ret = evaluate(policy, args.env, n_eps=20, device=str(device))
162
+ gate = ugtc.get_gate_stats(obs_t)["gate_mean"]
163
+ print(f"{step:>10} {mean_ret:>12.1f} {std_ret:>8.1f} {gate:>8.3f}")
164
+ next_eval += args.eval_interval
165
+
166
+ env.close()
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
examples/ugtc_td3_pendulum.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UGTC-TD3 on Pendulum-v1 — minimal runnable example.
4
+
5
+ Shows UGTC integrated with TD3: backbone's twin-Q and delayed policy update
6
+ are preserved; UGTC adds a value baseline correction to the actor gradient.
7
+
8
+ Requirements:
9
+ pip install torch gymnasium
10
+
11
+ Usage:
12
+ python examples/ugtc_td3_pendulum.py
13
+ """
14
+
15
+ import argparse
16
+ import numpy as np
17
+ import torch
18
+
19
+ import gymnasium as gym
20
+
21
+ from ugtc import UGTCTD3
22
+ from ugtc.td3 import ReplayBuffer
23
+
24
+
25
+ def get_args():
26
+ p = argparse.ArgumentParser()
27
+ p.add_argument("--total_steps", type=int, default=100_000)
28
+ p.add_argument("--start_steps", type=int, default=1_000)
29
+ p.add_argument("--batch_size", type=int, default=256)
30
+ p.add_argument("--seed", type=int, default=0)
31
+ p.add_argument("--hidden", type=int, default=256)
32
+ p.add_argument("--eta", type=float, default=0.5,
33
+ help="UGTC correction weight in actor loss (implementation default, not fixed in paper)")
34
+ return p.parse_args()
35
+
36
+
37
+ def evaluate(agent, env_name="Pendulum-v1", n_episodes=10):
38
+ env = gym.make(env_name)
39
+ returns = []
40
+ for _ in range(n_episodes):
41
+ obs, _ = env.reset()
42
+ done, total = False, 0.0
43
+ while not done:
44
+ action = agent.select_action(obs, noise=0.0)
45
+ obs, r, terminated, truncated, _ = env.step(action)
46
+ total += r
47
+ done = terminated or truncated
48
+ returns.append(total)
49
+ env.close()
50
+ return float(np.mean(returns)), float(np.std(returns))
51
+
52
+
53
+ def main():
54
+ args = get_args()
55
+ torch.manual_seed(args.seed)
56
+ np.random.seed(args.seed)
57
+ device = "cuda" if torch.cuda.is_available() else "cpu"
58
+
59
+ env = gym.make("Pendulum-v1")
60
+ obs_dim = env.observation_space.shape[0]
61
+ act_dim = env.action_space.shape[0]
62
+ max_action = float(env.action_space.high[0])
63
+
64
+ agent = UGTCTD3(
65
+ obs_dim=obs_dim,
66
+ act_dim=act_dim,
67
+ max_action=max_action,
68
+ hidden=args.hidden,
69
+ eta=args.eta,
70
+ device=device,
71
+ )
72
+ replay = ReplayBuffer(obs_dim, act_dim)
73
+
74
+ obs, _ = env.reset(seed=args.seed)
75
+ print(f"UGTC-TD3 on Pendulum-v1 | device={device} | η={args.eta}")
76
+ print(f"{'Step':>8} {'Return':>10} {'Gate':>8}")
77
+ print("-" * 34)
78
+
79
+ for step in range(args.total_steps):
80
+ if step < args.start_steps:
81
+ action = env.action_space.sample()
82
+ else:
83
+ action = agent.select_action(obs, noise=0.1)
84
+
85
+ next_obs, reward, terminated, truncated, _ = env.step(action)
86
+ done = terminated or truncated
87
+ replay.add(obs, action, reward, next_obs, done)
88
+ obs = next_obs if not done else env.reset()[0]
89
+
90
+ if step >= args.start_steps:
91
+ metrics = agent.update(replay, args.batch_size)
92
+
93
+ if (step + 1) % 10_000 == 0:
94
+ mean_ret, std_ret = evaluate(agent)
95
+ gate = metrics.get("gate_mean", float("nan"))
96
+ print(f"{step+1:>8} {mean_ret:>10.1f} {gate:>8.3f}")
97
+
98
+ env.close()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
implementations/cpp/ugtc.hpp ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UGTC: Uncertainty-Gated Temporal Credit — C++ Header-Only Reference Implementation
3
+ * ====================================================================================
4
+ *
5
+ * A minimal, dependency-free reference implementation of the UGTC module.
6
+ * Uses Eigen3 for matrix operations. No RL framework dependency.
7
+ *
8
+ * Requirements:
9
+ * - C++17 or later
10
+ * - Eigen3 (https://eigen.tuxfamily.org/)
11
+ *
12
+ * Usage:
13
+ * #include "ugtc.hpp"
14
+ *
15
+ * UGTC::Config cfg;
16
+ * UGTC::Module ugtc(obs_dim, cfg);
17
+ * auto advantages = ugtc.computeAdvantages(obs, next_obs, rewards, dones, gamma);
18
+ *
19
+ * Paper: https://doi.org/10.5281/zenodo.19715116
20
+ */
21
+
22
+ #pragma once
23
+
24
+ #include <vector>
25
+ #include <cmath>
26
+ #include <numeric>
27
+ #include <random>
28
+ #include <cassert>
29
+ #include <algorithm>
30
+ #include <Eigen/Dense>
31
+
32
+ namespace UGTC {
33
+
34
+ using Matrix = Eigen::MatrixXf;
35
+ using Vector = Eigen::VectorXf;
36
+
37
+ // ──────────────────────────────────────────────────────────────────────────────
38
+ // Configuration
39
+ // ──────────────────────────────────────────────────────────────────────────────
40
+
41
+ struct Config {
42
+ int hidden_dim = 64; ///< Hidden layer width
43
+ int M = 3; ///< Ensemble size (slow critic)
44
+ float lambda_fast = 0.80f; ///< GAE lambda for fast critic
45
+ float lambda_slow = 0.99f; ///< GAE lambda for slow ensemble
46
+ float beta = 5.0f; ///< Gate temperature
47
+ float ema_momentum = 0.99f; ///< EMA momentum for uncertainty normalization
48
+ float eps = 1e-8f; ///< Numerical stability epsilon
49
+ };
50
+
51
+ // ──────────────────────────────────────────────────────────────────────────────
52
+ // Activation functions
53
+ // ──────────────────────────────────────────────────────────────────────────────
54
+
55
+ inline float sigmoid(float x) {
56
+ return 1.0f / (1.0f + std::exp(-x));
57
+ }
58
+
59
+ inline float tanh_activation(float x) {
60
+ return std::tanh(x);
61
+ }
62
+
63
+ inline Vector tanh_vec(const Vector& x) {
64
+ return x.unaryExpr([](float v) { return std::tanh(v); });
65
+ }
66
+
67
+ // ──────────────────────────────────────────────────────────────────────────────
68
+ // Linear layer (weight matrix + bias vector)
69
+ // ──────────────────────────────────────────────────────────────────────────────
70
+
71
+ struct Linear {
72
+ Matrix W; ///< (out_dim, in_dim)
73
+ Vector b; ///< (out_dim,)
74
+
75
+ Linear() = default;
76
+
77
+ Linear(int in_dim, int out_dim, std::mt19937& rng) {
78
+ W = Matrix::Random(out_dim, in_dim);
79
+ b = Vector::Zero(out_dim);
80
+ // Orthogonal-ish initialization via scaled random
81
+ float scale = std::sqrt(2.0f / in_dim);
82
+ W *= scale;
83
+ }
84
+
85
+ Vector forward(const Vector& x) const {
86
+ return W * x + b;
87
+ }
88
+ };
89
+
90
+ // ──────────────────────────────────────────────────────────────────────────────
91
+ // Value network: obs → hidden → hidden → scalar
92
+ // Architecture: Linear → Tanh → Linear → Tanh → Linear
93
+ // ──────────────────────────────────────────────────────────────────────────────
94
+
95
+ struct ValueNetwork {
96
+ Linear fc1, fc2, fc3;
97
+
98
+ ValueNetwork() = default;
99
+
100
+ ValueNetwork(int obs_dim, int hidden_dim, std::mt19937& rng)
101
+ : fc1(obs_dim, hidden_dim, rng)
102
+ , fc2(hidden_dim, hidden_dim, rng)
103
+ , fc3(hidden_dim, 1, rng)
104
+ {}
105
+
106
+ float forward(const Vector& obs) const {
107
+ Vector h1 = tanh_vec(fc1.forward(obs));
108
+ Vector h2 = tanh_vec(fc2.forward(h1));
109
+ return fc3.forward(h2)(0);
110
+ }
111
+ };
112
+
113
+ // ──────────────────────────────────────────────────────────────────────────────
114
+ // Ensemble value network: M independent ValueNetworks
115
+ // ─────────��────────────────────────────────────────────────────────────────────
116
+
117
+ struct EnsembleValueNetwork {
118
+ std::vector<ValueNetwork> members;
119
+ int M;
120
+
121
+ EnsembleValueNetwork() = default;
122
+
123
+ EnsembleValueNetwork(int obs_dim, int hidden_dim, int M, std::mt19937& rng)
124
+ : M(M)
125
+ {
126
+ members.reserve(M);
127
+ for (int i = 0; i < M; ++i) {
128
+ members.emplace_back(obs_dim, hidden_dim, rng);
129
+ }
130
+ }
131
+
132
+ /// Returns (mean, std) of ensemble predictions for a single observation.
133
+ std::pair<float, float> forward(const Vector& obs) const {
134
+ std::vector<float> vals;
135
+ vals.reserve(M);
136
+ for (auto& m : members) vals.push_back(m.forward(obs));
137
+
138
+ float mean = std::accumulate(vals.begin(), vals.end(), 0.0f) / M;
139
+ float var = 0.0f;
140
+ for (float v : vals) var += (v - mean) * (v - mean);
141
+ var /= (M > 1 ? M - 1 : 1);
142
+
143
+ return { mean, std::sqrt(var) };
144
+ }
145
+ };
146
+
147
+ // ──────────────────────────────────────────────────────────────────────────────
148
+ // Gate statistics output
149
+ // ──────────────────────────────────────────────────────────────────────────────
150
+
151
+ struct GateResult {
152
+ float gate; ///< u(s) ∈ [0, 1]
153
+ float v_fast; ///< Fast critic value
154
+ float v_slow; ///< Slow ensemble mean value
155
+ float sigma; ///< Ensemble disagreement (std)
156
+ };
157
+
158
+ // ──────────────────────────────────────────────────────────────────────────────
159
+ // UGTC Module
160
+ // ──────────────────────────────────────────────────────────────────────────────
161
+
162
+ class Module {
163
+ public:
164
+ Module(int obs_dim, const Config& cfg = Config{})
165
+ : cfg_(cfg)
166
+ , sigma_ema_(1.0f)
167
+ {
168
+ std::mt19937 rng(42);
169
+ fast_critic_ = ValueNetwork(obs_dim, cfg.hidden_dim, rng);
170
+ slow_ensemble_ = EnsembleValueNetwork(obs_dim, cfg.hidden_dim, cfg.M, rng);
171
+ }
172
+
173
+ // ── Gate computation ──────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Compute the uncertainty gate u(s) for a single observation.
177
+ *
178
+ * Steps:
179
+ * 1. Evaluate fast critic: v_fast = V_fast(s)
180
+ * 2. Evaluate slow ensemble: (v̄_slow, σ) = ensemble(s)
181
+ * 3. EMA-normalize: σ̂ = σ / σ_EMA
182
+ * 4. Sigmoid gate: u(s) = sigmoid(-β · (σ̂ - 1))
183
+ *
184
+ * @param obs Observation vector (obs_dim,)
185
+ * @param train Whether to update EMA (true during training)
186
+ * @return GateResult with gate, v_fast, v_slow, sigma
187
+ */
188
+ GateResult computeGate(const Vector& obs, bool train = false) {
189
+ float v_fast = fast_critic_.forward(obs);
190
+ auto [v_slow, sigma] = slow_ensemble_.forward(obs);
191
+
192
+ if (train) {
193
+ sigma_ema_ = cfg_.ema_momentum * sigma_ema_
194
+ + (1.0f - cfg_.ema_momentum) * sigma;
195
+ }
196
+
197
+ float normalized_sigma = sigma / (sigma_ema_ + cfg_.eps);
198
+ float gate = sigmoid(-cfg_.beta * (normalized_sigma - 1.0f));
199
+
200
+ return { gate, v_fast, v_slow, sigma };
201
+ }
202
+
203
+ // ── Value estimation ──────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Blended value estimate V^UGTC(s) = u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
207
+ */
208
+ float getValueUGTC(const Vector& obs, bool train = false) {
209
+ auto r = computeGate(obs, train);
210
+ return r.gate * r.v_slow + (1.0f - r.gate) * r.v_fast;
211
+ }
212
+
213
+ // ── GAE computation ───────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Standard Generalized Advantage Estimation.
217
+ *
218
+ * δₜ = rₜ + γ·V(sₜ₊₁)·(1-dₜ) - V(sₜ)
219
+ * Aₜ = δₜ + γλ·(1-dₜ)·Aₜ₊₁
220
+ *
221
+ * @param rewards (T,) reward sequence
222
+ * @param values (T,) current-state values
223
+ * @param next_vals (T,) next-state values
224
+ * @param dones (T,) episode termination flags
225
+ * @param gamma discount factor
226
+ * @param lam GAE lambda
227
+ * @return (T,) advantage estimates
228
+ */
229
+ static std::vector<float> computeGAE(
230
+ const std::vector<float>& rewards,
231
+ const std::vector<float>& values,
232
+ const std::vector<float>& next_vals,
233
+ const std::vector<float>& dones,
234
+ float gamma,
235
+ float lam
236
+ ) {
237
+ int T = static_cast<int>(rewards.size());
238
+ std::vector<float> advantages(T, 0.0f);
239
+
240
+ float gae = 0.0f;
241
+ for (int t = T - 1; t >= 0; --t) {
242
+ float delta = rewards[t] + gamma * next_vals[t] * (1.0f - dones[t]) - values[t];
243
+ gae = delta + gamma * lam * (1.0f - dones[t]) * gae;
244
+ advantages[t] = gae;
245
+ }
246
+ return advantages;
247
+ }
248
+
249
+ // ── UGTC advantage ────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Compute UGTC blended advantages for a trajectory.
253
+ *
254
+ * A^UGTC_t = u(sₜ)·A^slow_t + (1-u(sₜ))·A^fast_t
255
+ *
256
+ * @param obs_seq Sequence of observations (T × obs_dim)
257
+ * @param next_obs_seq Sequence of next observations (T × obs_dim)
258
+ * @param rewards (T,) rewards
259
+ * @param dones (T,) done flags
260
+ * @param gamma Discount factor
261
+ * @param train Whether to update EMA
262
+ * @return (T,) UGTC blended advantages
263
+ */
264
+ std::vector<float> computeAdvantages(
265
+ const std::vector<Vector>& obs_seq,
266
+ const std::vector<Vector>& next_obs_seq,
267
+ const std::vector<float>& rewards,
268
+ const std::vector<float>& dones,
269
+ float gamma = 0.99f,
270
+ bool train = false
271
+ ) {
272
+ int T = static_cast<int>(obs_seq.size());
273
+ assert(T == static_cast<int>(rewards.size()));
274
+
275
+ std::vector<float> gates(T), v_fast_arr(T), v_slow_arr(T);
276
+ std::vector<float> v_fast_next(T), v_slow_next(T);
277
+
278
+ for (int t = 0; t < T; ++t) {
279
+ auto r = computeGate(obs_seq[t], train);
280
+ auto r_next = computeGate(next_obs_seq[t], false);
281
+ gates[t] = r.gate;
282
+ v_fast_arr[t] = r.v_fast;
283
+ v_slow_arr[t] = r.v_slow;
284
+ v_fast_next[t] = r_next.v_fast;
285
+ v_slow_next[t] = r_next.v_slow;
286
+ }
287
+
288
+ auto adv_fast = computeGAE(rewards, v_fast_arr, v_fast_next, dones, gamma, cfg_.lambda_fast);
289
+ auto adv_slow = computeGAE(rewards, v_slow_arr, v_slow_next, dones, gamma, cfg_.lambda_slow);
290
+
291
+ std::vector<float> advantages(T);
292
+ for (int t = 0; t < T; ++t) {
293
+ advantages[t] = gates[t] * adv_slow[t] + (1.0f - gates[t]) * adv_fast[t];
294
+ }
295
+ return advantages;
296
+ }
297
+
298
+ // ── Accessors ─────────────────────────────────────────────────────────────
299
+
300
+ float getSigmaEMA() const { return sigma_ema_; }
301
+ const Config& getConfig() const { return cfg_; }
302
+
303
+ private:
304
+ Config cfg_;
305
+ ValueNetwork fast_critic_;
306
+ EnsembleValueNetwork slow_ensemble_;
307
+ float sigma_ema_;
308
+ };
309
+
310
+ } // namespace UGTC
implementations/java/UGTCModule.java ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UGTC: Uncertainty-Gated Temporal Credit — Java Reference Implementation
3
+ * =========================================================================
4
+ *
5
+ * A pure Java reference implementation of the UGTC module.
6
+ * No external dependencies. Uses simple float[][] arrays for matrix ops.
7
+ *
8
+ * This is a reference implementation for readability and portability.
9
+ * For production use, consider using a Java deep learning framework
10
+ * (DL4J, PyTorch Java API) with proper GPU support.
11
+ *
12
+ * Paper: https://doi.org/10.5281/zenodo.19715116
13
+ */
14
+
15
+ package ai.ethosoft.ugtc;
16
+
17
+ import java.util.Random;
18
+
19
+ public class UGTCModule {
20
+
21
+ // ──────────────────────────────────────────────────────────────────────────
22
+ // Configuration
23
+ // ──────────────────────────────────────────────────────────────────────────
24
+
25
+ public static class Config {
26
+ public int hiddenDim = 64;
27
+ public int M = 3;
28
+ public float lambdaFast = 0.80f;
29
+ public float lambdaSlow = 0.99f;
30
+ public float beta = 5.0f;
31
+ public float emaMomentum = 0.99f;
32
+ public float eps = 1e-8f;
33
+
34
+ public Config() {}
35
+
36
+ public Config(int hiddenDim, int M, float lambdaFast, float lambdaSlow,
37
+ float beta, float emaMomentum) {
38
+ this.hiddenDim = hiddenDim;
39
+ this.M = M;
40
+ this.lambdaFast = lambdaFast;
41
+ this.lambdaSlow = lambdaSlow;
42
+ this.beta = beta;
43
+ this.emaMomentum = emaMomentum;
44
+ }
45
+ }
46
+
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+ // Linear layer
49
+ // ──────────────────────────────────────────────────────────────────────────
50
+
51
+ private static class Linear {
52
+ final float[][] W; // [outDim][inDim]
53
+ final float[] b; // [outDim]
54
+
55
+ Linear(int inDim, int outDim, Random rng) {
56
+ W = new float[outDim][inDim];
57
+ b = new float[outDim];
58
+ float scale = (float) Math.sqrt(2.0 / inDim);
59
+ for (int i = 0; i < outDim; i++)
60
+ for (int j = 0; j < inDim; j++)
61
+ W[i][j] = rng.nextGaussian() > 0 ? scale : -scale;
62
+ }
63
+
64
+ float[] forward(float[] x) {
65
+ int outDim = W.length;
66
+ int inDim = x.length;
67
+ float[] out = new float[outDim];
68
+ for (int i = 0; i < outDim; i++) {
69
+ out[i] = b[i];
70
+ for (int j = 0; j < inDim; j++)
71
+ out[i] += W[i][j] * x[j];
72
+ }
73
+ return out;
74
+ }
75
+ }
76
+
77
+ // ──────────────────────────────────────────────────────────────────────────
78
+ // Value network: obs → h → h → scalar
79
+ // ──────────────────────────────────────────────────────────────────────────
80
+
81
+ private static class ValueNetwork {
82
+ final Linear fc1, fc2, fc3;
83
+
84
+ ValueNetwork(int obsDim, int hiddenDim, Random rng) {
85
+ fc1 = new Linear(obsDim, hiddenDim, rng);
86
+ fc2 = new Linear(hiddenDim, hiddenDim, rng);
87
+ fc3 = new Linear(hiddenDim, 1, rng);
88
+ }
89
+
90
+ float forward(float[] obs) {
91
+ float[] h1 = applyTanh(fc1.forward(obs));
92
+ float[] h2 = applyTanh(fc2.forward(h1));
93
+ return fc3.forward(h2)[0];
94
+ }
95
+ }
96
+
97
+ // ──────────────────────────────────────────────────────────────────────────
98
+ // Ensemble value network
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+
101
+ private static class EnsembleValueNetwork {
102
+ final ValueNetwork[] members;
103
+
104
+ EnsembleValueNetwork(int obsDim, int hiddenDim, int M, Random rng) {
105
+ members = new ValueNetwork[M];
106
+ for (int i = 0; i < M; i++)
107
+ members[i] = new ValueNetwork(obsDim, hiddenDim, rng);
108
+ }
109
+
110
+ /** @return float[] {mean, std} of ensemble predictions */
111
+ float[] forward(float[] obs) {
112
+ int M = members.length;
113
+ float[] vals = new float[M];
114
+ float mean = 0.0f;
115
+
116
+ for (int i = 0; i < M; i++) {
117
+ vals[i] = members[i].forward(obs);
118
+ mean += vals[i];
119
+ }
120
+ mean /= M;
121
+
122
+ float var = 0.0f;
123
+ for (float v : vals) var += (v - mean) * (v - mean);
124
+ var /= (M > 1 ? M - 1 : 1);
125
+
126
+ return new float[]{ mean, (float) Math.sqrt(var) };
127
+ }
128
+ }
129
+
130
+ // ──────────────────────────────────────────────────────────────────────────
131
+ // Gate result
132
+ // ──────────────────────────────────────────────────────────────────────────
133
+
134
+ public static class GateResult {
135
+ public final float gate;
136
+ public final float vFast;
137
+ public final float vSlow;
138
+ public final float sigma;
139
+
140
+ GateResult(float gate, float vFast, float vSlow, float sigma) {
141
+ this.gate = gate;
142
+ this.vFast = vFast;
143
+ this.vSlow = vSlow;
144
+ this.sigma = sigma;
145
+ }
146
+ }
147
+
148
+ // ──────────────────────────────────────────────────────────────────────────
149
+ // Module fields
150
+ // ──────────────────────────────────────────────────────────────────────────
151
+
152
+ private final Config config;
153
+ private final ValueNetwork fastCritic;
154
+ private final EnsembleValueNetwork slowEnsemble;
155
+ private float sigmaEMA;
156
+
157
+ // ──────────────────────────────────────────────────────────────────────────
158
+ // Constructor
159
+ // ──────────────────────────────────────────────────────────────────────────
160
+
161
+ public UGTCModule(int obsDim) {
162
+ this(obsDim, new Config());
163
+ }
164
+
165
+ public UGTCModule(int obsDim, Config config) {
166
+ this.config = config;
167
+ this.sigmaEMA = 1.0f;
168
+ Random rng = new Random(42);
169
+ this.fastCritic = new ValueNetwork(obsDim, config.hiddenDim, rng);
170
+ this.slowEnsemble = new EnsembleValueNetwork(obsDim, config.hiddenDim, config.M, rng);
171
+ }
172
+
173
+ // ──────────────────────────────────────────────────────────────────────────
174
+ // Gate computation
175
+ // ──────────────────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Compute the uncertainty gate u(s) for a single observation.
179
+ *
180
+ * u(s) = sigmoid(-β · (σ̂(s) - 1))
181
+ * where σ̂(s) = σ(s) / σ_EMA
182
+ *
183
+ * @param obs Observation vector
184
+ * @param train Whether to update EMA statistics
185
+ * @return GateResult containing gate, v_fast, v_slow, sigma
186
+ */
187
+ public GateResult computeGate(float[] obs, boolean train) {
188
+ float vFast = fastCritic.forward(obs);
189
+ float[] ensOut = slowEnsemble.forward(obs);
190
+ float vSlow = ensOut[0];
191
+ float sigma = ensOut[1];
192
+
193
+ if (train) {
194
+ sigmaEMA = config.emaMomentum * sigmaEMA
195
+ + (1.0f - config.emaMomentum) * sigma;
196
+ }
197
+
198
+ float normalizedSigma = sigma / (sigmaEMA + config.eps);
199
+ float gate = sigmoid(-config.beta * (normalizedSigma - 1.0f));
200
+
201
+ return new GateResult(gate, vFast, vSlow, sigma);
202
+ }
203
+
204
+ // ──────────────────────────────────────────────────────────────────────────
205
+ // Value estimation
206
+ // ──────────────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Blended value estimate: V^UGTC(s) = u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
210
+ *
211
+ * @param obs Observation vector
212
+ * @param train Whether to update EMA
213
+ * @return Scalar blended value
214
+ */
215
+ public float getValueUGTC(float[] obs, boolean train) {
216
+ GateResult r = computeGate(obs, train);
217
+ return r.gate * r.vSlow + (1.0f - r.gate) * r.vFast;
218
+ }
219
+
220
+ // ──────────────────────────────────────────────────────────────────────────
221
+ // GAE computation
222
+ // ──────────────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Standard Generalized Advantage Estimation.
226
+ *
227
+ * δₜ = rₜ + γ·V(sₜ₊₁)·(1-dₜ) - V(sₜ)
228
+ * Aₜ = δₜ + γλ·(1-dₜ)·Aₜ₊₁
229
+ *
230
+ * @param rewards Reward sequence
231
+ * @param values Current-state values
232
+ * @param nextVals Next-state values
233
+ * @param dones Episode termination flags (1.0 = done)
234
+ * @param gamma Discount factor
235
+ * @param lam GAE lambda
236
+ * @return Array of advantage estimates
237
+ */
238
+ public static float[] computeGAE(
239
+ float[] rewards, float[] values, float[] nextVals, float[] dones,
240
+ float gamma, float lam
241
+ ) {
242
+ int T = rewards.length;
243
+ float[] advantages = new float[T];
244
+ float gae = 0.0f;
245
+
246
+ for (int t = T - 1; t >= 0; t--) {
247
+ float delta = rewards[t] + gamma * nextVals[t] * (1.0f - dones[t]) - values[t];
248
+ gae = delta + gamma * lam * (1.0f - dones[t]) * gae;
249
+ advantages[t] = gae;
250
+ }
251
+ return advantages;
252
+ }
253
+
254
+ // ──────────────────────────────────────────────────────────────────────────
255
+ // UGTC advantage computation
256
+ // ──────────────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Compute UGTC blended advantages for a trajectory.
260
+ *
261
+ * A^UGTC_t = u(sₜ)·A^slow_t + (1-u(sₜ))·A^fast_t
262
+ *
263
+ * @param obsSeq Sequence of observations (T × obsDim)
264
+ * @param nextObsSeq Sequence of next observations (T × obsDim)
265
+ * @param rewards Reward sequence (T,)
266
+ * @param dones Done flags (T,)
267
+ * @param gamma Discount factor
268
+ * @param train Whether to update EMA
269
+ * @return UGTC blended advantages (T,)
270
+ */
271
+ public float[] computeAdvantages(
272
+ float[][] obsSeq, float[][] nextObsSeq,
273
+ float[] rewards, float[] dones,
274
+ float gamma, boolean train
275
+ ) {
276
+ int T = rewards.length;
277
+ float[] gates = new float[T];
278
+ float[] vFastArr = new float[T];
279
+ float[] vSlowArr = new float[T];
280
+ float[] vFastNext = new float[T];
281
+ float[] vSlowNext = new float[T];
282
+
283
+ for (int t = 0; t < T; t++) {
284
+ GateResult r = computeGate(obsSeq[t], train);
285
+ GateResult rNext = computeGate(nextObsSeq[t], false);
286
+ gates[t] = r.gate;
287
+ vFastArr[t] = r.vFast;
288
+ vSlowArr[t] = r.vSlow;
289
+ vFastNext[t] = rNext.vFast;
290
+ vSlowNext[t] = rNext.vSlow;
291
+ }
292
+
293
+ float[] advFast = computeGAE(rewards, vFastArr, vFastNext, dones, gamma, config.lambdaFast);
294
+ float[] advSlow = computeGAE(rewards, vSlowArr, vSlowNext, dones, gamma, config.lambdaSlow);
295
+
296
+ float[] advantages = new float[T];
297
+ for (int t = 0; t < T; t++) {
298
+ advantages[t] = gates[t] * advSlow[t] + (1.0f - gates[t]) * advFast[t];
299
+ }
300
+ return advantages;
301
+ }
302
+
303
+ // Convenience overload with training=false and gamma=0.99
304
+ public float[] computeAdvantages(float[][] obsSeq, float[][] nextObsSeq,
305
+ float[] rewards, float[] dones) {
306
+ return computeAdvantages(obsSeq, nextObsSeq, rewards, dones, 0.99f, false);
307
+ }
308
+
309
+ // ──────────────────────────────────────────────────────────────────────────
310
+ // Accessors
311
+ // ──────────────────────────────────────────────────────────────────────────
312
+
313
+ public float getSigmaEMA() { return sigmaEMA; }
314
+ public Config getConfig() { return config; }
315
+
316
+ // ────────────────────────���─────────────────────────────────────────────────
317
+ // Utility functions
318
+ // ──────────────────────────────────────────────────────────────────────────
319
+
320
+ private static float sigmoid(float x) {
321
+ return 1.0f / (1.0f + (float) Math.exp(-x));
322
+ }
323
+
324
+ private static float[] applyTanh(float[] x) {
325
+ float[] out = new float[x.length];
326
+ for (int i = 0; i < x.length; i++) out[i] = (float) Math.tanh(x[i]);
327
+ return out;
328
+ }
329
+
330
+ // ──────────────────────────────────────────────────────────────────────────
331
+ // Main — minimal smoke test
332
+ // ──────────────────────────────────────────────────────────────────────────
333
+
334
+ public static void main(String[] args) {
335
+ System.out.println("UGTC Java Reference Implementation");
336
+ System.out.println("Paper: https://doi.org/10.5281/zenodo.19715116");
337
+ System.out.println();
338
+
339
+ int obsDim = 17;
340
+ int T = 32;
341
+ UGTCModule ugtc = new UGTCModule(obsDim);
342
+
343
+ // Random trajectory
344
+ Random rng = new Random(0);
345
+ float[][] obs = new float[T][obsDim];
346
+ float[][] nextObs = new float[T][obsDim];
347
+ float[] rewards = new float[T];
348
+ float[] dones = new float[T];
349
+
350
+ for (int t = 0; t < T; t++) {
351
+ for (int d = 0; d < obsDim; d++) {
352
+ obs[t][d] = (float) rng.nextGaussian();
353
+ nextObs[t][d] = (float) rng.nextGaussian();
354
+ }
355
+ rewards[t] = (float) rng.nextGaussian();
356
+ dones[t] = (t == T - 1) ? 1.0f : 0.0f;
357
+ }
358
+
359
+ float[] advantages = ugtc.computeAdvantages(obs, nextObs, rewards, dones, 0.99f, true);
360
+
361
+ System.out.printf("obs_dim: %d T: %d%n", obsDim, T);
362
+ System.out.printf("Advantages: [%.4f, %.4f, %.4f, ...]%n",
363
+ advantages[0], advantages[1], advantages[2]);
364
+
365
+ // Gate check
366
+ GateResult gate = ugtc.computeGate(obs[0], false);
367
+ System.out.printf("Gate u(s₀): %.4f σ_EMA: %.4f%n",
368
+ gate.gate, ugtc.getSigmaEMA());
369
+
370
+ System.out.println("\nSmoke test passed.");
371
+ }
372
+ }
pseudocode/ugtc_pseudocode.md ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UGTC: Algorithm Pseudocode
2
+
3
+ ## Algorithm 1: UGTC Module (Core)
4
+
5
+ ```
6
+ INPUT:
7
+ obs_dim — observation space dimension
8
+ M — ensemble size (default: 3)
9
+ λ_fast — fast critic GAE lambda (default: 0.80)
10
+ λ_slow — slow ensemble GAE lambda (default: 0.99)
11
+ β — gate temperature (default: 5.0)
12
+ α_EMA — EMA momentum (default: 0.99)
13
+
14
+ PARAMETERS:
15
+ θ_fast — fast critic V_fast: obs_dim → 64 → 64 → 1
16
+ θ¹_slow, ..., θᴹ_slow — slow ensemble members (same arch, independent init)
17
+ σ_EMA — EMA buffer (initialized to 1.0)
18
+
19
+ ──────────────────────────────────────────────────────────────
20
+
21
+ PROCEDURE compute_gate(s):
22
+ v_fast ← V_fast(s; θ_fast)
23
+ v¹, ..., vᴹ ← V¹_slow(s), ..., Vᴹ_slow(s) # slow ensemble
24
+ v̄_slow ← mean(v¹, ..., vᴹ)
25
+ σ(s) ← std(v¹, ..., vᴹ)
26
+
27
+ // EMA normalization (updated only during training)
28
+ σ_EMA ← α_EMA · σ_EMA + (1 - α_EMA) · mean(σ(s))
29
+ σ̂(s) ← σ(s) / (σ_EMA + ε)
30
+
31
+ // Sigmoid gate
32
+ u(s) ← sigmoid(-β · (σ̂(s) - 1.0))
33
+
34
+ RETURN u(s), v_fast, v̄_slow
35
+
36
+ ──────────────────────────────────────────────────────────────
37
+
38
+ PROCEDURE compute_advantages(τ = {s₀, a₀, r₀, s₁, ..., sₜ}, γ):
39
+ FOR t = 0 TO T-1:
40
+ u(sₜ), v_fast(sₜ), v̄_slow(sₜ) ← compute_gate(sₜ)
41
+ u(sₜ₊₁), v_fast(sₜ₊₁), v̄_slow(sₜ₊₁) ← compute_gate(sₜ₊₁)
42
+
43
+ // Fast GAE stream (λ = λ_fast)
44
+ A^fast ← GAE(τ, V=v_fast, λ=λ_fast, γ=γ)
45
+
46
+ // Slow GAE stream (λ = λ_slow)
47
+ A^slow ← GAE(τ, V=v̄_slow, λ=λ_slow, γ=γ)
48
+
49
+ // Blended advantage
50
+ FOR t = 0 TO T-1:
51
+ A^UGTC_t ← u(sₜ) · A^slow_t + (1 - u(sₜ)) · A^fast_t
52
+
53
+ RETURN A^UGTC
54
+
55
+ ──────────────────────────────────────────────────────────────
56
+
57
+ SUBROUTINE GAE(τ, V, λ, γ):
58
+ // Standard Generalized Advantage Estimation
59
+ gae ← 0
60
+ FOR t = T-1 DOWNTO 0:
61
+ δₜ ← rₜ + γ · V(sₜ₊₁) · (1 - dₜ) - V(sₜ)
62
+ gaeₜ ← δₜ + γ · λ · (1 - dₜ) · gae
63
+ gae ← gaeₜ
64
+ RETURN {gae₀, gae₁, ..., gaeₜ}
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Algorithm 2: UGTC-PPO
70
+
71
+ ```
72
+ HYPERPARAMETERS (fixed):
73
+ λ_fast=0.80, λ_slow=0.99, M=3, β=5.0, α_EMA=0.99
74
+ PPO HYPERPARAMETERS:
75
+ ε=0.2 (clip), K epochs, γ, lr
76
+
77
+ INITIALIZE:
78
+ π_θ — policy (Gaussian or Categorical)
79
+ UGTC — UGTCModule(obs_dim, M, λ_fast, λ_slow, β)
80
+
81
+ REPEAT until convergence:
82
+ // Collect rollout
83
+ τ ← {s₀, a₀, r₀, ..., sₜ} using π_θ
84
+
85
+ // === KEY CHANGE: UGTC replaces standard GAE ===
86
+ A^UGTC ← UGTC.compute_advantages(τ, γ)
87
+ Â ← normalize(A^UGTC) // mean=0, std=1
88
+ R̂ ← Â + V^UGTC(s) // value targets
89
+
90
+ // K gradient epochs
91
+ FOR k = 1 TO K:
92
+ // Policy loss (standard PPO clipped surrogate)
93
+ ratio ← π_θ(a|s) / π_θ_old(a|s)
94
+ L_policy ← -mean(min(ratio·Â, clip(ratio, 1±ε)·Â))
95
+
96
+ // Critic losses (train all UGTC critics)
97
+ L_fast ← MSE(V_fast(s), R̂)
98
+ L_slow ← mean over m of MSE(Vᵐ_slow(s), R̂)
99
+ L_total ← L_policy + 0.5·(L_fast + L_slow)
100
+
101
+ θ, UGTC ← Adam step on L_total
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Algorithm 3: UGTC-TD3
107
+
108
+ ```
109
+ INITIALIZE:
110
+ π_φ (actor), Q_ψ (twin-Q critic), target networks
111
+ UGTC — UGTCModule(obs_dim)
112
+ Replay buffer D
113
+
114
+ REPEAT until convergence:
115
+ // Collect experience
116
+ aₜ ← π_φ(sₜ) + ε (exploration noise)
117
+ Store (sₜ, aₜ, rₜ, sₜ₊₁) in D
118
+
119
+ // Sample batch
120
+ B = {(s, a, r, s')} ← sample(D)
121
+
122
+ // Critic update (standard TD3 twin-Q, unchanged)
123
+ ã ← π_φ_target(s') + clip(ε, -c, c) // target policy smoothing
124
+ y ← r + γ · min(Q¹_target(s',ã), Q²_target(s',ã))
125
+ L_critic ← MSE(Q¹(s,a), y) + MSE(Q²(s,a), y)
126
+ ψ ← Adam step on L_critic
127
+
128
+ // Delayed actor update (every d steps)
129
+ IF update_count % d == 0:
130
+ u(s), v_fast, v̄_slow ← UGTC.compute_gate(s)
131
+ V^UGTC(s) ← u(s)·v̄_slow + (1-u(s))·v_fast
132
+ Q_min ← min(Q¹(s, π(s)), Q²(s, π(s)))
133
+ A^UGTC(s) ← Q_min - V^UGTC(s) // advantage correction
134
+
135
+ // === KEY CHANGE: DPG + UGTC baseline correction ===
136
+ L_actor ← -mean(Q_min + η · A^UGTC(s))
137
+ φ ← Adam step on L_actor
138
+
139
+ // UGTC critic training
140
+ L_UGTC ← MSE(V^UGTC(s), r + γ·V^UGTC(s'))
141
+ UGTC ← Adam step on L_UGTC
142
+
143
+ // Soft target updates
144
+ ψ_target ← τ·ψ + (1-τ)·ψ_target
145
+ φ_target ← τ·φ + (1-τ)·φ_target
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Algorithm 4: UGTC-SAC
151
+
152
+ ```
153
+ INITIALIZE:
154
+ π_φ (stochastic actor), Q_ψ (twin-Q), α (entropy coefficient)
155
+ UGTC — UGTCModule(obs_dim)
156
+
157
+ REPEAT until convergence:
158
+ Sample B from replay buffer
159
+
160
+ // Critic update (standard SAC twin-Q)
161
+ ã', log_π(ã'|s') ← sample π(·|s')
162
+ y ← r + γ(min(Q¹_target, Q²_target)(s',ã') - α·log_π(ã'|s'))
163
+ L_critic ← MSE(Q¹(s,a), y) + MSE(Q²(s,a), y)
164
+
165
+ // Actor update with UGTC baseline
166
+ ã, log_π(ã|s) ← sample π(·|s)
167
+ Q_min ← min(Q¹(s,ã), Q²(s,ã))
168
+ V^UGTC(s) ← u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
169
+
170
+ // === KEY CHANGE: V^UGTC replaces implicit value baseline ===
171
+ // Standard SAC: L_π = mean(α·log π - Q_min)
172
+ // UGTC-SAC: L_π = mean(α·log π - Q_min + V^UGTC)
173
+ L_actor ← mean(α·log π(ã|s) - Q_min + V^UGTC(s))
174
+
175
+ // Entropy coefficient update (auto-tuning)
176
+ L_α ← mean(-log_α · (log π(ã|s) + H̄)) // H̄ = target entropy
177
+
178
+ // UGTC critic update
179
+ L_UGTC ← MSE(V^UGTC(s), r + γ·V^UGTC(s'))
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Gate Behavior Summary
185
+
186
+ ```
187
+ σ̂(s) u(s) Action
188
+ ───────────────────────────────────────────────────────────────────
189
+ σ̂ << 1 (low uncertainty, ensemble agrees) → u → 1.0 use A^slow (accurate, high λ)
190
+ σ̂ = 1 (uncertainty at running average) → u = 0.5 equal blend
191
+ σ̂ >> 1 (high uncertainty, ensemble disagrees) → u → 0.0 use A^fast (stable, low λ)
192
+ ```
193
+
194
+ The gate temperature β controls the sharpness of this transition:
195
+ - β = 1: gradual blend
196
+ - β = 5: moderate sharpness (paper default)
197
+ - β = 20: near-binary switching
pyproject.toml ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.backends.legacy:build"
4
+
5
+ [project]
6
+ name = "ugtc"
7
+ version = "1.0.0"
8
+ description = "UGTC: Uncertainty-Gated Temporal Credit — plug-in advantage estimator for actor-critic RL"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [
12
+ { name = "Yağız Ekrem Dalar", email = "pertevniyalai@gmail.com" }
13
+ ]
14
+ keywords = [
15
+ "reinforcement-learning",
16
+ "advantage-estimation",
17
+ "temporal-credit",
18
+ "uncertainty",
19
+ "actor-critic",
20
+ "PPO",
21
+ "TD3",
22
+ "SAC",
23
+ "DDPG",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Science/Research",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
34
+ ]
35
+ requires-python = ">=3.10"
36
+ dependencies = [
37
+ "torch>=2.2.0",
38
+ "numpy>=1.24.0",
39
+ ]
40
+
41
+ [project.optional-dependencies]
42
+ mujoco = [
43
+ "gymnasium>=1.0.0",
44
+ "mujoco>=3.1.0",
45
+ ]
46
+ procgen = [
47
+ "gymnasium>=1.0.0",
48
+ "procgen>=0.10.7",
49
+ ]
50
+ all = [
51
+ "gymnasium>=1.0.0",
52
+ "mujoco>=3.1.0",
53
+ "procgen>=0.10.7",
54
+ "metaworld>=2.0.0",
55
+ "crafter>=1.8.0",
56
+ "pyyaml>=6.0",
57
+ "matplotlib>=3.7.0",
58
+ "pandas>=2.0.0",
59
+ "psutil>=5.9.0",
60
+ ]
61
+ dev = [
62
+ "pytest>=7.4",
63
+ "pytest-cov>=4.1",
64
+ "black>=24.0",
65
+ "isort>=5.12",
66
+ "mypy>=1.8",
67
+ "ruff>=0.3",
68
+ ]
69
+
70
+ [project.urls]
71
+ Homepage = "https://ethosoftai.github.io/ugtc"
72
+ Repository = "https://github.com/ethosoftai/ugtc"
73
+ Paper = "https://doi.org/10.5281/zenodo.19715116"
74
+ Demo = "https://huggingface.co/spaces/Ethosoft/ugtc"
75
+ "Bug Tracker" = "https://github.com/ethosoftai/ugtc/issues"
76
+
77
+ [tool.setuptools.packages.find]
78
+ where = ["."]
79
+ include = ["ugtc*"]
80
+
81
+ [tool.black]
82
+ line-length = 100
83
+ target-version = ["py310", "py311"]
84
+
85
+ [tool.isort]
86
+ profile = "black"
87
+ line_length = 100
88
+
89
+ [tool.ruff]
90
+ line-length = 100
91
+ target-version = "py310"
92
+
93
+ [tool.pytest.ini_options]
94
+ testpaths = ["tests"]
95
+ addopts = "-v --tb=short"
96
+
97
+ [tool.mypy]
98
+ python_version = "3.10"
99
+ warn_return_any = true
100
+ warn_unused_configs = true
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.2.0
2
+ numpy>=1.24.0
3
+ gymnasium>=1.0.0
4
+ mujoco>=3.1.0
5
+ pyyaml>=6.0
6
+ procgen>=0.10.7
7
+ metaworld>=2.0.0
8
+ crafter>=1.8.0
9
+ matplotlib>=3.7.0
10
+ pandas>=2.0.0
11
+ psutil>=5.9.0
tests/__init__.py ADDED
File without changes
tests/test_integrations.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for UGTC algorithm wrappers (PPO, TD3, SAC, DDPG).
3
+
4
+ These tests verify that each integration:
5
+ - Initializes without error
6
+ - Produces valid losses (finite, not NaN)
7
+ - Returns expected metric keys
8
+ - Updates parameters (gradient flows)
9
+
10
+ Note: Full training runs are not tested here — see examples/ for that.
11
+ """
12
+
13
+ import pytest
14
+ import torch
15
+ import numpy as np
16
+
17
+ from ugtc import UGTCPPO, UGTCTD3, UGTCSAC, UGTCDDPG
18
+ from ugtc.td3 import ReplayBuffer
19
+
20
+
21
+ OBS_DIM = 8
22
+ ACT_DIM = 2
23
+ BATCH = 32
24
+ HIDDEN = 32
25
+
26
+
27
+ def make_ppo_rollout(T=64, device="cpu"):
28
+ obs = torch.randn(T, OBS_DIM)
29
+ actions = torch.randn(T, ACT_DIM)
30
+ return {
31
+ "obs": obs,
32
+ "actions": actions,
33
+ "rewards": torch.randn(T),
34
+ "next_obs": torch.randn(T, OBS_DIM),
35
+ "dones": torch.zeros(T),
36
+ "log_probs": torch.randn(T),
37
+ }
38
+
39
+
40
+ def make_replay_buffer():
41
+ buf = ReplayBuffer(OBS_DIM, ACT_DIM, capacity=1000)
42
+ for _ in range(BATCH * 2):
43
+ obs = np.random.randn(OBS_DIM).astype(np.float32)
44
+ action = np.random.randn(ACT_DIM).astype(np.float32)
45
+ reward = float(np.random.randn())
46
+ next_obs = np.random.randn(OBS_DIM).astype(np.float32)
47
+ done = False
48
+ buf.add(obs, action, reward, next_obs, done)
49
+ return buf
50
+
51
+
52
+ # ── UGTC-PPO ──────────────────────────────────────────────────────────────────
53
+
54
+ class TestUGTCPPO:
55
+ @pytest.fixture
56
+ def agent(self):
57
+ return UGTCPPO(OBS_DIM, ACT_DIM, hidden_dim=HIDDEN, epochs=2)
58
+
59
+ def test_init(self, agent):
60
+ assert agent.policy is not None
61
+ assert agent.ugtc is not None
62
+
63
+ def test_select_action_shape(self, agent):
64
+ obs = torch.randn(1, OBS_DIM)
65
+ action, log_prob = agent.select_action(obs)
66
+ assert action.shape == (1, ACT_DIM)
67
+ assert log_prob.shape == (1,)
68
+
69
+ def test_update_returns_dict(self, agent):
70
+ rollout = make_ppo_rollout()
71
+ metrics = agent.update(rollout)
72
+ assert isinstance(metrics, dict)
73
+
74
+ def test_update_metrics_finite(self, agent):
75
+ rollout = make_ppo_rollout()
76
+ metrics = agent.update(rollout)
77
+ for key, val in metrics.items():
78
+ assert np.isfinite(val), f"Metric {key} = {val} is not finite"
79
+
80
+ def test_update_metrics_keys(self, agent):
81
+ rollout = make_ppo_rollout()
82
+ metrics = agent.update(rollout)
83
+ assert "policy_loss" in metrics
84
+ assert "fast_value_loss" in metrics
85
+ assert "gate_mean" in metrics
86
+
87
+ def test_parameters_update(self, agent):
88
+ """Verify that an update step actually modifies parameters."""
89
+ initial_params = [p.clone() for p in agent.policy.parameters()]
90
+ rollout = make_ppo_rollout()
91
+ agent.update(rollout)
92
+ updated_params = list(agent.policy.parameters())
93
+ changed = any(
94
+ not torch.allclose(i, u) for i, u in zip(initial_params, updated_params)
95
+ )
96
+ assert changed, "Policy parameters should change after update"
97
+
98
+ def test_save_load(self, agent, tmp_path):
99
+ path = str(tmp_path / "ppo.pt")
100
+ agent.save(path)
101
+ agent.load(path)
102
+
103
+
104
+ # ── UGTC-TD3 ──────────────────────────────────────────────────────────────────
105
+
106
+ class TestUGTCTD3:
107
+ @pytest.fixture
108
+ def agent(self):
109
+ return UGTCTD3(OBS_DIM, ACT_DIM, hidden=HIDDEN, device="cpu")
110
+
111
+ def test_init(self, agent):
112
+ assert agent.actor is not None
113
+ assert agent.ugtc is not None
114
+
115
+ def test_select_action_shape(self, agent):
116
+ obs = np.random.randn(OBS_DIM).astype(np.float32)
117
+ action = agent.select_action(obs, noise=0.0)
118
+ assert action.shape == (ACT_DIM,)
119
+
120
+ def test_update_returns_dict(self, agent):
121
+ buf = make_replay_buffer()
122
+ metrics = agent.update(buf, batch_size=BATCH)
123
+ assert isinstance(metrics, dict)
124
+
125
+ def test_critic_loss_finite(self, agent):
126
+ buf = make_replay_buffer()
127
+ metrics = agent.update(buf, batch_size=BATCH)
128
+ assert np.isfinite(metrics["critic_loss"])
129
+
130
+ def test_actor_loss_after_delay(self, agent):
131
+ """Actor loss should appear after policy_delay updates."""
132
+ buf = make_replay_buffer()
133
+ # Run enough updates to trigger delayed actor update
134
+ for _ in range(agent.policy_delay + 1):
135
+ metrics = agent.update(buf, batch_size=BATCH)
136
+ assert "actor_loss" in metrics
137
+
138
+ def test_action_clipped(self, agent):
139
+ obs = np.random.randn(OBS_DIM).astype(np.float32) * 10
140
+ action = agent.select_action(obs, noise=0.0)
141
+ assert np.all(np.abs(action) <= agent.max_action + 1e-6)
142
+
143
+
144
+ # ── UGTC-SAC ───────────────────���──────────────────────────────────────────────
145
+
146
+ class TestUGTCSAC:
147
+ @pytest.fixture
148
+ def agent(self):
149
+ return UGTCSAC(OBS_DIM, ACT_DIM, hidden=HIDDEN, auto_alpha=True, device="cpu")
150
+
151
+ def test_init(self, agent):
152
+ assert agent.policy is not None
153
+ assert agent.ugtc is not None
154
+
155
+ def test_select_action_stochastic(self, agent):
156
+ obs = np.random.randn(OBS_DIM).astype(np.float32)
157
+ a1 = agent.select_action(obs, deterministic=False)
158
+ a2 = agent.select_action(obs, deterministic=False)
159
+ assert a1.shape == (ACT_DIM,)
160
+ # Stochastic: two samples should (almost certainly) differ
161
+ assert not np.allclose(a1, a2)
162
+
163
+ def test_select_action_deterministic(self, agent):
164
+ obs = np.random.randn(OBS_DIM).astype(np.float32)
165
+ a1 = agent.select_action(obs, deterministic=True)
166
+ a2 = agent.select_action(obs, deterministic=True)
167
+ assert np.allclose(a1, a2)
168
+
169
+ def test_update_returns_dict(self, agent):
170
+ buf = make_replay_buffer()
171
+ metrics = agent.update(buf, batch_size=BATCH)
172
+ assert isinstance(metrics, dict)
173
+
174
+ def test_all_metrics_finite(self, agent):
175
+ buf = make_replay_buffer()
176
+ metrics = agent.update(buf, batch_size=BATCH)
177
+ for k, v in metrics.items():
178
+ assert np.isfinite(v), f"Metric {k} = {v}"
179
+
180
+ def test_auto_alpha_changes(self, agent):
181
+ buf = make_replay_buffer()
182
+ initial_alpha = agent.alpha
183
+ for _ in range(5):
184
+ agent.update(buf, batch_size=BATCH)
185
+ # Alpha should move (unless already converged)
186
+ assert isinstance(agent.alpha, float)
187
+
188
+
189
+ # ── UGTC-DDPG ─────────────────────────────────────────────────────────────────
190
+
191
+ class TestUGTCDDPG:
192
+ @pytest.fixture
193
+ def agent(self):
194
+ return UGTCDDPG(OBS_DIM, ACT_DIM, hidden=HIDDEN, device="cpu")
195
+
196
+ def test_init(self, agent):
197
+ assert agent.actor is not None
198
+ assert agent.ugtc is not None
199
+
200
+ def test_select_action(self, agent):
201
+ obs = np.random.randn(OBS_DIM).astype(np.float32)
202
+ action = agent.select_action(obs, add_noise=False)
203
+ assert action.shape == (ACT_DIM,)
204
+
205
+ def test_update_finite(self, agent):
206
+ buf = make_replay_buffer()
207
+ metrics = agent.update(buf, batch_size=BATCH)
208
+ for k, v in metrics.items():
209
+ assert np.isfinite(v), f"Metric {k} = {v}"
210
+
211
+ def test_update_keys(self, agent):
212
+ buf = make_replay_buffer()
213
+ metrics = agent.update(buf, batch_size=BATCH)
214
+ assert "critic_loss" in metrics
215
+ assert "actor_loss" in metrics
216
+ assert "gate_mean" in metrics
tests/test_module.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for UGTCModule.
3
+
4
+ Tests cover:
5
+ - Gate computation correctness
6
+ - GAE computation
7
+ - Advantage shapes and dtype
8
+ - EMA normalization
9
+ - Parameter counting
10
+ - Value blending
11
+ """
12
+
13
+ import pytest
14
+ import torch
15
+ import numpy as np
16
+
17
+ from ugtc.module import ValueNetwork, EnsembleValueNetwork, UGTCModule
18
+
19
+
20
+ OBS_DIM = 17
21
+ BATCH = 32
22
+ HIDDEN = 32
23
+
24
+
25
+ @pytest.fixture
26
+ def ugtc():
27
+ return UGTCModule(obs_dim=OBS_DIM, hidden_dim=HIDDEN, M=3)
28
+
29
+
30
+ @pytest.fixture
31
+ def obs():
32
+ return torch.randn(BATCH, OBS_DIM)
33
+
34
+
35
+ # ── ValueNetwork ─────────────────────────────────────────────────────────────
36
+
37
+ class TestValueNetwork:
38
+ def test_output_shape(self):
39
+ net = ValueNetwork(OBS_DIM, HIDDEN)
40
+ obs = torch.randn(BATCH, OBS_DIM)
41
+ out = net(obs)
42
+ assert out.shape == (BATCH,), f"Expected ({BATCH},), got {out.shape}"
43
+
44
+ def test_single_sample(self):
45
+ net = ValueNetwork(OBS_DIM, HIDDEN)
46
+ obs = torch.randn(1, OBS_DIM)
47
+ out = net(obs)
48
+ assert out.shape == (1,)
49
+
50
+ def test_grad_flows(self):
51
+ net = ValueNetwork(OBS_DIM, HIDDEN)
52
+ obs = torch.randn(BATCH, OBS_DIM, requires_grad=False)
53
+ loss = net(obs).mean()
54
+ loss.backward()
55
+ for p in net.parameters():
56
+ if p.requires_grad:
57
+ assert p.grad is not None
58
+
59
+
60
+ # ── EnsembleValueNetwork ──────────────────────────────────────────────────────
61
+
62
+ class TestEnsembleValueNetwork:
63
+ def test_output_shapes(self):
64
+ ens = EnsembleValueNetwork(OBS_DIM, HIDDEN, M=3)
65
+ obs = torch.randn(BATCH, OBS_DIM)
66
+ mean, sigma = ens(obs)
67
+ assert mean.shape == (BATCH,)
68
+ assert sigma.shape == (BATCH,)
69
+
70
+ def test_uncertainty_nonneg(self):
71
+ ens = EnsembleValueNetwork(OBS_DIM, HIDDEN, M=3)
72
+ obs = torch.randn(BATCH, OBS_DIM)
73
+ _, sigma = ens(obs)
74
+ assert (sigma >= 0).all(), "Uncertainty (std) must be non-negative"
75
+
76
+ def test_diversity(self):
77
+ """Members should produce different outputs (different random init)."""
78
+ ens = EnsembleValueNetwork(OBS_DIM, HIDDEN, M=5)
79
+ obs = torch.randn(BATCH, OBS_DIM)
80
+ all_vals = ens.forward_all(obs) # (M, batch)
81
+ # At least one pair should differ
82
+ diffs = all_vals[0] - all_vals[1]
83
+ assert diffs.abs().mean() > 0, "Ensemble members should differ"
84
+
85
+ def test_forward_all_shape(self):
86
+ M = 4
87
+ ens = EnsembleValueNetwork(OBS_DIM, HIDDEN, M=M)
88
+ out = ens.forward_all(torch.randn(BATCH, OBS_DIM))
89
+ assert out.shape == (M, BATCH)
90
+
91
+
92
+ # ── UGTCModule ────────────────────────────────────────────────────────────────
93
+
94
+ class TestUGTCModule:
95
+ def test_gate_shape(self, ugtc, obs):
96
+ gate, v_fast, v_slow = ugtc.compute_gate(obs)
97
+ assert gate.shape == (BATCH,)
98
+ assert v_fast.shape == (BATCH,)
99
+ assert v_slow.shape == (BATCH,)
100
+
101
+ def test_gate_in_unit_interval(self, ugtc, obs):
102
+ gate, _, _ = ugtc.compute_gate(obs)
103
+ assert (gate >= 0.0).all(), "Gate must be >= 0"
104
+ assert (gate <= 1.0).all(), "Gate must be <= 1"
105
+
106
+ def test_advantage_shape(self, ugtc, obs):
107
+ next_obs = torch.randn(BATCH, OBS_DIM)
108
+ rewards = torch.randn(BATCH)
109
+ dones = torch.zeros(BATCH)
110
+ adv = ugtc.compute_advantages(obs, next_obs, rewards, dones, gamma=0.99)
111
+ assert adv.shape == (BATCH,), f"Expected ({BATCH},), got {adv.shape}"
112
+
113
+ def test_advantage_finite(self, ugtc, obs):
114
+ next_obs = torch.randn(BATCH, OBS_DIM)
115
+ rewards = torch.randn(BATCH)
116
+ dones = torch.zeros(BATCH)
117
+ adv = ugtc.compute_advantages(obs, next_obs, rewards, dones)
118
+ assert torch.isfinite(adv).all(), "All advantages should be finite"
119
+
120
+ def test_value_shape(self, ugtc, obs):
121
+ v = ugtc.get_value_ugtc(obs)
122
+ assert v.shape == (BATCH,)
123
+
124
+ def test_value_finite(self, ugtc, obs):
125
+ v = ugtc.get_value_ugtc(obs)
126
+ assert torch.isfinite(v).all()
127
+
128
+ def test_gae_zero_reward(self, ugtc):
129
+ """Zero rewards with no termination should give near-zero advantages."""
130
+ T = 16
131
+ obs = torch.randn(T, OBS_DIM)
132
+ next_obs = torch.randn(T, OBS_DIM)
133
+ rewards = torch.zeros(T)
134
+ dones = torch.zeros(T)
135
+ adv = ugtc.compute_advantages(obs, next_obs, rewards, dones, gamma=0.99)
136
+ # GAE with zero rewards is not necessarily zero (depends on value differences)
137
+ assert adv.shape == (T,)
138
+
139
+ def test_done_masks(self, ugtc):
140
+ """Done flags should prevent bootstrapping across episodes."""
141
+ T = 4
142
+ obs = torch.randn(T, OBS_DIM)
143
+ next_obs = torch.randn(T, OBS_DIM)
144
+ rewards = torch.ones(T)
145
+ dones_all = torch.ones(T) # every step terminates
146
+ dones_none = torch.zeros(T)
147
+
148
+ adv_all = ugtc.compute_advantages(obs, next_obs, rewards, dones_all)
149
+ adv_none = ugtc.compute_advantages(obs, next_obs, rewards, dones_none)
150
+ # These should differ because done=1 zeroes out future bootstrapping
151
+ assert not torch.allclose(adv_all, adv_none)
152
+
153
+ def test_parameter_count(self, ugtc):
154
+ counts = ugtc.parameter_count()
155
+ assert "fast_critic" in counts
156
+ assert "slow_ensemble" in counts
157
+ assert "total" in counts
158
+ assert counts["total"] == counts["fast_critic"] + counts["slow_ensemble"]
159
+ assert counts["total"] > 0
160
+
161
+ def test_gate_stats_keys(self, ugtc, obs):
162
+ stats = ugtc.get_gate_stats(obs)
163
+ for key in ("gate_mean", "gate_std", "gate_min", "gate_max", "sigma_ema"):
164
+ assert key in stats, f"Missing key: {key}"
165
+
166
+ def test_ema_updates_during_training(self, ugtc, obs):
167
+ ugtc.train()
168
+ initial_ema = ugtc.sigma_ema.item()
169
+ for _ in range(10):
170
+ ugtc.compute_gate(obs)
171
+ updated_ema = ugtc.sigma_ema.item()
172
+ # EMA should change (unless uncertainty is exactly 1.0 from the start)
173
+ assert isinstance(updated_ema, float)
174
+
175
+ def test_ema_frozen_in_eval(self, ugtc, obs):
176
+ ugtc.eval()
177
+ initial_ema = ugtc.sigma_ema.item()
178
+ for _ in range(10):
179
+ ugtc.compute_gate(obs)
180
+ assert ugtc.sigma_ema.item() == initial_ema, "EMA should not update in eval mode"
181
+
182
+ def test_different_lambda_values(self):
183
+ """Verify different lambda values produce different advantages."""
184
+ ugtc_low = UGTCModule(OBS_DIM, HIDDEN, lambda_fast=0.1, lambda_slow=0.2)
185
+ ugtc_high = UGTCModule(OBS_DIM, HIDDEN, lambda_fast=0.8, lambda_slow=0.99)
186
+ obs = torch.randn(16, OBS_DIM)
187
+ next_obs = torch.randn(16, OBS_DIM)
188
+ rewards = torch.randn(16)
189
+ dones = torch.zeros(16)
190
+ adv_low = ugtc_low.compute_advantages(obs, next_obs, rewards, dones)
191
+ adv_high = ugtc_high.compute_advantages(obs, next_obs, rewards, dones)
192
+ assert not torch.allclose(adv_low, adv_high)
193
+
194
+ def test_beta_affects_gate_sharpness(self):
195
+ """Higher beta should produce sharper gate transitions."""
196
+ ugtc_low_beta = UGTCModule(OBS_DIM, HIDDEN, beta=0.1)
197
+ ugtc_high_beta = UGTCModule(OBS_DIM, HIDDEN, beta=20.0)
198
+ obs = torch.randn(64, OBS_DIM)
199
+ gate_low, _, _ = ugtc_low_beta.compute_gate(obs)
200
+ gate_high, _, _ = ugtc_high_beta.compute_gate(obs)
201
+ # High beta should have more extreme values (closer to 0 or 1)
202
+ extremity_low = ((gate_low - 0.5).abs()).mean()
203
+ extremity_high = ((gate_high - 0.5).abs()).mean()
204
+ assert extremity_high >= extremity_low, "Higher beta should produce sharper gate"
205
+
206
+ @pytest.mark.parametrize("M", [1, 2, 5, 10])
207
+ def test_ensemble_sizes(self, M):
208
+ ugtc = UGTCModule(OBS_DIM, HIDDEN, M=M)
209
+ obs = torch.randn(BATCH, OBS_DIM)
210
+ gate, v_fast, v_slow = ugtc.compute_gate(obs)
211
+ assert gate.shape == (BATCH,)
212
+
213
+ def test_no_grad_in_advantage_computation(self, ugtc, obs):
214
+ """compute_advantages should not retain gradients on the output."""
215
+ next_obs = torch.randn(BATCH, OBS_DIM)
216
+ rewards = torch.randn(BATCH)
217
+ dones = torch.zeros(BATCH)
218
+ adv = ugtc.compute_advantages(obs, next_obs, rewards, dones)
219
+ assert not adv.requires_grad
220
+
221
+
222
+ # ── GAE computation ────────────────────────────────────────────────────────────
223
+
224
+ class TestGAEComputation:
225
+ def test_single_step_gae(self):
226
+ """Single step: advantage = δ = r + γV(s') - V(s)."""
227
+ rewards = torch.tensor([1.0])
228
+ values = torch.tensor([0.5])
229
+ next_values = torch.tensor([0.5])
230
+ dones = torch.tensor([0.0])
231
+ adv = UGTCModule._compute_gae(rewards, values, next_values, dones, gamma=0.99, lam=0.95)
232
+ expected = 1.0 + 0.99 * 0.5 - 0.5
233
+ assert abs(adv[0].item() - expected) < 1e-5
234
+
235
+ def test_terminal_step(self):
236
+ """Done=1 should zero out future value bootstrap."""
237
+ rewards = torch.tensor([1.0])
238
+ values = torch.tensor([0.0])
239
+ next_values = torch.tensor([100.0]) # large, should be masked
240
+ dones = torch.tensor([1.0])
241
+ adv = UGTCModule._compute_gae(rewards, values, next_values, dones, gamma=0.99, lam=0.95)
242
+ expected = 1.0 + 0.99 * 100.0 * 0.0 - 0.0 # next_values masked out
243
+ assert abs(adv[0].item() - expected) < 1e-5
ugtc/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC: Uncertainty-Gated Temporal Credit
3
+ ========================================
4
+
5
+ A backbone-agnostic plug-in advantage estimator for actor-critic RL.
6
+
7
+ Core exports:
8
+ UGTCModule — main module (use this in your algorithm)
9
+ ValueNetwork — single value network (fast critic)
10
+ EnsembleValueNetwork — ensemble of value networks (slow critic)
11
+
12
+ Algorithm integrations:
13
+ UGTCPPO — UGTC + Proximal Policy Optimization
14
+ UGTCTD3 — UGTC + Twin Delayed DDPG
15
+ UGTCSAC — UGTC + Soft Actor-Critic
16
+ UGTCDDPG — UGTC + Deep Deterministic Policy Gradient
17
+
18
+ Paper: https://doi.org/10.5281/zenodo.19715116
19
+ Docs: https://ethosoftai.github.io/ugtc
20
+ """
21
+
22
+ from ugtc.module import UGTCModule, ValueNetwork, EnsembleValueNetwork
23
+ from ugtc.ppo import UGTCPPO
24
+ from ugtc.td3 import UGTCTD3
25
+ from ugtc.sac import UGTCSAC
26
+ from ugtc.ddpg import UGTCDDPG
27
+
28
+ __version__ = "1.0.0"
29
+ __author__ = "Yağız Ekrem Dalar"
30
+ __license__ = "MIT"
31
+ __paper__ = "https://doi.org/10.5281/zenodo.19715116"
32
+
33
+ __all__ = [
34
+ "UGTCModule",
35
+ "ValueNetwork",
36
+ "EnsembleValueNetwork",
37
+ "UGTCPPO",
38
+ "UGTCTD3",
39
+ "UGTCSAC",
40
+ "UGTCDDPG",
41
+ ]
ugtc/ddpg.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC-DDPG: Deep Deterministic Policy Gradient with Uncertainty-Gated Temporal Credit
3
+ ======================================================================================
4
+
5
+ Integration strategy:
6
+ UGTC provides an uncertainty-aware advantage correction to the DDPG actor.
7
+ This is an implementation extension — DDPG is not explicitly benchmarked in
8
+ the paper, but the integration follows the same principle as UGTC-TD3
9
+ (removing twin-Q and delayed policy update).
10
+
11
+ Actor loss:
12
+ L_actor = -(Q(s, π(s)) + η · A^UGTC(s)).mean()
13
+
14
+ where A^UGTC(s) = Q(s, π(s)) - V^UGTC(s)
15
+
16
+ ⚠️ Assumption: This integration is a proposed extension based on the
17
+ UGTC principle. It is not directly evaluated in the paper. Results may
18
+ differ from TD3/PPO integrations.
19
+
20
+ Reference: https://doi.org/10.5281/zenodo.19715116
21
+ """
22
+
23
+ import copy
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.optim as optim
27
+ import numpy as np
28
+ from typing import Dict
29
+
30
+ from ugtc.module import UGTCModule
31
+ from ugtc.td3 import ReplayBuffer
32
+
33
+
34
+ class DDPGActor(nn.Module):
35
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256, max_action: float = 1.0):
36
+ super().__init__()
37
+ self.max_action = max_action
38
+ self.net = nn.Sequential(
39
+ nn.Linear(obs_dim, hidden), nn.ReLU(),
40
+ nn.Linear(hidden, hidden), nn.ReLU(),
41
+ nn.Linear(hidden, act_dim), nn.Tanh(),
42
+ )
43
+
44
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
45
+ return self.max_action * self.net(obs)
46
+
47
+
48
+ class DDPGCritic(nn.Module):
49
+ """Single Q-network (DDPG, unlike TD3 which uses twin-Q)."""
50
+
51
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256):
52
+ super().__init__()
53
+ self.net = nn.Sequential(
54
+ nn.Linear(obs_dim + act_dim, hidden), nn.ReLU(),
55
+ nn.Linear(hidden, hidden), nn.ReLU(),
56
+ nn.Linear(hidden, 1),
57
+ )
58
+
59
+ def forward(self, obs: torch.Tensor, action: torch.Tensor) -> torch.Tensor:
60
+ return self.net(torch.cat([obs, action], dim=-1)).squeeze(-1)
61
+
62
+
63
+ class OUNoise:
64
+ """Ornstein-Uhlenbeck noise for DDPG exploration."""
65
+
66
+ def __init__(self, act_dim: int, mu: float = 0.0, theta: float = 0.15, sigma: float = 0.2):
67
+ self.mu = mu * np.ones(act_dim)
68
+ self.theta = theta
69
+ self.sigma = sigma
70
+ self.state = self.mu.copy()
71
+
72
+ def reset(self):
73
+ self.state = self.mu.copy()
74
+
75
+ def sample(self) -> np.ndarray:
76
+ dx = self.theta * (self.mu - self.state) + self.sigma * np.random.randn(*self.state.shape)
77
+ self.state += dx
78
+ return self.state
79
+
80
+
81
+ class UGTCDDPG:
82
+ """
83
+ UGTC-DDPG trainer.
84
+
85
+ ⚠️ Implementation assumption: DDPG is not benchmarked in the paper.
86
+ This follows the same UGTC integration logic as TD3 but without
87
+ twin-Q critics or delayed policy update.
88
+
89
+ Args:
90
+ obs_dim: Observation space dimension.
91
+ act_dim: Action space dimension.
92
+ max_action: Action space bound (default 1.0).
93
+ hidden: Hidden layer width (default 256).
94
+ lr: Learning rate (default 1e-3).
95
+ gamma: Discount factor (default 0.99).
96
+ tau: Soft target update coefficient (default 0.005).
97
+ eta: UGTC correction weight in actor loss (default 0.5).
98
+ M: UGTC ensemble size (default 3).
99
+ beta: UGTC gate temperature (default 5.0).
100
+ device: PyTorch device string (default "cpu").
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ obs_dim: int,
106
+ act_dim: int,
107
+ max_action: float = 1.0,
108
+ hidden: int = 256,
109
+ lr: float = 1e-3,
110
+ gamma: float = 0.99,
111
+ tau: float = 0.005,
112
+ eta: float = 0.5,
113
+ M: int = 3,
114
+ beta: float = 5.0,
115
+ device: str = "cpu",
116
+ ):
117
+ self.gamma = gamma
118
+ self.tau = tau
119
+ self.eta = eta
120
+ self.max_action = max_action
121
+ self.device = torch.device(device)
122
+
123
+ self.actor = DDPGActor(obs_dim, act_dim, hidden, max_action).to(self.device)
124
+ self.actor_target = copy.deepcopy(self.actor)
125
+
126
+ self.critic = DDPGCritic(obs_dim, act_dim, hidden).to(self.device)
127
+ self.critic_target = copy.deepcopy(self.critic)
128
+
129
+ self.ugtc = UGTCModule(obs_dim, hidden, M=M, beta=beta).to(self.device)
130
+ self.noise = OUNoise(act_dim)
131
+
132
+ self.actor_opt = optim.Adam(self.actor.parameters(), lr=lr)
133
+ self.critic_opt = optim.Adam(self.critic.parameters(), lr=lr)
134
+ self.ugtc_opt = optim.Adam(self.ugtc.parameters(), lr=lr)
135
+
136
+ def select_action(self, obs: np.ndarray, add_noise: bool = True) -> np.ndarray:
137
+ obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
138
+ with torch.no_grad():
139
+ action = self.actor(obs_t).squeeze(0).cpu().numpy()
140
+ if add_noise:
141
+ action += self.noise.sample()
142
+ return action.clip(-self.max_action, self.max_action)
143
+
144
+ def update(self, replay: ReplayBuffer, batch_size: int = 256) -> Dict[str, float]:
145
+ """One DDPG gradient step with UGTC baseline correction."""
146
+ batch = replay.sample(batch_size, self.device)
147
+ obs = batch["obs"]
148
+ actions = batch["actions"]
149
+ rewards = batch["rewards"]
150
+ next_obs = batch["next_obs"]
151
+ dones = batch["dones"]
152
+
153
+ # --- Critic update ---
154
+ with torch.no_grad():
155
+ next_actions = self.actor_target(next_obs)
156
+ q_target = rewards + (1.0 - dones) * self.gamma * self.critic_target(next_obs, next_actions)
157
+
158
+ q = self.critic(obs, actions)
159
+ critic_loss = (q - q_target).pow(2).mean()
160
+
161
+ self.critic_opt.zero_grad()
162
+ critic_loss.backward()
163
+ self.critic_opt.step()
164
+
165
+ # --- Actor update with UGTC baseline ---
166
+ pi = self.actor(obs)
167
+ q_pi = self.critic(obs, pi)
168
+
169
+ gate, v_fast, v_slow = self.ugtc.compute_gate(obs)
170
+ v_ugtc = (gate * v_slow + (1.0 - gate) * v_fast).detach()
171
+ advantage = q_pi - v_ugtc
172
+
173
+ actor_loss = -(q_pi + self.eta * advantage).mean()
174
+
175
+ self.actor_opt.zero_grad()
176
+ actor_loss.backward()
177
+ self.actor_opt.step()
178
+
179
+ # --- UGTC critic update ---
180
+ with torch.no_grad():
181
+ v_target = rewards + (1.0 - dones) * self.gamma * self.ugtc.get_value_ugtc(next_obs)
182
+ ugtc_loss = (self.ugtc.get_value_ugtc(obs) - v_target).pow(2).mean()
183
+
184
+ self.ugtc_opt.zero_grad()
185
+ ugtc_loss.backward()
186
+ self.ugtc_opt.step()
187
+
188
+ # Soft target updates
189
+ for sp, tp in zip(self.actor.parameters(), self.actor_target.parameters()):
190
+ tp.data.copy_(self.tau * sp.data + (1.0 - self.tau) * tp.data)
191
+ for sp, tp in zip(self.critic.parameters(), self.critic_target.parameters()):
192
+ tp.data.copy_(self.tau * sp.data + (1.0 - self.tau) * tp.data)
193
+
194
+ return {
195
+ "critic_loss": critic_loss.item(),
196
+ "actor_loss": actor_loss.item(),
197
+ "ugtc_loss": ugtc_loss.item(),
198
+ **self.ugtc.get_gate_stats(obs),
199
+ }
ugtc/module.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC: Uncertainty-Gated Temporal Credit — Core Module
3
+ ======================================================
4
+
5
+ Backbone-agnostic advantage estimator. Drop this into any actor-critic
6
+ algorithm by replacing the advantage computation step.
7
+
8
+ Mathematical summary:
9
+
10
+ Fast GAE: A^fast_t computed with λ_fast = 0.80
11
+ Slow GAE: A^slow_t computed with λ_slow = 0.99, using ensemble mean
12
+ Gate: u(s) = σ(-β · (σ̂(s) - 1.0))
13
+ σ̂(s) = std(V¹,...,Vᴹ)(s) / σ_EMA
14
+ Blended: A^UGTC_t = u(sₜ)·A^slow_t + (1-u(sₜ))·A^fast_t
15
+
16
+ Reference: https://doi.org/10.5281/zenodo.19715116
17
+ """
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import numpy as np
22
+ from typing import Tuple, Dict
23
+
24
+
25
+ class ValueNetwork(nn.Module):
26
+ """
27
+ Single feed-forward value network V(s).
28
+
29
+ Used as the fast critic. Architecture: two hidden layers with Tanh,
30
+ orthogonal initialization (following PPO conventions).
31
+
32
+ Args:
33
+ obs_dim: Observation space dimension.
34
+ hidden_dim: Hidden layer width (default 64).
35
+ """
36
+
37
+ def __init__(self, obs_dim: int, hidden_dim: int = 64):
38
+ super().__init__()
39
+ self.net = nn.Sequential(
40
+ nn.Linear(obs_dim, hidden_dim),
41
+ nn.Tanh(),
42
+ nn.Linear(hidden_dim, hidden_dim),
43
+ nn.Tanh(),
44
+ nn.Linear(hidden_dim, 1),
45
+ )
46
+ self._init_weights()
47
+
48
+ def _init_weights(self) -> None:
49
+ for m in self.modules():
50
+ if isinstance(m, nn.Linear):
51
+ nn.init.orthogonal_(m.weight, gain=np.sqrt(2))
52
+ nn.init.constant_(m.bias, 0.0)
53
+ nn.init.orthogonal_(self.net[-1].weight, gain=1.0)
54
+
55
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
56
+ return self.net(obs).squeeze(-1)
57
+
58
+
59
+ class EnsembleValueNetwork(nn.Module):
60
+ """
61
+ Ensemble of M independently initialized value networks (slow critic).
62
+
63
+ Each member has its own parameters — no shared trunk — to maximise
64
+ functional diversity and produce meaningful disagreement estimates.
65
+
66
+ Args:
67
+ obs_dim: Observation space dimension.
68
+ hidden_dim: Hidden layer width (default 64).
69
+ M: Ensemble size (default 3).
70
+ """
71
+
72
+ def __init__(self, obs_dim: int, hidden_dim: int = 64, M: int = 3):
73
+ super().__init__()
74
+ self.M = M
75
+ self.members = nn.ModuleList(
76
+ [ValueNetwork(obs_dim, hidden_dim) for _ in range(M)]
77
+ )
78
+
79
+ def forward(self, obs: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
80
+ """
81
+ Args:
82
+ obs: (batch, obs_dim)
83
+
84
+ Returns:
85
+ mean_value: (batch,) — ensemble mean V̄_slow(s)
86
+ uncertainty: (batch,) — per-state std dev σ(s)
87
+ """
88
+ values = torch.stack([m(obs) for m in self.members], dim=0) # (M, batch)
89
+ return values.mean(dim=0), values.std(dim=0)
90
+
91
+ def forward_all(self, obs: torch.Tensor) -> torch.Tensor:
92
+ """Returns all member predictions as (M, batch) tensor."""
93
+ return torch.stack([m(obs) for m in self.members], dim=0)
94
+
95
+
96
+ class UGTCModule(nn.Module):
97
+ """
98
+ Uncertainty-Gated Temporal Credit module.
99
+
100
+ Drop-in replacement for standard GAE. Integrates with any actor-critic
101
+ algorithm by substituting the advantage computation step.
102
+
103
+ Architecture:
104
+ - Fast critic: single ValueNetwork, λ = lambda_fast
105
+ - Slow ensemble: M independent ValueNetworks, λ = lambda_slow
106
+ - Gate: sigmoid of EMA-normalized ensemble disagreement
107
+
108
+ All hyperparameters are fixed across benchmarks (no per-task tuning).
109
+
110
+ Args:
111
+ obs_dim: Observation space dimension.
112
+ hidden_dim: Hidden layer width for all critics (default 64).
113
+ M: Ensemble size for slow critic (default 3).
114
+ lambda_fast: GAE lambda for fast critic (default 0.80).
115
+ lambda_slow: GAE lambda for slow ensemble (default 0.99).
116
+ beta: Gate temperature — higher = sharper gate (default 5.0).
117
+ ema_momentum: EMA momentum for uncertainty normalization (default 0.99).
118
+
119
+ Example::
120
+
121
+ ugtc = UGTCModule(obs_dim=17)
122
+ advantages = ugtc.compute_advantages(obs, next_obs, rewards, dones, gamma=0.99)
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ obs_dim: int,
128
+ hidden_dim: int = 64,
129
+ M: int = 3,
130
+ lambda_fast: float = 0.80,
131
+ lambda_slow: float = 0.99,
132
+ beta: float = 5.0,
133
+ ema_momentum: float = 0.99,
134
+ ):
135
+ super().__init__()
136
+
137
+ self.lambda_fast = lambda_fast
138
+ self.lambda_slow = lambda_slow
139
+ self.beta = beta
140
+ self.ema_momentum = ema_momentum
141
+
142
+ self.fast_critic = ValueNetwork(obs_dim, hidden_dim)
143
+ self.slow_ensemble = EnsembleValueNetwork(obs_dim, hidden_dim, M)
144
+
145
+ self.register_buffer("sigma_ema", torch.ones(1))
146
+ self.eps = 1e-8
147
+
148
+ # ------------------------------------------------------------------
149
+ # Gate computation
150
+ # ------------------------------------------------------------------
151
+
152
+ def compute_gate(
153
+ self, obs: torch.Tensor
154
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
155
+ """
156
+ Compute per-state uncertainty gate u(s).
157
+
158
+ Steps:
159
+ 1. Evaluate fast critic: v_fast = V_fast(s)
160
+ 2. Evaluate slow ensemble: v_slow_mean, σ = V̄_slow(s), std(V¹,...,Vᴹ)(s)
161
+ 3. EMA-normalize: σ̂ = σ / σ_EMA
162
+ 4. Sigmoid gate: u(s) = σ(-β · (σ̂ - 1))
163
+
164
+ Returns:
165
+ gate: (batch,) — blend weight u(s) ∈ [0, 1]
166
+ v_fast: (batch,) — fast critic values
167
+ v_slow_mean: (batch,) — slow ensemble mean values
168
+ """
169
+ v_fast = self.fast_critic(obs)
170
+ v_slow_mean, sigma = self.slow_ensemble(obs)
171
+
172
+ if self.training:
173
+ self.sigma_ema = (
174
+ self.ema_momentum * self.sigma_ema
175
+ + (1.0 - self.ema_momentum) * sigma.mean().detach()
176
+ )
177
+
178
+ normalized_sigma = sigma / (self.sigma_ema + self.eps)
179
+ gate = torch.sigmoid(-self.beta * (normalized_sigma - 1.0))
180
+
181
+ return gate, v_fast, v_slow_mean
182
+
183
+ # ------------------------------------------------------------------
184
+ # Advantage computation
185
+ # ------------------------------------------------------------------
186
+
187
+ def compute_advantages(
188
+ self,
189
+ obs: torch.Tensor,
190
+ next_obs: torch.Tensor,
191
+ rewards: torch.Tensor,
192
+ dones: torch.Tensor,
193
+ gamma: float = 0.99,
194
+ ) -> torch.Tensor:
195
+ """
196
+ Compute UGTC blended advantages for a batch of transitions.
197
+
198
+ Formula:
199
+ A^UGTC_t = u(sₜ)·A^slow_t + (1-u(sₜ))·A^fast_t
200
+
201
+ Args:
202
+ obs: (T, obs_dim) — current observations
203
+ next_obs: (T, obs_dim) — next observations
204
+ rewards: (T,) — reward signal
205
+ dones: (T,) — episode termination flags (1.0 = done)
206
+ gamma: Discount factor (default 0.99)
207
+
208
+ Returns:
209
+ advantages: (T,) — UGTC blended advantages
210
+ """
211
+ with torch.no_grad():
212
+ gate, v_fast, v_slow = self.compute_gate(obs)
213
+ _, v_fast_next, v_slow_next = self.compute_gate(next_obs)
214
+
215
+ adv_fast = self._compute_gae(
216
+ rewards, v_fast, v_fast_next, dones, gamma, self.lambda_fast
217
+ )
218
+ adv_slow = self._compute_gae(
219
+ rewards, v_slow, v_slow_next, dones, gamma, self.lambda_slow
220
+ )
221
+
222
+ return gate * adv_slow + (1.0 - gate) * adv_fast
223
+
224
+ @staticmethod
225
+ def _compute_gae(
226
+ rewards: torch.Tensor,
227
+ values: torch.Tensor,
228
+ next_values: torch.Tensor,
229
+ dones: torch.Tensor,
230
+ gamma: float,
231
+ lam: float,
232
+ ) -> torch.Tensor:
233
+ """
234
+ Standard GAE computation (vectorized reverse accumulation).
235
+
236
+ δₜ = rₜ + γ·V(sₜ₊₁)·(1-dₜ) - V(sₜ)
237
+ Aₜ = δₜ + γλ·(1-dₜ)·Aₜ₊₁
238
+ """
239
+ T = rewards.shape[0]
240
+ advantages = torch.zeros_like(rewards)
241
+ deltas = rewards + gamma * next_values * (1.0 - dones) - values
242
+
243
+ gae = 0.0
244
+ for t in reversed(range(T)):
245
+ gae = deltas[t] + gamma * lam * (1.0 - dones[t]) * gae
246
+ advantages[t] = gae
247
+
248
+ return advantages
249
+
250
+ # ------------------------------------------------------------------
251
+ # Value estimation
252
+ # ------------------------------------------------------------------
253
+
254
+ def get_value_ugtc(self, obs: torch.Tensor) -> torch.Tensor:
255
+ """
256
+ Blended value estimate.
257
+
258
+ V^UGTC(s) = u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
259
+
260
+ Args:
261
+ obs: (batch, obs_dim)
262
+
263
+ Returns:
264
+ (batch,) blended value
265
+ """
266
+ gate, v_fast, v_slow = self.compute_gate(obs)
267
+ return gate * v_slow + (1.0 - gate) * v_fast
268
+
269
+ # ------------------------------------------------------------------
270
+ # Diagnostics
271
+ # ------------------------------------------------------------------
272
+
273
+ def get_gate_stats(self, obs: torch.Tensor) -> Dict[str, float]:
274
+ """Return gate statistics for logging/debugging."""
275
+ with torch.no_grad():
276
+ gate, v_fast, v_slow = self.compute_gate(obs)
277
+ return {
278
+ "gate_mean": gate.mean().item(),
279
+ "gate_std": gate.std().item(),
280
+ "gate_min": gate.min().item(),
281
+ "gate_max": gate.max().item(),
282
+ "sigma_ema": self.sigma_ema.item(),
283
+ }
284
+
285
+ def parameter_count(self) -> Dict[str, int]:
286
+ """Report parameter counts for overhead analysis."""
287
+ fast = sum(p.numel() for p in self.fast_critic.parameters())
288
+ slow = sum(p.numel() for p in self.slow_ensemble.parameters())
289
+ return {"fast_critic": fast, "slow_ensemble": slow, "total": fast + slow}
ugtc/ppo.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC-PPO: Proximal Policy Optimization with Uncertainty-Gated Temporal Credit
3
+ ==============================================================================
4
+
5
+ Integration strategy:
6
+ The only structural change from vanilla PPO is the advantage computation:
7
+ A^UGTC replaces the standard single-critic GAE advantage.
8
+
9
+ All UGTC critics are trained via the same regression loss pipeline
10
+ used by the vanilla PPO critic (MSE against λ-returns).
11
+
12
+ Algorithm (one update step):
13
+ 1. Collect rollout with current policy
14
+ 2. Compute A^UGTC using UGTCModule.compute_advantages()
15
+ 3. Normalize advantages
16
+ 4. Run K epochs of clipped surrogate + critic regression
17
+ 5. Gradient clip and step
18
+
19
+ Reference: https://doi.org/10.5281/zenodo.19715116
20
+ """
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.optim as optim
25
+ import numpy as np
26
+ from typing import Dict, Optional
27
+
28
+ from ugtc.module import UGTCModule
29
+
30
+
31
+ class PPOPolicy(nn.Module):
32
+ """
33
+ Gaussian policy for continuous control (shared trunk, separate heads).
34
+
35
+ For discrete action spaces, replace Normal distribution with Categorical
36
+ and remove log_std parameter.
37
+ """
38
+
39
+ def __init__(self, obs_dim: int, act_dim: int, hidden_dim: int = 64):
40
+ super().__init__()
41
+ self.trunk = nn.Sequential(
42
+ nn.Linear(obs_dim, hidden_dim),
43
+ nn.Tanh(),
44
+ nn.Linear(hidden_dim, hidden_dim),
45
+ nn.Tanh(),
46
+ )
47
+ self.mean_head = nn.Linear(hidden_dim, act_dim)
48
+ self.log_std = nn.Parameter(torch.zeros(act_dim))
49
+ self._init_weights()
50
+
51
+ def _init_weights(self) -> None:
52
+ for m in self.trunk.modules():
53
+ if isinstance(m, nn.Linear):
54
+ nn.init.orthogonal_(m.weight, gain=np.sqrt(2))
55
+ nn.init.constant_(m.bias, 0.0)
56
+ nn.init.orthogonal_(self.mean_head.weight, gain=0.01)
57
+ nn.init.constant_(self.mean_head.bias, 0.0)
58
+
59
+ def forward(self, obs: torch.Tensor) -> torch.distributions.Normal:
60
+ h = self.trunk(obs)
61
+ mean = self.mean_head(h)
62
+ std = self.log_std.clamp(-4.0, 2.0).exp().expand_as(mean)
63
+ return torch.distributions.Normal(mean, std)
64
+
65
+ def get_log_prob(self, obs: torch.Tensor, actions: torch.Tensor) -> torch.Tensor:
66
+ return self.forward(obs).log_prob(actions).sum(-1)
67
+
68
+
69
+ class UGTCPPO:
70
+ """
71
+ UGTC-PPO trainer.
72
+
73
+ The sole algorithmic difference from vanilla PPO is that advantages are
74
+ computed by UGTCModule.compute_advantages() rather than single-critic GAE.
75
+
76
+ Args:
77
+ obs_dim: Observation space dimension.
78
+ act_dim: Action space dimension.
79
+ hidden_dim: Hidden layer width (default 64).
80
+ lr: Learning rate (default 3e-4).
81
+ gamma: Discount factor (default 0.99).
82
+ clip_eps: PPO clipping coefficient ε (default 0.2).
83
+ epochs: Number of update epochs per rollout (default 10).
84
+ vf_coef: Value function loss coefficient (default 0.5).
85
+ ent_coef: Entropy bonus coefficient (default 0.0).
86
+ max_grad_norm: Gradient norm clip (default 0.5).
87
+ lambda_fast: UGTC fast critic λ (default 0.80).
88
+ lambda_slow: UGTC slow ensemble λ (default 0.99).
89
+ M: UGTC ensemble size (default 3).
90
+ beta: UGTC gate temperature (default 5.0).
91
+ ema_momentum: UGTC EMA momentum (default 0.99).
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ obs_dim: int,
97
+ act_dim: int,
98
+ hidden_dim: int = 64,
99
+ lr: float = 3e-4,
100
+ gamma: float = 0.99,
101
+ clip_eps: float = 0.2,
102
+ epochs: int = 10,
103
+ vf_coef: float = 0.5,
104
+ ent_coef: float = 0.0,
105
+ max_grad_norm: float = 0.5,
106
+ # UGTC hyperparameters — fixed across ALL benchmarks
107
+ lambda_fast: float = 0.80,
108
+ lambda_slow: float = 0.99,
109
+ M: int = 3,
110
+ beta: float = 5.0,
111
+ ema_momentum: float = 0.99,
112
+ ):
113
+ self.gamma = gamma
114
+ self.clip_eps = clip_eps
115
+ self.epochs = epochs
116
+ self.vf_coef = vf_coef
117
+ self.ent_coef = ent_coef
118
+ self.max_grad_norm = max_grad_norm
119
+
120
+ self.policy = PPOPolicy(obs_dim, act_dim, hidden_dim)
121
+ self.ugtc = UGTCModule(
122
+ obs_dim, hidden_dim, M, lambda_fast, lambda_slow, beta, ema_momentum
123
+ )
124
+
125
+ self.optimizer = optim.Adam(
126
+ list(self.policy.parameters()) + list(self.ugtc.parameters()),
127
+ lr=lr,
128
+ eps=1e-5,
129
+ )
130
+
131
+ def select_action(
132
+ self, obs: torch.Tensor
133
+ ) -> tuple[torch.Tensor, torch.Tensor]:
134
+ """Sample action and log-prob for rollout collection."""
135
+ with torch.no_grad():
136
+ dist = self.policy(obs)
137
+ action = dist.sample()
138
+ log_prob = dist.log_prob(action).sum(-1)
139
+ return action, log_prob
140
+
141
+ def update(self, rollout: Dict[str, torch.Tensor]) -> Dict[str, float]:
142
+ """
143
+ One PPO update using UGTC advantages.
144
+
145
+ Args:
146
+ rollout: dict with keys:
147
+ 'obs' (T, obs_dim)
148
+ 'actions' (T, act_dim)
149
+ 'rewards' (T,)
150
+ 'next_obs' (T, obs_dim)
151
+ 'dones' (T,)
152
+ 'log_probs' (T,)
153
+
154
+ Returns:
155
+ dict with logged scalar metrics.
156
+ """
157
+ obs = rollout["obs"]
158
+ actions = rollout["actions"]
159
+ old_log_probs = rollout["log_probs"]
160
+ rewards = rollout["rewards"]
161
+ next_obs = rollout["next_obs"]
162
+ dones = rollout["dones"]
163
+
164
+ self.ugtc.train()
165
+
166
+ # === KEY CHANGE: UGTC advantages replaces standard single-critic GAE ===
167
+ advantages = self.ugtc.compute_advantages(
168
+ obs, next_obs, rewards, dones, self.gamma
169
+ )
170
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
171
+
172
+ # Value targets for critic training (using blended value + advantages)
173
+ with torch.no_grad():
174
+ returns = advantages + self.ugtc.get_value_ugtc(obs)
175
+
176
+ metrics: Dict[str, float] = {}
177
+
178
+ for epoch in range(self.epochs):
179
+ # --- Policy loss (PPO clipped surrogate) ---
180
+ dist = self.policy(obs)
181
+ new_log_probs = dist.log_prob(actions).sum(-1)
182
+ entropy = dist.entropy().sum(-1)
183
+ ratio = (new_log_probs - old_log_probs).exp()
184
+
185
+ surr1 = ratio * advantages
186
+ surr2 = ratio.clamp(1.0 - self.clip_eps, 1.0 + self.clip_eps) * advantages
187
+ policy_loss = -torch.min(surr1, surr2).mean()
188
+
189
+ # --- Critic losses (train all UGTC critics against same targets) ---
190
+ fast_loss = (self.ugtc.fast_critic(obs) - returns).pow(2).mean()
191
+
192
+ ensemble_loss = torch.stack([
193
+ (m(obs) - returns).pow(2).mean()
194
+ for m in self.ugtc.slow_ensemble.members
195
+ ]).mean()
196
+
197
+ entropy_loss = -self.ent_coef * entropy.mean()
198
+
199
+ loss = policy_loss + self.vf_coef * (fast_loss + ensemble_loss) + entropy_loss
200
+
201
+ self.optimizer.zero_grad()
202
+ loss.backward()
203
+ nn.utils.clip_grad_norm_(
204
+ list(self.policy.parameters()) + list(self.ugtc.parameters()),
205
+ self.max_grad_norm,
206
+ )
207
+ self.optimizer.step()
208
+
209
+ gate_stats = self.ugtc.get_gate_stats(obs)
210
+ metrics.update({
211
+ "policy_loss": policy_loss.item(),
212
+ "fast_value_loss": fast_loss.item(),
213
+ "ensemble_value_loss": ensemble_loss.item(),
214
+ **gate_stats,
215
+ })
216
+
217
+ return metrics
218
+
219
+ def save(self, path: str) -> None:
220
+ torch.save(
221
+ {"policy": self.policy.state_dict(), "ugtc": self.ugtc.state_dict()},
222
+ path,
223
+ )
224
+
225
+ def load(self, path: str) -> None:
226
+ ckpt = torch.load(path, map_location="cpu")
227
+ self.policy.load_state_dict(ckpt["policy"])
228
+ self.ugtc.load_state_dict(ckpt["ugtc"])
ugtc/sac.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC-SAC: Soft Actor-Critic with Uncertainty-Gated Temporal Credit
3
+ ===================================================================
4
+
5
+ Integration strategy:
6
+ UGTC substitutes the value baseline in the SAC actor loss.
7
+ Entropy coefficient α, twin-Q training, and target networks are unchanged.
8
+
9
+ Standard SAC actor loss:
10
+ J_π = E[α·log π(a|s) - Q_min(s, a)]
11
+
12
+ UGTC-SAC actor loss:
13
+ J_π^UGTC = E[α·log π(a|s) - Q_min(s, a) + V^UGTC(s)]
14
+
15
+ where V^UGTC(s) = u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
16
+
17
+ This substitution replaces the implicit value baseline with an
18
+ uncertainty-aware estimate, providing variance reduction in the gradient.
19
+
20
+ Reference: https://doi.org/10.5281/zenodo.19715116
21
+ """
22
+
23
+ import copy
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.optim as optim
27
+ import torch.nn.functional as F
28
+ import numpy as np
29
+ from typing import Dict, Tuple
30
+
31
+ from ugtc.module import UGTCModule
32
+ from ugtc.td3 import ReplayBuffer
33
+
34
+
35
+ LOG_SIG_MAX = 2
36
+ LOG_SIG_MIN = -20
37
+ EPSILON = 1e-6
38
+
39
+
40
+ class SACGaussianPolicy(nn.Module):
41
+ """Squashed Gaussian policy for SAC."""
42
+
43
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256):
44
+ super().__init__()
45
+ self.net = nn.Sequential(
46
+ nn.Linear(obs_dim, hidden), nn.ReLU(),
47
+ nn.Linear(hidden, hidden), nn.ReLU(),
48
+ )
49
+ self.mean_head = nn.Linear(hidden, act_dim)
50
+ self.log_std_head = nn.Linear(hidden, act_dim)
51
+
52
+ def forward(self, obs: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
53
+ h = self.net(obs)
54
+ mean = self.mean_head(h)
55
+ log_std = self.log_std_head(h).clamp(LOG_SIG_MIN, LOG_SIG_MAX)
56
+ return mean, log_std
57
+
58
+ def sample(self, obs: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
59
+ mean, log_std = self.forward(obs)
60
+ std = log_std.exp()
61
+ normal = torch.distributions.Normal(mean, std)
62
+ x_t = normal.rsample()
63
+ y_t = torch.tanh(x_t)
64
+ log_prob = normal.log_prob(x_t) - torch.log(1 - y_t.pow(2) + EPSILON)
65
+ return y_t, log_prob.sum(-1)
66
+
67
+
68
+ class SACCritic(nn.Module):
69
+ """Twin Q-networks — same as TD3 critic."""
70
+
71
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256):
72
+ super().__init__()
73
+ in_dim = obs_dim + act_dim
74
+ self.q1 = nn.Sequential(
75
+ nn.Linear(in_dim, hidden), nn.ReLU(),
76
+ nn.Linear(hidden, hidden), nn.ReLU(),
77
+ nn.Linear(hidden, 1),
78
+ )
79
+ self.q2 = nn.Sequential(
80
+ nn.Linear(in_dim, hidden), nn.ReLU(),
81
+ nn.Linear(hidden, hidden), nn.ReLU(),
82
+ nn.Linear(hidden, 1),
83
+ )
84
+
85
+ def forward(self, obs, action):
86
+ x = torch.cat([obs, action], dim=-1)
87
+ return self.q1(x).squeeze(-1), self.q2(x).squeeze(-1)
88
+
89
+
90
+ class UGTCSAC:
91
+ """
92
+ UGTC-SAC trainer.
93
+
94
+ Entropy tuning, twin-Q critic training, and target network updates
95
+ are all standard SAC. UGTC modifies only the actor's value baseline.
96
+
97
+ Args:
98
+ obs_dim: Observation space dimension.
99
+ act_dim: Action space dimension.
100
+ hidden: Hidden layer width (default 256).
101
+ lr: Learning rate (default 3e-4).
102
+ gamma: Discount factor (default 0.99).
103
+ tau: Soft target update coefficient (default 0.005).
104
+ alpha: Initial entropy coefficient (default 0.2).
105
+ auto_alpha: Enable automatic entropy tuning (default True).
106
+ target_entropy: Target entropy for auto-tuning (default -act_dim).
107
+ M: UGTC ensemble size (default 3).
108
+ beta: UGTC gate temperature (default 5.0).
109
+ device: PyTorch device string (default "cpu").
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ obs_dim: int,
115
+ act_dim: int,
116
+ hidden: int = 256,
117
+ lr: float = 3e-4,
118
+ gamma: float = 0.99,
119
+ tau: float = 0.005,
120
+ alpha: float = 0.2,
121
+ auto_alpha: bool = True,
122
+ target_entropy: float = None,
123
+ M: int = 3,
124
+ beta: float = 5.0,
125
+ device: str = "cpu",
126
+ ):
127
+ self.gamma = gamma
128
+ self.tau = tau
129
+ self.act_dim = act_dim
130
+ self.device = torch.device(device)
131
+
132
+ # Policy and critics
133
+ self.policy = SACGaussianPolicy(obs_dim, act_dim, hidden).to(self.device)
134
+ self.critic = SACCritic(obs_dim, act_dim, hidden).to(self.device)
135
+ self.critic_target = copy.deepcopy(self.critic)
136
+
137
+ # UGTC module
138
+ self.ugtc = UGTCModule(obs_dim, hidden, M=M, beta=beta).to(self.device)
139
+
140
+ # Entropy coefficient
141
+ self.auto_alpha = auto_alpha
142
+ if auto_alpha:
143
+ self.target_entropy = target_entropy or -float(act_dim)
144
+ self.log_alpha = torch.zeros(1, requires_grad=True, device=self.device)
145
+ self.alpha = self.log_alpha.exp().item()
146
+ self.alpha_opt = optim.Adam([self.log_alpha], lr=lr)
147
+ else:
148
+ self.alpha = alpha
149
+
150
+ self.policy_opt = optim.Adam(self.policy.parameters(), lr=lr)
151
+ self.critic_opt = optim.Adam(self.critic.parameters(), lr=lr)
152
+ self.ugtc_opt = optim.Adam(self.ugtc.parameters(), lr=lr)
153
+
154
+ def select_action(self, obs: np.ndarray, deterministic: bool = False) -> np.ndarray:
155
+ obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
156
+ with torch.no_grad():
157
+ if deterministic:
158
+ mean, _ = self.policy(obs_t)
159
+ return torch.tanh(mean).squeeze(0).cpu().numpy()
160
+ action, _ = self.policy.sample(obs_t)
161
+ return action.squeeze(0).cpu().numpy()
162
+
163
+ def update(self, replay: ReplayBuffer, batch_size: int = 256) -> Dict[str, float]:
164
+ """One SAC gradient step with UGTC baseline substitution."""
165
+ batch = replay.sample(batch_size, self.device)
166
+ obs = batch["obs"]
167
+ actions = batch["actions"]
168
+ rewards = batch["rewards"]
169
+ next_obs = batch["next_obs"]
170
+ dones = batch["dones"]
171
+
172
+ # --- Critic update (standard SAC twin-Q, unchanged) ---
173
+ with torch.no_grad():
174
+ next_actions, next_log_pi = self.policy.sample(next_obs)
175
+ q1_next, q2_next = self.critic_target(next_obs, next_actions)
176
+ q_next = torch.min(q1_next, q2_next) - self.alpha * next_log_pi
177
+ q_target = rewards + (1.0 - dones) * self.gamma * q_next
178
+
179
+ q1, q2 = self.critic(obs, actions)
180
+ critic_loss = F.mse_loss(q1, q_target) + F.mse_loss(q2, q_target)
181
+
182
+ self.critic_opt.zero_grad()
183
+ critic_loss.backward()
184
+ self.critic_opt.step()
185
+
186
+ # --- Actor update (UGTC baseline substitution) ---
187
+ pi, log_pi = self.policy.sample(obs)
188
+ q1_pi, q2_pi = self.critic(obs, pi)
189
+ q_min_pi = torch.min(q1_pi, q2_pi)
190
+
191
+ # UGTC value baseline (state-only)
192
+ self.ugtc.train()
193
+ gate, v_fast, v_slow = self.ugtc.compute_gate(obs)
194
+ v_ugtc = (gate * v_slow + (1.0 - gate) * v_fast).detach()
195
+
196
+ # KEY CHANGE: V^UGTC replaces implicit value baseline in SAC actor loss
197
+ # Standard: J_π = (α·log π - Q_min).mean()
198
+ # UGTC-SAC: J_π = (α·log π - Q_min + V^UGTC).mean()
199
+ policy_loss = (self.alpha * log_pi - q_min_pi + v_ugtc).mean()
200
+
201
+ self.policy_opt.zero_grad()
202
+ policy_loss.backward()
203
+ self.policy_opt.step()
204
+
205
+ # --- UGTC critic training ---
206
+ with torch.no_grad():
207
+ v_target = (rewards + (1.0 - dones) * self.gamma * self.ugtc.get_value_ugtc(next_obs))
208
+ ugtc_loss = (self.ugtc.get_value_ugtc(obs) - v_target).pow(2).mean()
209
+
210
+ self.ugtc_opt.zero_grad()
211
+ ugtc_loss.backward()
212
+ self.ugtc_opt.step()
213
+
214
+ # --- Entropy coefficient update ---
215
+ if self.auto_alpha:
216
+ alpha_loss = -(self.log_alpha * (log_pi + self.target_entropy).detach()).mean()
217
+ self.alpha_opt.zero_grad()
218
+ alpha_loss.backward()
219
+ self.alpha_opt.step()
220
+ self.alpha = self.log_alpha.exp().item()
221
+
222
+ # Soft target update
223
+ for sp, tp in zip(self.critic.parameters(), self.critic_target.parameters()):
224
+ tp.data.copy_(self.tau * sp.data + (1.0 - self.tau) * tp.data)
225
+
226
+ return {
227
+ "critic_loss": critic_loss.item(),
228
+ "policy_loss": policy_loss.item(),
229
+ "ugtc_loss": ugtc_loss.item(),
230
+ "alpha": self.alpha,
231
+ **self.ugtc.get_gate_stats(obs),
232
+ }
ugtc/td3.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC-TD3: Twin Delayed DDPG with Uncertainty-Gated Temporal Credit
3
+ ===================================================================
4
+
5
+ Integration strategy:
6
+ UGTC provides a baseline correction for the actor gradient while the
7
+ backbone's clipped double-Q and delayed policy update are preserved.
8
+
9
+ Actor loss:
10
+ L_actor = -(Q_min(s, π(s)) + η · A^UGTC(s, π(s))).mean()
11
+
12
+ where A^UGTC(s, a) = Q_min(s, a) - V^UGTC(s)
13
+ V^UGTC(s) = u(s)·V̄_slow(s) + (1-u(s))·V_fast(s)
14
+
15
+ The critic (twin-Q) update is standard TD3: unmodified.
16
+
17
+ Reference: https://doi.org/10.5281/zenodo.19715116
18
+ """
19
+
20
+ import copy
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.optim as optim
24
+ import numpy as np
25
+ from typing import Dict, Tuple
26
+
27
+ from ugtc.module import UGTCModule
28
+
29
+
30
+ class TD3Actor(nn.Module):
31
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256, max_action: float = 1.0):
32
+ super().__init__()
33
+ self.max_action = max_action
34
+ self.net = nn.Sequential(
35
+ nn.Linear(obs_dim, hidden), nn.ReLU(),
36
+ nn.Linear(hidden, hidden), nn.ReLU(),
37
+ nn.Linear(hidden, act_dim), nn.Tanh(),
38
+ )
39
+
40
+ def forward(self, obs: torch.Tensor) -> torch.Tensor:
41
+ return self.max_action * self.net(obs)
42
+
43
+
44
+ class TD3Critic(nn.Module):
45
+ """Twin Q-networks — standard TD3 critic."""
46
+
47
+ def __init__(self, obs_dim: int, act_dim: int, hidden: int = 256):
48
+ super().__init__()
49
+ in_dim = obs_dim + act_dim
50
+ self.q1 = nn.Sequential(
51
+ nn.Linear(in_dim, hidden), nn.ReLU(),
52
+ nn.Linear(hidden, hidden), nn.ReLU(),
53
+ nn.Linear(hidden, 1),
54
+ )
55
+ self.q2 = nn.Sequential(
56
+ nn.Linear(in_dim, hidden), nn.ReLU(),
57
+ nn.Linear(hidden, hidden), nn.ReLU(),
58
+ nn.Linear(hidden, 1),
59
+ )
60
+
61
+ def forward(self, obs: torch.Tensor, action: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
62
+ x = torch.cat([obs, action], dim=-1)
63
+ return self.q1(x).squeeze(-1), self.q2(x).squeeze(-1)
64
+
65
+ def q_min(self, obs: torch.Tensor, action: torch.Tensor) -> torch.Tensor:
66
+ q1, q2 = self.forward(obs, action)
67
+ return torch.min(q1, q2)
68
+
69
+
70
+ class ReplayBuffer:
71
+ """Simple experience replay buffer."""
72
+
73
+ def __init__(self, obs_dim: int, act_dim: int, capacity: int = 1_000_000):
74
+ self.capacity = capacity
75
+ self.ptr = 0
76
+ self.size = 0
77
+ self.obs = np.zeros((capacity, obs_dim), dtype=np.float32)
78
+ self.next_obs = np.zeros((capacity, obs_dim), dtype=np.float32)
79
+ self.actions = np.zeros((capacity, act_dim), dtype=np.float32)
80
+ self.rewards = np.zeros((capacity, 1), dtype=np.float32)
81
+ self.dones = np.zeros((capacity, 1), dtype=np.float32)
82
+
83
+ def add(self, obs, action, reward, next_obs, done):
84
+ self.obs[self.ptr] = obs
85
+ self.actions[self.ptr] = action
86
+ self.rewards[self.ptr] = reward
87
+ self.next_obs[self.ptr] = next_obs
88
+ self.dones[self.ptr] = float(done)
89
+ self.ptr = (self.ptr + 1) % self.capacity
90
+ self.size = min(self.size + 1, self.capacity)
91
+
92
+ def sample(self, batch_size: int, device: torch.device) -> Dict[str, torch.Tensor]:
93
+ idx = np.random.randint(0, self.size, size=batch_size)
94
+ return {
95
+ "obs": torch.FloatTensor(self.obs[idx]).to(device),
96
+ "actions": torch.FloatTensor(self.actions[idx]).to(device),
97
+ "rewards": torch.FloatTensor(self.rewards[idx]).to(device),
98
+ "next_obs": torch.FloatTensor(self.next_obs[idx]).to(device),
99
+ "dones": torch.FloatTensor(self.dones[idx]).to(device),
100
+ }
101
+
102
+
103
+ class UGTCTD3:
104
+ """
105
+ UGTC-TD3 trainer.
106
+
107
+ The backbone's twin-Q, target networks, and delayed policy update are
108
+ fully preserved. UGTC modifies only the actor gradient via a value
109
+ baseline correction.
110
+
111
+ Args:
112
+ obs_dim: Observation space dimension.
113
+ act_dim: Action space dimension.
114
+ max_action: Action space bound (default 1.0).
115
+ hidden: Hidden layer width (default 256).
116
+ lr: Learning rate for all networks (default 3e-4).
117
+ gamma: Discount factor (default 0.99).
118
+ tau: Soft target update coefficient (default 0.005).
119
+ policy_noise: Target policy smoothing std (default 0.2).
120
+ noise_clip: Target noise clip (default 0.5).
121
+ policy_delay: Actor update frequency in critic steps (default 2).
122
+ eta: UGTC correction weight in actor loss (default 0.5).
123
+ M: UGTC ensemble size (default 3).
124
+ beta: UGTC gate temperature (default 5.0).
125
+
126
+ Note: eta=0.5 is the suggested default. This hyperparameter controls the
127
+ strength of the UGTC baseline correction in the actor loss and may benefit
128
+ from tuning per environment. It is not a fixed UGTC hyperparameter.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ obs_dim: int,
134
+ act_dim: int,
135
+ max_action: float = 1.0,
136
+ hidden: int = 256,
137
+ lr: float = 3e-4,
138
+ gamma: float = 0.99,
139
+ tau: float = 0.005,
140
+ policy_noise: float = 0.2,
141
+ noise_clip: float = 0.5,
142
+ policy_delay: int = 2,
143
+ eta: float = 0.5,
144
+ M: int = 3,
145
+ beta: float = 5.0,
146
+ device: str = "cpu",
147
+ ):
148
+ self.gamma = gamma
149
+ self.tau = tau
150
+ self.policy_noise = policy_noise
151
+ self.noise_clip = noise_clip
152
+ self.policy_delay = policy_delay
153
+ self.eta = eta
154
+ self.max_action = max_action
155
+ self.device = torch.device(device)
156
+ self._update_count = 0
157
+
158
+ self.actor = TD3Actor(obs_dim, act_dim, hidden, max_action).to(self.device)
159
+ self.actor_target = copy.deepcopy(self.actor)
160
+
161
+ self.critic = TD3Critic(obs_dim, act_dim, hidden).to(self.device)
162
+ self.critic_target = copy.deepcopy(self.critic)
163
+
164
+ # UGTC module — obs_dim only (state-based value baseline)
165
+ self.ugtc = UGTCModule(obs_dim, hidden, M=M, beta=beta).to(self.device)
166
+
167
+ self.actor_opt = optim.Adam(self.actor.parameters(), lr=lr)
168
+ self.critic_opt = optim.Adam(self.critic.parameters(), lr=lr)
169
+ self.ugtc_opt = optim.Adam(self.ugtc.parameters(), lr=lr)
170
+
171
+ def select_action(self, obs: np.ndarray, noise: float = 0.1) -> np.ndarray:
172
+ obs_t = torch.FloatTensor(obs).unsqueeze(0).to(self.device)
173
+ with torch.no_grad():
174
+ action = self.actor(obs_t).squeeze(0).cpu().numpy()
175
+ if noise > 0:
176
+ action += np.random.normal(0, noise, size=action.shape)
177
+ return action.clip(-self.max_action, self.max_action)
178
+
179
+ def update(self, replay: ReplayBuffer, batch_size: int = 256) -> Dict[str, float]:
180
+ """One TD3 gradient step with UGTC baseline correction."""
181
+ self._update_count += 1
182
+ batch = replay.sample(batch_size, self.device)
183
+ obs = batch["obs"]
184
+ actions = batch["actions"]
185
+ rewards = batch["rewards"]
186
+ next_obs = batch["next_obs"]
187
+ dones = batch["dones"]
188
+
189
+ # --- Critic update (standard TD3, unchanged) ---
190
+ with torch.no_grad():
191
+ noise = (
192
+ torch.randn_like(actions) * self.policy_noise
193
+ ).clamp(-self.noise_clip, self.noise_clip)
194
+ next_actions = (self.actor_target(next_obs) + noise).clamp(
195
+ -self.max_action, self.max_action
196
+ )
197
+ q1_next, q2_next = self.critic_target(next_obs, next_actions)
198
+ q_target = rewards + (1.0 - dones) * self.gamma * torch.min(q1_next, q2_next)
199
+
200
+ q1, q2 = self.critic(obs, actions)
201
+ critic_loss = (q1 - q_target).pow(2).mean() + (q2 - q_target).pow(2).mean()
202
+
203
+ self.critic_opt.zero_grad()
204
+ critic_loss.backward()
205
+ self.critic_opt.step()
206
+
207
+ metrics = {"critic_loss": critic_loss.item()}
208
+
209
+ # --- Actor update (delayed, with UGTC baseline correction) ---
210
+ if self._update_count % self.policy_delay == 0:
211
+ pi = self.actor(obs)
212
+ q_min = self.critic.q_min(obs, pi)
213
+
214
+ # UGTC value baseline
215
+ self.ugtc.train()
216
+ gate, v_fast, v_slow = self.ugtc.compute_gate(obs)
217
+ v_ugtc = gate * v_slow + (1.0 - gate) * v_fast
218
+
219
+ # A^UGTC(s, π(s)) = Q_min(s, π(s)) - V^UGTC(s)
220
+ advantage_ugtc = q_min - v_ugtc.detach()
221
+
222
+ # Actor loss: DPG + UGTC baseline correction
223
+ actor_loss = -(q_min + self.eta * advantage_ugtc).mean()
224
+
225
+ self.actor_opt.zero_grad()
226
+ actor_loss.backward()
227
+ self.actor_opt.step()
228
+
229
+ # Train UGTC critics against TD targets
230
+ v_ugtc_pred = self.ugtc.get_value_ugtc(obs)
231
+ ugtc_value_target = (rewards + (1.0 - dones) * self.gamma * self.ugtc.get_value_ugtc(next_obs)).detach()
232
+ ugtc_loss = (v_ugtc_pred - ugtc_value_target).pow(2).mean()
233
+
234
+ self.ugtc_opt.zero_grad()
235
+ ugtc_loss.backward()
236
+ self.ugtc_opt.step()
237
+
238
+ # Soft target updates
239
+ self._soft_update(self.actor, self.actor_target)
240
+ self._soft_update(self.critic, self.critic_target)
241
+
242
+ metrics.update({
243
+ "actor_loss": actor_loss.item(),
244
+ "ugtc_value_loss": ugtc_loss.item(),
245
+ **self.ugtc.get_gate_stats(obs),
246
+ })
247
+
248
+ return metrics
249
+
250
+ def _soft_update(self, source: nn.Module, target: nn.Module) -> None:
251
+ for sp, tp in zip(source.parameters(), target.parameters()):
252
+ tp.data.copy_(self.tau * sp.data + (1.0 - self.tau) * tp.data)
ugtc/utils.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UGTC utility functions — evaluation metrics and logging helpers.
3
+ """
4
+
5
+ import numpy as np
6
+ from typing import List, Dict, Sequence
7
+
8
+
9
+ def bootstrap_ci(
10
+ data: Sequence[float],
11
+ n_bootstrap: int = 10_000,
12
+ ci: float = 0.95,
13
+ ) -> tuple[float, float]:
14
+ """
15
+ Compute bootstrap confidence interval (percentile method).
16
+
17
+ Args:
18
+ data: 1-D sequence of scalar values (e.g., final returns per seed).
19
+ n_bootstrap: Number of bootstrap samples (default 10,000).
20
+ ci: Confidence level (default 0.95).
21
+
22
+ Returns:
23
+ (lower, upper) confidence interval bounds.
24
+ """
25
+ data = np.asarray(data, dtype=float)
26
+ rng = np.random.default_rng()
27
+ boot_means = np.array([
28
+ rng.choice(data, size=len(data), replace=True).mean()
29
+ for _ in range(n_bootstrap)
30
+ ])
31
+ alpha = (1.0 - ci) / 2.0
32
+ return float(np.percentile(boot_means, 100 * alpha)), float(np.percentile(boot_means, 100 * (1 - alpha)))
33
+
34
+
35
+ def interquartile_mean(data: Sequence[float]) -> float:
36
+ """
37
+ Compute IQM (Interquartile Mean) — robust to outlier seeds.
38
+
39
+ IQM discards the bottom and top 25% of seeds before averaging,
40
+ following the rliable evaluation protocol.
41
+
42
+ Args:
43
+ data: 1-D sequence of scalar values.
44
+
45
+ Returns:
46
+ IQM value.
47
+ """
48
+ data = np.sort(np.asarray(data, dtype=float))
49
+ n = len(data)
50
+ q1 = n // 4
51
+ q3 = 3 * n // 4
52
+ return float(data[q1:q3 + 1].mean())
53
+
54
+
55
+ def area_under_curve(
56
+ curves: List[List[Dict[str, float]]],
57
+ step_key: str = "step",
58
+ return_key: str = "mean_return",
59
+ normalize_by: float = None,
60
+ ) -> float:
61
+ """
62
+ Compute mean area under the learning curve across seeds.
63
+
64
+ Args:
65
+ curves: List of per-seed curves, each a list of {step_key: ..., return_key: ...} dicts.
66
+ step_key: Key for timestep values.
67
+ return_key: Key for return values.
68
+ normalize_by: Normalise AUC by this step count (e.g., total_steps).
69
+
70
+ Returns:
71
+ Mean AUC across seeds.
72
+ """
73
+ aucs = []
74
+ for curve in curves:
75
+ steps = np.array([r[step_key] for r in curve])
76
+ returns = np.array([r[return_key] for r in curve])
77
+ auc = np.trapz(returns, steps)
78
+ if normalize_by is not None:
79
+ auc /= normalize_by
80
+ aucs.append(auc)
81
+ return float(np.mean(aucs))
82
+
83
+
84
+ def print_eval_table(
85
+ results: Dict[str, List[float]],
86
+ n_bootstrap: int = 10_000,
87
+ ) -> None:
88
+ """
89
+ Pretty-print evaluation table with IQM, mean±std, and 95% CI.
90
+
91
+ Args:
92
+ results: Dict mapping method name → list of final returns per seed.
93
+ n_bootstrap: Bootstrap samples for CI.
94
+ """
95
+ header = f"{'Method':<20} {'Mean':>8} {'Std':>8} {'IQM':>8} {'95% CI':>18}"
96
+ print(header)
97
+ print("-" * len(header))
98
+ for method, returns in sorted(results.items()):
99
+ mean = float(np.mean(returns))
100
+ std = float(np.std(returns))
101
+ iqm = interquartile_mean(returns)
102
+ lo, hi = bootstrap_ci(returns, n_bootstrap)
103
+ print(f"{method:<20} {mean:>8.1f} {std:>8.1f} {iqm:>8.1f} [{lo:>7.1f}, {hi:>7.1f}]")
104
+
105
+
106
+ def compute_sample_efficiency(
107
+ ugtc_curve: List[Dict],
108
+ baseline_curve: List[Dict],
109
+ target_return: float,
110
+ step_key: str = "step",
111
+ return_key: str = "mean_return",
112
+ ) -> Dict[str, float]:
113
+ """
114
+ Compute steps-to-threshold: how many steps to first exceed target_return.
115
+
116
+ Returns -1 for a curve that never reaches the target.
117
+ """
118
+
119
+ def steps_to(curve):
120
+ for point in curve:
121
+ if point[return_key] >= target_return:
122
+ return float(point[step_key])
123
+ return -1.0
124
+
125
+ ugtc_steps = steps_to(ugtc_curve)
126
+ base_steps = steps_to(baseline_curve)
127
+
128
+ result = {
129
+ "ugtc_steps_to_target": ugtc_steps,
130
+ "baseline_steps_to_target": base_steps,
131
+ }
132
+ if ugtc_steps > 0 and base_steps > 0:
133
+ result["efficiency_ratio"] = base_steps / ugtc_steps
134
+ return result