Initial release: UGTC - Uncertainty-Gated Temporal Credit
Browse files- .github/workflows/ci.yml +69 -0
- .github/workflows/pages.yml +42 -0
- CITATION.cff +42 -0
- CONTRIBUTING.md +84 -0
- LICENSE +21 -0
- README.md +139 -0
- benchmarks/__init__.py +0 -0
- benchmarks/mujoco/__init__.py +0 -0
- benchmarks/mujoco/train_ugtc_td3_mujoco.py +124 -0
- benchmarks/procgen/__init__.py +0 -0
- benchmarks/procgen/train_ugtc_ppo_procgen.py +445 -0
- configs/ant.yaml +8 -0
- configs/carracing.yaml +8 -0
- configs/crafter.yaml +8 -0
- configs/hopper.yaml +8 -0
- configs/metaworld.yaml +9 -0
- configs/procgen.yaml +9 -0
- docs/index.html +504 -0
- examples/ugtc_ppo_cartpole.py +197 -0
- examples/ugtc_ppo_mujoco.py +170 -0
- examples/ugtc_td3_pendulum.py +102 -0
- implementations/cpp/ugtc.hpp +310 -0
- implementations/java/UGTCModule.java +372 -0
- pseudocode/ugtc_pseudocode.md +197 -0
- pyproject.toml +100 -0
- requirements.txt +11 -0
- tests/__init__.py +0 -0
- tests/test_integrations.py +216 -0
- tests/test_module.py +243 -0
- ugtc/__init__.py +41 -0
- ugtc/ddpg.py +199 -0
- ugtc/module.py +289 -0
- ugtc/ppo.py +228 -0
- ugtc/sac.py +232 -0
- ugtc/td3.py +252 -0
- ugtc/utils.py +134 -0
.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 |
+
[](https://doi.org/10.5281/zenodo.19715116)
|
| 22 |
+
[](https://doi.org/10.5281/zenodo.19715116)
|
| 23 |
+
[](LICENSE)
|
| 24 |
+
[](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
|