Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +54 -0
- LICENSE +21 -0
- README.md +148 -0
- README_zh.md +174 -0
- causal_tech_stack_demo.py +208 -0
- checkpoints/lm1_8m_round9.pt +3 -0
- checkpoints/lm1_latest.pt +3 -0
- checkpoints/lm1_round1.pt +3 -0
- checkpoints/lm1_round2.pt +3 -0
- checkpoints/lm1_round3.pt +3 -0
- checkpoints/lm1_round4.pt +3 -0
- checkpoints/lm1_smoke.pt +3 -0
- checkpoints/lm2_latest.pt +3 -0
- checkpoints/lm3_base_sequential_latest.pt +3 -0
- checkpoints/lm3_replay_sequential_latest.pt +3 -0
- hibs_lnn/causal/__init__.py +30 -0
- hibs_lnn/causal/causal_proposer.py +141 -0
- hibs_lnn/causal/causal_rl.py +109 -0
- hibs_lnn/causal/dag.py +153 -0
- hibs_lnn/causal/discovery.py +124 -0
- hibs_lnn/causal/identification.py +93 -0
- hibs_lnn/causal/scm.py +212 -0
- hibs_lnn/causal/self_test.py +90 -0
- hibs_lnn/code_world.py +380 -0
- hibs_lnn/dual_proposer.py +221 -0
- hibs_lnn/evidence_bank.py +345 -0
- hibs_lnn/gridworld.py +509 -0
- hibs_lnn/knowledge.py +386 -0
- hibs_lnn/meta_rule_world.py +387 -0
- hibs_lnn/oak_proposer.py +444 -0
- hibs_lnn/option_manager.py +502 -0
- hibs_lnn/pln_head.py +264 -0
- hibs_lnn/rl_proposer.py +464 -0
- hibs_lnn/rr_agent.py +1108 -0
- hibs_lnn/rr_options.py +278 -0
- hibs_lnn/skill_agent.py +338 -0
- hibs_lnn/skill_mdp.py +187 -0
- hibs_lnn/ssm_v30_3.py +166 -0
- hibs_lnn/stochastic_keydoor.py +195 -0
- hibs_lnn/subtask_options.py +310 -0
- hibs_lnn/swifttd_head.py +435 -0
- hibs_lnn/transition_model.py +118 -0
- hibs_lnn/uncertainty_gate.py +239 -0
- hibs_lnn/value_proposer.py +180 -0
- manifests/delta_compile_report.md +21 -0
- manifests/knowledge_graph.json +143 -0
- manifests/raw_sources.csv +2 -0
- manifests/raw_sources.meta.json +34 -0
- manifests/stale_report.md +40 -0
- scripts/bootstrap_knowledge_system.py +195 -0
.gitignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
*.egg-info/
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
.eggs/
|
| 10 |
+
|
| 11 |
+
# Virtual environments
|
| 12 |
+
venv/
|
| 13 |
+
.venv/
|
| 14 |
+
env/
|
| 15 |
+
.env/
|
| 16 |
+
|
| 17 |
+
# Checkpoints and results (large generated files)
|
| 18 |
+
checkpoints/
|
| 19 |
+
results/
|
| 20 |
+
|
| 21 |
+
# Downloaded datasets (LM4 wave data, >100MB)
|
| 22 |
+
data/
|
| 23 |
+
|
| 24 |
+
# Logs and temporary data
|
| 25 |
+
*.log
|
| 26 |
+
*.tmp
|
| 27 |
+
/tmp/lm2data/
|
| 28 |
+
/tmp/lm3data/
|
| 29 |
+
|
| 30 |
+
# OS files
|
| 31 |
+
.DS_Store
|
| 32 |
+
Thumbs.db
|
| 33 |
+
desktop.ini
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.vscode/
|
| 37 |
+
.idea/
|
| 38 |
+
*.swp
|
| 39 |
+
*.swo
|
| 40 |
+
*~
|
| 41 |
+
|
| 42 |
+
# Jupyter
|
| 43 |
+
.ipynb_checkpoints/
|
| 44 |
+
*.ipynb
|
| 45 |
+
|
| 46 |
+
# Archives
|
| 47 |
+
*.zip
|
| 48 |
+
*.tar.gz
|
| 49 |
+
*.rar
|
| 50 |
+
paper/*.aux
|
| 51 |
+
paper/*.bbl
|
| 52 |
+
paper/*.out
|
| 53 |
+
paper/*.log
|
| 54 |
+
paper/*.jsonl
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 yuanjieliu Authors
|
| 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,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- state-space-model
|
| 5 |
+
- continual-learning
|
| 6 |
+
- metalearning
|
| 7 |
+
- ssm
|
| 8 |
+
library_name: other
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# headless
|
| 12 |
+
|
| 13 |
+
A production-grade **continuously learning model** system built on SSM (State Space Model) + OML (Optimization-based Meta-Learning) with autonomous data exploration.
|
| 14 |
+
|
| 15 |
+
> Formerly known as LMT-twister. Established 2026-08-20.
|
| 16 |
+
|
| 17 |
+
## Continuous Learning
|
| 18 |
+
|
| 19 |
+
headless models learn continuously during runtime without forgetting previously acquired knowledge. The system demonstrates:
|
| 20 |
+
|
| 21 |
+
- **Zero forgetting across domains** — LM2 on wikitext-2 (3 domains, streaming 500 steps/domain): all domains show **positive transfer** after sequential training (A: 0.429→0.472, B: 0.478→0.488, C: 0.493).
|
| 22 |
+
- **Cross-domain robustness** — LM3 on en→zh→code (BPE, 3 domains): naive sequential training causes catastrophic forgetting (-10.4pp on English), proving **true continuous learning requires mechanisms** (Replay +5.0pp, correct OML via inner-loop adaptation).
|
| 23 |
+
- **Autonomous exploration** — LM1 generates its own training data via a value-function proposer (simplicity + consistency + coverage), reaching **unseen accuracy 0.1276 / c4 structure 0.1510** with **negative forgetting** (-0.006), beating the family 22-intervention peak (0.099) by **1.5×**.
|
| 24 |
+
|
| 25 |
+
## Architecture
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
LM1 (Production, 1.05M params)
|
| 29 |
+
├── SSM backbone (V31_RLN, configurable d_model/d_state/layers)
|
| 30 |
+
├── SwiftTD head — per-feature step-size optimization (IDBD online, no BPTT)
|
| 31 |
+
├── OML dual-loop — inner-loop PLN adaptation + outer-loop meta-update
|
| 32 |
+
└── S4 Value Proposer — autonomous task generation from structural novelty
|
| 33 |
+
|
| 34 |
+
LM2 (Text, 0.71M params)
|
| 35 |
+
├── SSM character-level LM (vocab 607)
|
| 36 |
+
└── Streaming 3-domain wikitext-2
|
| 37 |
+
|
| 38 |
+
LM3 (BPE, 2.26M params)
|
| 39 |
+
├── BPE tokenizer (vocab 4627)
|
| 40 |
+
└── en→zh→code cross-domain experiments
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### Key Mechanisms
|
| 44 |
+
|
| 45 |
+
| Mechanism | Role | Evidence |
|
| 46 |
+
|:--|:--|:--|
|
| 47 |
+
| **Replay** | Anti-forgetting (data anchoring) | +5.0pp avg over base |
|
| 48 |
+
| **OML** | Adaptation efficiency (inner-loop cloning) | Not anti-forgetting; enables fast new-domain adaptation |
|
| 49 |
+
| **SwiftTD** | Per-feature learning rates (online IDBD) | Prevents step-size collapse under bound/decay |
|
| 50 |
+
| **Value Proposer** | Autonomous data generation | Drives continuous exploration without human labeling |
|
| 51 |
+
|
| 52 |
+
## Project Structure
|
| 53 |
+
|
| 54 |
+
```
|
| 55 |
+
tests/
|
| 56 |
+
├── run_lm1_production.py — Production autonomous CL model (checkpoint, eval, infer)
|
| 57 |
+
├── run_lm2_text.py — Real-text continuous learning (wikitext-2)
|
| 58 |
+
├── run_lm3_bpe.py — BPE + cross-domain CL (5 methods)
|
| 59 |
+
├── lm1_inference_demo.py — Production inference API (define+adapt+query)
|
| 60 |
+
└── run_v3*.py — 17 experiment runners (V31-V35 ablation suite)
|
| 61 |
+
|
| 62 |
+
hibs_lnn/
|
| 63 |
+
├── ssm_v30_3.py — SSM layer with internal entanglement (phase modulation)
|
| 64 |
+
├── swiftd_head.py — SwiftTD per-feature step-size optimizer
|
| 65 |
+
├── meta_rule_world.py — Meta-learning task distribution (S4 permutations)
|
| 66 |
+
└── code_world.py — Register-machine sandbox (8 instructions, 4 registers)
|
| 67 |
+
|
| 68 |
+
docs/wiki/log.md — Continuous learning experiment log
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Quick Start
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
# Smoke test (1 round, 2 iters, no migration)
|
| 75 |
+
python smoke_lm1.py
|
| 76 |
+
|
| 77 |
+
# Production training (10 rounds, auto-explore + evaluate)
|
| 78 |
+
python tests/run_lm1_production.py --rounds 10
|
| 79 |
+
|
| 80 |
+
# Resume from checkpoint
|
| 81 |
+
python tests/run_lm1_production.py --resume
|
| 82 |
+
|
| 83 |
+
# Inference demo
|
| 84 |
+
python tests/lm1_inference_demo.py --ckpt checkpoints/lm1_final.pt
|
| 85 |
+
|
| 86 |
+
# Text continuous learning
|
| 87 |
+
python tests/run_lm2_text.py
|
| 88 |
+
|
| 89 |
+
# BPE cross-domain (replay method)
|
| 90 |
+
python tests/run_lm3_bpe.py --method replay
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## Results
|
| 94 |
+
|
| 95 |
+
### LM1 — Production Autonomous CL
|
| 96 |
+
|
| 97 |
+
| Metric | Value | Family Peak | Improvement |
|
| 98 |
+
|:--|:--|:--|:--|
|
| 99 |
+
| Unseen accuracy | 0.1276 | 0.099 | **+29%** |
|
| 100 |
+
| c4 structure accuracy | 0.1510 | 0.099 | **+53%** |
|
| 101 |
+
| Forgetting | -0.006 | — | **Negative (improvement)** |
|
| 102 |
+
| S5 transfer (R16) | 0.1172 | — | Positive migration |
|
| 103 |
+
|
| 104 |
+
- **17 rounds / 680 iters** fully automatic convergence
|
| 105 |
+
- Model: `checkpoints/lm1_final.pt` (6.3MB)
|
| 106 |
+
- Report: `results/lm1_report.md`
|
| 107 |
+
|
| 108 |
+
### LM2 — Real-Text CL
|
| 109 |
+
|
| 110 |
+
| Domain | Before | After | Change |
|
| 111 |
+
|:--|:--|:--|:--|
|
| 112 |
+
| A (train 2M chars) | 0.429 | 0.472 | **+4.3pp** |
|
| 113 |
+
| B (valid 1.1M chars) | 0.478 | 0.488 | **+1.0pp** |
|
| 114 |
+
| C (test 1.3M chars) | 0.493 | — | **Positive transfer** |
|
| 115 |
+
|
| 116 |
+
**Conclusion:** Continuous learning with zero forgetting holds on real text (same-domain wikitext).
|
| 117 |
+
|
| 118 |
+
### LM3 — Cross-Domain CL
|
| 119 |
+
|
| 120 |
+
| Method | en | zh | code | Average |
|
| 121 |
+
|:--|:--|:--|:--|:--|
|
| 122 |
+
| Base (sequential) | 0.260 | 0.076 | 0.340 | 0.170 |
|
| 123 |
+
| Replay | 0.252 | 0.057 | 0.352 | **0.220** |
|
| 124 |
+
| OML (dual-lr) | — | — | — | 0.131 |
|
| 125 |
+
| OML2 (correct) | — | — | — | 0.168 |
|
| 126 |
+
|
| 127 |
+
**Conclusion:** Replay is the only effective anti-forgetting mechanism (+5.0pp). Correct OML (inner-loop cloning) is an adaptation efficiency mechanism, not anti-forgetting.
|
| 128 |
+
|
| 129 |
+
## Mechanism Conclusions
|
| 130 |
+
|
| 131 |
+
1. **Replay is the sole anti-forgetting mechanism** — data-level anchoring (+5.0pp)
|
| 132 |
+
2. **OML is adaptation efficiency** — correct form = inner-loop cloning + query meta-loss
|
| 133 |
+
3. **Dual learning rate ≠ OML** — simple head+body LR split fails
|
| 134 |
+
4. **Naive sequential training = catastrophic forgetting** across languages
|
| 135 |
+
5. **Production CL = larger representation × long training × autonomous data** — emergent as a system
|
| 136 |
+
|
| 137 |
+
## Data Requirements
|
| 138 |
+
|
| 139 |
+
- **LM1 / LM2**: No external data needed (synthetic tasks / wikitext-2 auto-download)
|
| 140 |
+
- **LM3**: Place data in `/tmp/lm2data` and `/tmp/lm3data` respectively
|
| 141 |
+
|
| 142 |
+
## Citation
|
| 143 |
+
|
| 144 |
+
If you use this work, please cite the mechanism conclusions and the LM1 production system results.
|
| 145 |
+
|
| 146 |
+
## License
|
| 147 |
+
|
| 148 |
+
MIT — see [LICENSE](LICENSE)
|
README_zh.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# headless
|
| 2 |
+
|
| 3 |
+
基于 SSM(状态空间模型)+ OML(基于优化的元学习)构建的生产级**持续学习模型**系统,配备自主数据探索能力。
|
| 4 |
+
|
| 5 |
+
> 前身为 LMT-twister。建立于 2026-08-20。
|
| 6 |
+
|
| 7 |
+
## 持续学习
|
| 8 |
+
|
| 9 |
+
headless 模型在运行时持续学习,不会遗忘先前获取的知识。系统展示的能力包括:
|
| 10 |
+
|
| 11 |
+
- **跨域零遗忘** — LM2 在 wikitext-2 上(3 域,流式 500 步/域):顺序训练后所有域均呈现**正向迁移**(A: 0.429→0.472,B: 0.478→0.488,C: 0.493)。
|
| 12 |
+
- **跨域鲁棒性** — LM3 在 en→zh→code 上(BPE,3 域):朴素顺序训练造成灾难性遗忘(English -10.4pp),证明**真正的持续学习需要机制**(Replay +5.0pp,正确 OML 通过内循环适应)。
|
| 13 |
+
- **自主探索** — LM1 通过价值函数提议器(简约性 + 自洽性 + 覆盖)自主生成训练数据,达到 **unseen 准确率 0.1276 / c4 结构 0.1510**,**遗忘为负**(-0.006),以 **1.5 倍** 超越家族 22 次干预峰值(0.099)。
|
| 14 |
+
|
| 15 |
+
## 架构
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
LM1(生产级,105 万参数)
|
| 19 |
+
├── SSM 骨干网络(V31_RLN,d_model/d_state/层数可配置)
|
| 20 |
+
├── SwiftTD 头 — 逐特征步长优化(在线 IDBD,无需 BPTT)
|
| 21 |
+
├── OML 双层循环 — 内循环 PLN 适应 + 外循环元更新
|
| 22 |
+
└── S4 价值提议器 — 基于结构新颖性自主生成任务
|
| 23 |
+
|
| 24 |
+
LM2(文本,71 万参数)
|
| 25 |
+
├── SSM 字符级语言模型(词表 607)
|
| 26 |
+
└── 三域流式 wikitext-2
|
| 27 |
+
|
| 28 |
+
LM3(BPE,226 万参数)
|
| 29 |
+
├── BPE 分词器(词表 4627)
|
| 30 |
+
└── en→zh→code 跨域实验
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 核心机制
|
| 34 |
+
|
| 35 |
+
| 机制 | 作用 | 证据 |
|
| 36 |
+
|:--|:--|:--|
|
| 37 |
+
| **Replay(回放)** | 防遗忘(数据级锚定) | 相对基线 +5.0pp |
|
| 38 |
+
| **OML** | 适应效率(内循环克隆) | 非防遗忘机制;实现新域快速适应 |
|
| 39 |
+
| **SwiftTD** | 逐特征学习率(在线 IDBD) | 防止 bound/decay 下的步长塌缩 |
|
| 40 |
+
| **价值提议器** | 自主数据生成 | 无需人工标注即可持续探索 |
|
| 41 |
+
|
| 42 |
+
## 因果技术栈(Causal AI,新增)
|
| 43 |
+
|
| 44 |
+
把《Causal AI》一书的因果方法落地到 S4 元学习世界,用**因果发现改进自主数据提议器**:
|
| 45 |
+
|
| 46 |
+
| 文件 | 落地 | 对应书章节 |
|
| 47 |
+
|:--|:--|:--|
|
| 48 |
+
| `hibs_lnn/causal/dag.py` | DAG + d-separation | 第 3-4 章 |
|
| 49 |
+
| `hibs_lnn/causal/scm.py` | SCM + do() 干预 + 反事实 | 第 6-9 章 |
|
| 50 |
+
| `hibs_lnn/causal/identification.py` | 后门调整 / g-公式识别 | 第 10-11 章 |
|
| 51 |
+
| `hibs_lnn/causal/discovery.py` | 条件独立检验 + PC 骨架 | 因果发现 |
|
| 52 |
+
| `hibs_lnn/causal/causal_proposer.py` | 因果驱动的提议器(do-效应/反事实) | 自主探索 |
|
| 53 |
+
| `hibs_lnn/causal/causal_rl.py` | Causal RL + 因果 credit assignment | 第 12 章 |
|
| 54 |
+
|
| 55 |
+
- 演示:`python3 causal_tech_stack_demo.py`(8 步,秒级)
|
| 56 |
+
- 自测:`python3 -m hibs_lnn.causal.self_test`(15/15 passed)
|
| 57 |
+
- 文档:`docs/wiki/causal_stack.md`
|
| 58 |
+
|
| 59 |
+
**实测**:同样已学集合下,因果提议器每步覆盖新生成元原子 **0.50**,启发式仅 **0.33**。
|
| 60 |
+
## 项目结构
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
tests/
|
| 64 |
+
├── run_lm1_production.py — 生产级自主持续学习模型(checkpoint、评估、推理)
|
| 65 |
+
├── run_lm2_text.py — 真实文本持续学习(wikitext-2)
|
| 66 |
+
├── run_lm3_bpe.py — BPE + 跨域持续学习(5 种方法)
|
| 67 |
+
├── lm1_inference_demo.py — 生产级推理接口(define+adapt+query)
|
| 68 |
+
└── run_v3*.py — 17 个实验运行脚本(V31-V35 消融套件)
|
| 69 |
+
|
| 70 |
+
hibs_lnn/
|
| 71 |
+
├── ssm_v30_3.py — 带内部纠缠的 SSM 层(相位调制)
|
| 72 |
+
├── swiftd_head.py — SwiftTD 逐特征步长优化器
|
| 73 |
+
├── meta_rule_world.py — 元学习任务分布(S4 排列)
|
| 74 |
+
├── code_world.py — 寄存器机沙箱(8 条指令,4 个寄存器)
|
| 75 |
+
└── causal/ — Causal AI 技术栈(DoWhy/pgmpy 的轻量等价物)
|
| 76 |
+
├── dag.py — DAG + d-separation
|
| 77 |
+
├── scm.py — 结构因果模型 + do() + 反事实
|
| 78 |
+
├── identification.py — 后门调整 / g-公式识别
|
| 79 |
+
├── discovery.py — 条件独立检验 + PC 骨架
|
| 80 |
+
├── causal_proposer.py — 因果驱动的自主数据提议器(改进提议器)
|
| 81 |
+
└── causal_rl.py — Causal RL + 因果 credit assignment
|
| 82 |
+
|
| 83 |
+
tests/
|
| 84 |
+
├── run_lm1_production.py — 生产级自主持续学习模型(checkpoint、评估、推理)
|
| 85 |
+
├── run_lm2_text.py — 真实文本持续学习(wikitext-2)
|
| 86 |
+
├── run_lm3_bpe.py — BPE + 跨域持续学习(5 种方法)
|
| 87 |
+
├── lm1_inference_demo.py — 生产级推理接口(define+adapt+query)
|
| 88 |
+
└── run_v3*.py — 17 个实验运行脚本(V31-V35 消融套件)
|
| 89 |
+
|
| 90 |
+
docs/wiki/
|
| 91 |
+
├── log.md — 持续学习实验日志
|
| 92 |
+
└── causal_stack.md — 因果技术栈落地文档
|
| 93 |
+
|
| 94 |
+
causal_tech_stack_demo.py — 因果技术栈端到端演示(8 步,秒级)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## 快速开始
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
# 冒烟测试(1 轮、2 步、不触发迁移)
|
| 101 |
+
python smoke_lm1.py
|
| 102 |
+
|
| 103 |
+
# 生产级训练(10 轮,自动探索 + 评估)
|
| 104 |
+
python tests/run_lm1_production.py --rounds 10
|
| 105 |
+
|
| 106 |
+
# 从 checkpoint 恢复
|
| 107 |
+
python tests/run_lm1_production.py --resume
|
| 108 |
+
|
| 109 |
+
# 推理演示
|
| 110 |
+
python tests/lm1_inference_demo.py --ckpt checkpoints/lm1_final.pt
|
| 111 |
+
|
| 112 |
+
# 文本持续学习
|
| 113 |
+
python tests/run_lm2_text.py
|
| 114 |
+
|
| 115 |
+
# BPE 跨域(replay 方法)
|
| 116 |
+
python tests/run_lm3_bpe.py --method replay
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## 实验结果
|
| 120 |
+
|
| 121 |
+
### LM1 — 生产级自主持续学习
|
| 122 |
+
|
| 123 |
+
| 指标 | 数值 | 家族峰值 | 提升 |
|
| 124 |
+
|:--|:--|:--|:--|
|
| 125 |
+
| Unseen 准确率 | 0.1276 | 0.099 | **+29%** |
|
| 126 |
+
| c4 结构准确率 | 0.1510 | 0.099 | **+53%** |
|
| 127 |
+
| 遗忘 | -0.006 | — | **负值(能力提升)** |
|
| 128 |
+
| S5 迁移(R16) | 0.1172 | — | 正向迁移 |
|
| 129 |
+
|
| 130 |
+
- **17 轮 / 680 步** 全自动收敛
|
| 131 |
+
- 模型:`checkpoints/lm1_final.pt`(6.3MB)
|
| 132 |
+
- 报告:`results/lm1_report.md`
|
| 133 |
+
|
| 134 |
+
### LM2 — 真实文本持续学习
|
| 135 |
+
|
| 136 |
+
| 域 | 训练前 | 训练后 | 变化 |
|
| 137 |
+
|:--|:--|:--|:--|
|
| 138 |
+
| A(训练 200 万字符) | 0.429 | 0.472 | **+4.3pp** |
|
| 139 |
+
| B(验证 110 万字符) | 0.478 | 0.488 | **+1.0pp** |
|
| 140 |
+
| C(测试 130 万字符) | 0.493 | — | **正向迁移** |
|
| 141 |
+
|
| 142 |
+
**结论:** 持续学习与零遗忘在同源真实文本(维基)上成立。
|
| 143 |
+
|
| 144 |
+
### LM3 — 跨域持续学习
|
| 145 |
+
|
| 146 |
+
| 方法 | en | zh | code | 平均 |
|
| 147 |
+
|:--|:--|:--|:--|:--|
|
| 148 |
+
| 基线(顺序训练) | 0.260 | 0.076 | 0.340 | 0.170 |
|
| 149 |
+
| Replay | 0.252 | 0.057 | 0.352 | **0.220** |
|
| 150 |
+
| OML(双学习率) | — | — | — | 0.131 |
|
| 151 |
+
| OML2(正确实现) | — | — | — | 0.168 |
|
| 152 |
+
|
| 153 |
+
**结论:** Replay 是唯一有效的防遗忘机制(+5.0pp)。正确 OML(内循环克隆)是适应效率机制,而非防遗忘机制。
|
| 154 |
+
|
| 155 |
+
## 机制结论
|
| 156 |
+
|
| 157 |
+
1. **Replay 是唯一的防遗忘机制** — 数据级锚定(+5.0pp)
|
| 158 |
+
2. **OML 是适应效率机制** — 正确形态 = 内循环克隆 + query 元损失
|
| 159 |
+
3. **简单双学习率 ≠ OML** — 仅使用 head+body 不同 LR 会失败
|
| 160 |
+
4. **朴素顺序训练 = 跨语言灾难性遗忘**
|
| 161 |
+
5. **生产级持续学习 = 更大表示 × 长期训练 × 自主探索数据** — 作为系统整体涌现
|
| 162 |
+
|
| 163 |
+
## 数据需求
|
| 164 |
+
|
| 165 |
+
- **LM1 / LM2**:无需外部数据(合成任务 / wikitext-2 自动下载)
|
| 166 |
+
- **LM3**:将数据放入 `/tmp/lm2data` 和 `/tmp/lm3data`
|
| 167 |
+
|
| 168 |
+
## 引用
|
| 169 |
+
|
| 170 |
+
如使用本工作,请引用上述机制结论及 LM1 生产级系统结果。
|
| 171 |
+
|
| 172 |
+
## 许可证
|
| 173 |
+
|
| 174 |
+
MIT — 详见 [LICENSE](LICENSE)
|
causal_tech_stack_demo.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""headless · Causal AI 技术栈端到端演示
|
| 3 |
+
|
| 4 |
+
把《Causal AI》的因果技术落地到 headless 的 S4 元学习世界,并展示
|
| 5 |
+
"用因果发现改进自主数据提议器"的全链路:
|
| 6 |
+
|
| 7 |
+
STEP 1 把 S4 世界建模为 SCM(生成元 -> 循环结构 -> 泛化表现)
|
| 8 |
+
STEP 2 d-separation:识别因果路径的独立性
|
| 9 |
+
STEP 3 do() 干预:覆盖生成元的因果效应
|
| 10 |
+
STEP 4 因果发现:从观测 acc 反估计"生成元 -> 泛化"的依赖
|
| 11 |
+
STEP 5 识别:后门调整集
|
| 12 |
+
STEP 6 反事实:Pearl 三步(Abduction / Action / Prediction)
|
| 13 |
+
STEP 7 因果提议器 vs 启发式提议器(自主探索效率对比)★
|
| 14 |
+
STEP 8 Causal RL:状态/动作/奖励 + 因果 credit assignment
|
| 15 |
+
|
| 16 |
+
运行:python3 causal_tech_stack_demo.py (纯 numpy,秒级)
|
| 17 |
+
"""
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
sys.path.insert(0, ROOT)
|
| 22 |
+
|
| 23 |
+
from hibs_lnn.causal import ( # noqa: E402
|
| 24 |
+
S4WorldSCM, Graph, d_separated,
|
| 25 |
+
backdoor_adjustment_set, g_formula,
|
| 26 |
+
CausalDiscovery, CausalProposer, CausalRLAgent,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def show(title):
|
| 31 |
+
print("\n" + "=" * 72)
|
| 32 |
+
print(title)
|
| 33 |
+
print("=" * 72)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main():
|
| 37 |
+
s = S4WorldSCM(seed=0)
|
| 38 |
+
|
| 39 |
+
# ---- STEP 1: SCM ----
|
| 40 |
+
show("STEP 1 把 S4 元学习世界建模为结构因果模型 (SCM)")
|
| 41 |
+
print(" 结构方程 (PARENTS):")
|
| 42 |
+
for v, pa in S4WorldSCM.PARENTS.items():
|
| 43 |
+
print(" %-5s <- %s" % (v, ", ".join(pa)))
|
| 44 |
+
print(" 变量:G 生成元集合(因) / cyc 循环类型 / obs 观测特征 / acc 泛化(果)")
|
| 45 |
+
p4 = (1, 2, 3, 0)
|
| 46 |
+
print("\n 例 perm=(1,2,3,0): cyc=%d, 生成元签名=%s"
|
| 47 |
+
% (s.cyc(p4), s.sig_bits(p4)))
|
| 48 |
+
print(" acc(未覆盖生成元)=%.3f, do(覆盖其生成元)=%.3f"
|
| 49 |
+
% (s.acc(p4, set(), noise=0.0), s.do_intervene(p4, set())))
|
| 50 |
+
|
| 51 |
+
# ---- STEP 2: d-separation ----
|
| 52 |
+
show("STEP 2 d-separation —— 因果图中路径的独立性")
|
| 53 |
+
Gc = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
|
| 54 |
+
print(" 图: G->cyc->acc 链 + G->obs、cyc->obs、obs->acc(obs 是另一中间变量)")
|
| 55 |
+
print(" 仅条件 {cyc} 不够:还存在 G->obs->acc 链,需同时阻断 obs")
|
| 56 |
+
print(" G 与 acc 无条件相连接 ->",
|
| 57 |
+
d_separated(Gc, {'G'}, {'acc'}, set()))
|
| 58 |
+
print(" G ┴ acc | {cyc, obs} ->",
|
| 59 |
+
d_separated(Gc, {'G'}, {'acc'}, {'cyc', 'obs'}))
|
| 60 |
+
gc = Graph({'cyc': {'G'}, 'acc': {'cyc'}})
|
| 61 |
+
print(" 最短链 G->cyc->acc |{cyc}->",
|
| 62 |
+
d_separated(gc, {'G'}, {'acc'}, {'cyc'}))
|
| 63 |
+
|
| 64 |
+
# ---- STEP 3: do() 干预 ----
|
| 65 |
+
show("STEP 3 do() 干预 —— 覆盖生成元对泛化的因果效应")
|
| 66 |
+
learned0 = [(0, 1, 2, 3), (1, 0, 2, 3)]
|
| 67 |
+
atoms0 = set()
|
| 68 |
+
for p in learned0:
|
| 69 |
+
atoms0 |= {i for i in range(6) if s.sig_bits(p)[i]}
|
| 70 |
+
print(" 已学 perm 覆盖生成元原子: %s" % sorted(atoms0))
|
| 71 |
+
eff = {}
|
| 72 |
+
for p in s.factor:
|
| 73 |
+
y_no = s.acc(p, set(), noise=0.0)
|
| 74 |
+
y_do = s.do_intervene(p, atoms0)
|
| 75 |
+
eff[p] = y_do - y_no
|
| 76 |
+
cand = sorted(eff, key=eff.get, reverse=True)[:3]
|
| 77 |
+
print(" 干预(do)效应最大的 3 个候选 perm:")
|
| 78 |
+
for p in cand:
|
| 79 |
+
print(" %s +%.3f" % (p, round(eff[p], 3)))
|
| 80 |
+
# ---- STEP 4: 因果发现 ----
|
| 81 |
+
show("STEP 4 因果发现 —— 从观测因子反估计 (生成元->泛化)")
|
| 82 |
+
learned = [(0, 1, 2, 3), (1, 0, 2, 3), (0, 1, 3, 2),
|
| 83 |
+
(0, 2, 1, 3), (0, 3, 2, 1)]
|
| 84 |
+
disc = CausalDiscovery(s, alpha=0.5)
|
| 85 |
+
# 因果发现:在"历史观测"上反推哪些因子决定泛化
|
| 86 |
+
# 为让离散统计稳定,对每个 perm 累积 8 次带噪观测
|
| 87 |
+
import numpy as np
|
| 88 |
+
all_perms = list(s.factor.keys())
|
| 89 |
+
rows = []
|
| 90 |
+
k = 0
|
| 91 |
+
for p in all_perms:
|
| 92 |
+
for _ in range(8):
|
| 93 |
+
f = s.observe(p, rng=np.random.default_rng(1000 + k))
|
| 94 |
+
rows.append([f[0], f[1], f[2], s.acc(p, set(), seed=2000 + k)])
|
| 95 |
+
k += 1
|
| 96 |
+
Xs = np.array(rows)
|
| 97 |
+
from hibs_lnn.causal.discovery import ci_test
|
| 98 |
+
p_cyc, mi_cyc = ci_test(Xs, 2, 3, []) # cyc 与 acc
|
| 99 |
+
p_gen, mi_gen = ci_test(Xs, 0, 3, []) # gen_count 与 acc
|
| 100 |
+
print(" 历史观测样本数:", len(Xs))
|
| 101 |
+
print(" 条件独立检验 (p 越小越依赖):")
|
| 102 |
+
print(" cyc(结构) -> acc : p=%.3f mi=%.3f -> %s"
|
| 103 |
+
% (p_cyc, mi_cyc, "相关(depend)" if p_cyc < 0.3 else "独立"))
|
| 104 |
+
print(" gen_count -> acc : p=%.3f mi=%.3f -> %s"
|
| 105 |
+
% (p_gen, mi_gen, "相关(depend)" if p_gen < 0.3 else "独立"))
|
| 106 |
+
print(' 解读:未覆盖任何生成元时,泛化仅由循环结构(cyc)决定;')
|
| 107 |
+
print(' 这就是数据对【先学结构、再补生成元】的因果提示。')
|
| 108 |
+
|
| 109 |
+
# ---- STEP 5: 识别 ----
|
| 110 |
+
show("STEP 5 识别 —— 后门调整")
|
| 111 |
+
Gg = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
|
| 112 |
+
Z = backdoor_adjustment_set(Gg, 'G', 'acc', {'G', 'cyc', 'obs'})
|
| 113 |
+
fident, msg = g_formula(Gg, 'G', 'acc', Z)
|
| 114 |
+
print(" 后门调整集 Z = %r" % (Z,))
|
| 115 |
+
print(" 可识别性: %s" % msg)
|
| 116 |
+
|
| 117 |
+
# ---- STEP 6: 反事实 ----
|
| 118 |
+
show("STEP 6 反事实 —— Pearl 三步 (Abduction/Action/Prediction)")
|
| 119 |
+
obs_p = (1, 2, 3, 0)
|
| 120 |
+
y_obs = s.acc(obs_p, atoms0, noise=0.05)
|
| 121 |
+
y_cf, meta = s.counterfactual(obs_p, y_obs, atoms0, do_perm=(2, 0, 3, 1))
|
| 122 |
+
print(" 观测: perm=%s acc=%.3f" % (obs_p, y_obs))
|
| 123 |
+
print(" 反事实(若早期探索另一 perm、覆盖其生成元): acc=%.3f" % y_cf)
|
| 124 |
+
print(" 归因: 推断外生扰动 u=%s, 生成元覆盖 %d -> %d"
|
| 125 |
+
% (meta['u_acc'], meta['n_known_before'], meta['n_known_after']))
|
| 126 |
+
|
| 127 |
+
# ---- STEP 7: ★ 因果提议器 vs 启发式提议器 ----
|
| 128 |
+
show("STEP 7 ★ 因果提议器 vs 启发式提议器(自主探索效率对比)")
|
| 129 |
+
scores = run_proposer_compare(s)
|
| 130 |
+
print(" 在同样的已学集合上各提议 6 个 unseen 排列:")
|
| 131 |
+
print(" %-22s %-16s %-14s"
|
| 132 |
+
% ("提议器", "新生成元原子/步", "覆盖未见结构占比"))
|
| 133 |
+
for name, new_atoms, frac in scores:
|
| 134 |
+
print(" %-22s %-16s %-14s"
|
| 135 |
+
% (name, "%.2f" % new_atoms, "%.2f" % frac))
|
| 136 |
+
print("\n 因果提议器优先选择【被生成元瓶颈卡住】的任务(do-效应/反事实增益高),")
|
| 137 |
+
print(" 用更少探索步数覆盖更多【原因原子】——自主数据生成器的因果改进。")
|
| 138 |
+
|
| 139 |
+
# ---- STEP 8: Causal RL ----
|
| 140 |
+
show("STEP 8 Causal RL —— 状态/动作/奖励 + 因果 credit assignment")
|
| 141 |
+
agent = CausalRLAgent(learned[:3], k=1, seed=3)
|
| 142 |
+
res = agent.run(4, verbose=True)
|
| 143 |
+
print(" 归因到的原子级因果价值 Q:", res['q_atom'])
|
| 144 |
+
print(" 已覆盖生成元原子数: %d / 6" % res['covered_atoms'])
|
| 145 |
+
|
| 146 |
+
print("\n 完成:因果技术栈已在 headless 的 S4 世界完整落地。")
|
| 147 |
+
print(" 详见 docs/wiki/causal_stack.md")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def baseline_value(world, learned, p, freq):
|
| 151 |
+
"""启发式提议器价值(简约/自洽/覆盖的轻量复刻)。"""
|
| 152 |
+
sim = 1.0
|
| 153 |
+
for q in learned:
|
| 154 |
+
if world.cyc(p) == world.cyc(q):
|
| 155 |
+
sim -= 0.3
|
| 156 |
+
learned_cyc = {world.cyc(q) for q in learned}
|
| 157 |
+
con = 0.5 if world.cyc(p) in learned_cyc else 1.0
|
| 158 |
+
cov = 1.0 / (1 + freq.get(p, 0))
|
| 159 |
+
return sim + con + cov
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def run_proposer_compare(world, n_steps=6, n_learned=6):
|
| 163 |
+
import numpy as np
|
| 164 |
+
perms = list(world.factor.keys())
|
| 165 |
+
learned = perms[:n_learned] # 同一初始已学集合
|
| 166 |
+
cands = [p for p in perms if p not in learned]
|
| 167 |
+
|
| 168 |
+
# --- 启发式 ---
|
| 169 |
+
freq_b = {}
|
| 170 |
+
picked_b = []
|
| 171 |
+
for _ in range(n_steps):
|
| 172 |
+
sc = [baseline_value(world, learned, p, freq_b) for p in cands]
|
| 173 |
+
for _ in range(4):
|
| 174 |
+
bi = int(np.argmax(sc))
|
| 175 |
+
p = cands[bi]
|
| 176 |
+
if p in picked_b:
|
| 177 |
+
sc[bi] = -1e9
|
| 178 |
+
continue
|
| 179 |
+
break
|
| 180 |
+
picked_b.append(p)
|
| 181 |
+
freq_b[p] = freq_b.get(p, 0) + 1
|
| 182 |
+
|
| 183 |
+
# --- 因果 ---
|
| 184 |
+
cp = CausalProposer(learned, k=n_steps, seed=7)
|
| 185 |
+
picked_c = cp.propose(n=n_steps)
|
| 186 |
+
|
| 187 |
+
def _new_atoms(picked):
|
| 188 |
+
at = set()
|
| 189 |
+
for p in learned:
|
| 190 |
+
at |= {i for i in range(6) if world.sig_bits(p)[i]}
|
| 191 |
+
tot = 0
|
| 192 |
+
for p in picked:
|
| 193 |
+
n0 = len(at)
|
| 194 |
+
at |= {i for i in range(6) if world.sig_bits(p)[i]}
|
| 195 |
+
tot += len(at) - n0
|
| 196 |
+
return tot / max(1, len(picked))
|
| 197 |
+
|
| 198 |
+
seen_cyc = {world.cyc(p) for p in learned}
|
| 199 |
+
frac_b = sum(1 for p in picked_b if world.cyc(p) not in seen_cyc) / n_steps
|
| 200 |
+
frac_c = sum(1 for p in picked_c if world.cyc(p) not in seen_cyc) / n_steps
|
| 201 |
+
return [
|
| 202 |
+
("启发式 (sim/con/cov)", _new_atoms(picked_b), frac_b),
|
| 203 |
+
("因果 (do/反事实/发现)", _new_atoms(picked_c), frac_c),
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
main()
|
checkpoints/lm1_8m_round9.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:36033592822e07084850b162b3e9d11d485824be247719eba6a314d12ed72b80
|
| 3 |
+
size 45745883
|
checkpoints/lm1_latest.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ae892613c8f0a7891e1a4a86b194c0ced7d5107705c1f013c605f85bad4de51f
|
| 3 |
+
size 545378
|
checkpoints/lm1_round1.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8c0dd80eb976b39340f8d1225219b73b8171dda3e78e9ed154b96dff9c4c7ddd
|
| 3 |
+
size 545186
|
checkpoints/lm1_round2.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9702ec3f89a0488d82eb0501875c169e52d091a1ba4d106108978d641a289709
|
| 3 |
+
size 545378
|
checkpoints/lm1_round3.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bac8fd755ad5891c60f92ecf7f0df82035da0d5f26b8aa2cb07eb2748c829788
|
| 3 |
+
size 1218018
|
checkpoints/lm1_round4.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:794e4ad0da13c95b6ee14405f1234315b7a34d1168beabe1ca07ba220ea08f20
|
| 3 |
+
size 1218210
|
checkpoints/lm1_smoke.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d0d4df9ba79316337c76cbe3d6c74fd803420a84b6f4a5abb917ad03c723a6a5
|
| 3 |
+
size 1217076
|
checkpoints/lm2_latest.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9999a202b50ce8702c81a3f008ef9ae106f1e1d6fa84625f9a82fd54459d605b
|
| 3 |
+
size 725090
|
checkpoints/lm3_base_sequential_latest.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f21dee92c10c0dab001bb3b036524cf6ea349d8a6dd1dfe56afcea0bd79150ba
|
| 3 |
+
size 1032402
|
checkpoints/lm3_replay_sequential_latest.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a270bfd994be01d8c96a5a88c8b4fef0f3d78aec16d6a6968aa71eeee6d840db
|
| 3 |
+
size 1032472
|
hibs_lnn/causal/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""headless · Causal AI 技术栈
|
| 2 |
+
|
| 3 |
+
把《Causal AI》一书的因果技术落地到 headless 的 S4 元学习世界:
|
| 4 |
+
|
| 5 |
+
dag.py DAG 数据结构 + d-separation(第 3-4 章)
|
| 6 |
+
scm.py 结构因果模型 SCM + do() 干预 + 反事实(第 6-9 章)
|
| 7 |
+
identification.py 后门调整 / 因果效应识别(第 10-11 章)
|
| 8 |
+
discovery.py 因果发现(条件独立检验 + PC 式骨架,第 4/发现章)
|
| 9 |
+
causal_proposer.py 因果驱动的自主数据提议器(改进 S4ValueProposer)
|
| 10 |
+
causal_rl.py Causal RL:状态/动作/奖励 + 因果 credit assignment(第 12 章)
|
| 11 |
+
|
| 12 |
+
实现原则:S4 世界是有限离散代数结构,核心算法用 numpy 自实现(无重型外部依赖),
|
| 13 |
+
对应书上 pgmpy / DoWhy / causal-learn 的轻量可复现等价物。
|
| 14 |
+
"""
|
| 15 |
+
from .dag import Graph, d_separated, is_dag, ancestors, descendants
|
| 16 |
+
from .scm import SCM, S4WorldSCM
|
| 17 |
+
from .identification import (
|
| 18 |
+
backdoor_adjustment_set, causal_effect, g_formula)
|
| 19 |
+
from .discovery import (
|
| 20 |
+
ci_test, pc_skeleton, CausalDiscovery)
|
| 21 |
+
from .causal_proposer import CausalProposer
|
| 22 |
+
from .causal_rl import CausalRLAgent
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"Graph", "d_separated", "is_dag", "ancestors", "descendants",
|
| 26 |
+
"SCM", "S4WorldSCM",
|
| 27 |
+
"backdoor_adjustment_set", "causal_effect", "g_formula",
|
| 28 |
+
"ci_test", "pc_skeleton", "CausalDiscovery",
|
| 29 |
+
"CausalProposer", "CausalRLAgent",
|
| 30 |
+
]
|
hibs_lnn/causal/causal_proposer.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""causal_proposer.py — 因果驱动的自主数据提议器(改进 S4ValueProposer)
|
| 2 |
+
|
| 3 |
+
原价值函数(S4ValueProposer)用启发式三分:简约 / 自洽 / 覆盖。
|
| 4 |
+
本模块用因果技术栈重写"提议价值":
|
| 5 |
+
|
| 6 |
+
value(p) = β1 · 生成元因果新颖性(引入未覆盖的"原因"原子)
|
| 7 |
+
+ β2 · 干预效应增量 (do(覆盖 p 的生成元) 对泛化的因果提升)
|
| 8 |
+
+ β3 · backdoor 因果效应(从观测数据发现的生成元→unseen 的 ATE)
|
| 9 |
+
+ β4 · 反事实价值 (若早先探索 p,未来 unseen 的预期增益)
|
| 10 |
+
− λ · 覆盖惩罚(freq)
|
| 11 |
+
|
| 12 |
+
接口与 S4ValueProposer 兼容:__init__(learned_perms, k=...) · propose(n=None)。
|
| 13 |
+
"""
|
| 14 |
+
import numpy as np
|
| 15 |
+
from .scm import S4WorldSCM, _PERMS24
|
| 16 |
+
from .discovery import CausalDiscovery, ci_test
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CausalProposer:
|
| 20 |
+
def __init__(self, learned_perms, k=2, seed=0, alpha=0.3,
|
| 21 |
+
beta=(1.0, 1.0, 1.0, 0.4), beta_f=0.6, tau=1.0, world=None):
|
| 22 |
+
self.learned = list(learned_perms)
|
| 23 |
+
self.k = k
|
| 24 |
+
self.tau = tau
|
| 25 |
+
self.beta = beta
|
| 26 |
+
self.beta_f = beta_f
|
| 27 |
+
self.seed = seed
|
| 28 |
+
self.world = world or S4WorldSCM(seed=seed)
|
| 29 |
+
self.discovery = CausalDiscovery(self.world, alpha=alpha, seed=seed)
|
| 30 |
+
self.freq = {}
|
| 31 |
+
self.learned_atoms = set()
|
| 32 |
+
for p in self.learned:
|
| 33 |
+
self.learned_atoms |= {i for i in range(6)
|
| 34 |
+
if self.world.sig_bits(p)[i]}
|
| 35 |
+
self.cands = [p for p in _PERMS24 if p not in self.learned]
|
| 36 |
+
# 真实反馈:观测到的 (perm -> 实际泛化 acc),供因果持续学习
|
| 37 |
+
self.real_obs = {}
|
| 38 |
+
self._feedback_trace = []
|
| 39 |
+
|
| 40 |
+
# ---- 组件 1:生成元因果新颖性 ----
|
| 41 |
+
def causal_novelty(self, p):
|
| 42 |
+
new_atoms = {i for i in range(6) if self.world.sig_bits(p)[i]
|
| 43 |
+
and i not in self.learned_atoms}
|
| 44 |
+
return len(new_atoms) / 6.0
|
| 45 |
+
|
| 46 |
+
# ---- 组件 2:干预效应增量 do() ----
|
| 47 |
+
def do_effect(self, p):
|
| 48 |
+
y0 = self.world.acc(p, self.learned_atoms, noise=0.0)
|
| 49 |
+
y1 = self.world.do_intervene(p, self.learned_atoms)
|
| 50 |
+
return max(0.0, y1 - y0)
|
| 51 |
+
|
| 52 |
+
# ---- 组件 3:backdoor 因果效应(从观测数据反估计生成元→unseen)----
|
| 53 |
+
def backdoor_causal(self):
|
| 54 |
+
"""用观测因子做条件独立检验,估计"覆盖某原子"对 acc 的贡献强度。"""
|
| 55 |
+
perms = self.learned[:8] # 用已学样本做因果归因
|
| 56 |
+
if len(perms) < 2:
|
| 57 |
+
return 0.0
|
| 58 |
+
rows = []
|
| 59 |
+
for i, p in enumerate(perms):
|
| 60 |
+
f = self.world.observe(p, rng=np.random.default_rng(i))
|
| 61 |
+
rows.append([f[0], f[1], f[2], self.world.acc(p, self.learned_atoms)])
|
| 62 |
+
X = np.array(rows)
|
| 63 |
+
# 检验 gen_count(原子覆盖数) 是否与 acc 依赖
|
| 64 |
+
pval, mi = ci_test(X, 0, 3, [], rng=self.discovery.rng)
|
| 65 |
+
return float(mi) if pval < 0.5 else 0.0
|
| 66 |
+
|
| 67 |
+
# ---- 组件 4:反事实价值(FNP:若 p 被提前覆盖,未来 unseen 预期增益)----
|
| 68 |
+
def counterfactual_value(self, p):
|
| 69 |
+
# 观测一个"较中位"未学 perm 的当前 acc,问若 p 的生成元早被覆盖会怎样
|
| 70 |
+
if not self.cands:
|
| 71 |
+
return 0.0
|
| 72 |
+
probe = self.cands[len(self.cands) // 2]
|
| 73 |
+
y_obs = self.world.acc(probe, self.learned_atoms, noise=0.0)
|
| 74 |
+
y_cf, _ = self.world.counterfactual(probe, y_obs, self.learned_atoms,
|
| 75 |
+
do_perm=p)
|
| 76 |
+
return max(0.0, y_cf - y_obs)
|
| 77 |
+
|
| 78 |
+
# ---- 真实反馈:因果持续学习(用 LM1 实际评估 acc 驱动)----
|
| 79 |
+
def observe(self, perm, acc):
|
| 80 |
+
"""记录一个 perm 的实际泛化表现,作为因果模型的真实世界观测。"""
|
| 81 |
+
self.real_obs[perm] = float(acc)
|
| 82 |
+
self._feedback_trace.append((perm, float(acc)))
|
| 83 |
+
|
| 84 |
+
def feedback_value(self, p):
|
| 85 |
+
"""基于真实反馈的因果价值。
|
| 86 |
+
|
| 87 |
+
若 p 覆盖的生成元原子,在"已观测但表现低"的 unseen 中出现的越多,
|
| 88 |
+
则覆盖 p 越能补齐短板(因果缺口大)→ 高价值。这反映模型真实盲区。
|
| 89 |
+
"""
|
| 90 |
+
if not self.real_obs:
|
| 91 |
+
return 0.0
|
| 92 |
+
atoms_p = {i for i in range(6) if self.world.sig_bits(p)[i]}
|
| 93 |
+
gap = 0.0; cnt = 0
|
| 94 |
+
for q, acc in self.real_obs.items():
|
| 95 |
+
shared = atoms_p & {i for i in range(6)
|
| 96 |
+
if self.world.sig_bits(q)[i]}
|
| 97 |
+
if shared:
|
| 98 |
+
# acc 越低 = 缺口越大;共享原子越多 = 覆盖 p 越对症
|
| 99 |
+
gap += (1.0 - acc) * (len(shared) / max(1.0, len(atoms_p)))
|
| 100 |
+
cnt += 1
|
| 101 |
+
return gap / max(1, cnt)
|
| 102 |
+
|
| 103 |
+
def value(self, p):
|
| 104 |
+
b1, b2, b3, b4 = self.beta
|
| 105 |
+
v = (b1 * self.causal_novelty(p)
|
| 106 |
+
+ b2 * self.do_effect(p)
|
| 107 |
+
+ b3 * self.backdoor_causal()
|
| 108 |
+
+ b4 * self.counterfactual_value(p))
|
| 109 |
+
v += self.beta_f * self.feedback_value(p) # 真实反馈项(因果持续学习)
|
| 110 |
+
v /= (1 + self.freq.get(p, 0)) # 覆盖惩罚
|
| 111 |
+
return v
|
| 112 |
+
|
| 113 |
+
def _explain(self, p):
|
| 114 |
+
if not hasattr(self, '_exp'):
|
| 115 |
+
self._exp = {}
|
| 116 |
+
self._exp[p] = {
|
| 117 |
+
'novelty': round(self.causal_novelty(p), 3),
|
| 118 |
+
'do_effect': round(self.do_effect(p), 3),
|
| 119 |
+
'backdoor': round(self.backdoor_causal(), 3),
|
| 120 |
+
'cf_value': round(self.counterfactual_value(p), 3),
|
| 121 |
+
}
|
| 122 |
+
return self._exp[p]
|
| 123 |
+
|
| 124 |
+
def propose(self, n=None):
|
| 125 |
+
"""提议 n 个新结构(softmax 采样),返回 perm 列表(兼容原接口)。"""
|
| 126 |
+
n = n or self.k
|
| 127 |
+
if not self.cands:
|
| 128 |
+
return []
|
| 129 |
+
scores = np.array([self.value(p) for p in self.cands])
|
| 130 |
+
probs = np.exp((scores - scores.max()) / max(self.tau, 1e-8))
|
| 131 |
+
probs = probs / probs.sum()
|
| 132 |
+
idxs = np.random.default_rng(self.seed).choice(
|
| 133 |
+
len(self.cands), size=min(n, len(self.cands)), replace=False,
|
| 134 |
+
p=probs)
|
| 135 |
+
out = []
|
| 136 |
+
for i in idxs:
|
| 137 |
+
p = self.cands[i]
|
| 138 |
+
self.freq[p] = self.freq.get(p, 0) + 1
|
| 139 |
+
self._explain(p)
|
| 140 |
+
out.append(p)
|
| 141 |
+
return out
|
hibs_lnn/causal/causal_rl.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""causal_rl.py — Causal RL:状态/动作/奖励 + 因果 credit assignment(第 12 章)
|
| 2 |
+
|
| 3 |
+
把"自主数据提议"建模为一个强化学习过程:
|
| 4 |
+
State : 已覆盖生成元集合 + 各结构区已有表现
|
| 5 |
+
Action : 提议探索某个 unseen 排列 perm(do(perm))
|
| 6 |
+
Reward : 实际泛化表现 acc 提升(因果可分配的增益)
|
| 7 |
+
Credit : 不用朴素相关,而是用因果归因(do_intervene / counterfactual)
|
| 8 |
+
把 reward 切分到每个贡献原子,解决"相关性≠因果"的 credit 混淆。
|
| 9 |
+
"""
|
| 10 |
+
import numpy as np
|
| 11 |
+
from .scm import S4WorldSCM, _PERMS24
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CausalRLAgent:
|
| 15 |
+
def __init__(self, learned_perms, k=1, seed=0, lr=0.3,
|
| 16 |
+
world=None, beta=1.0):
|
| 17 |
+
self.world = world or S4WorldSCM(seed=seed)
|
| 18 |
+
self.k = k
|
| 19 |
+
self.seed = seed
|
| 20 |
+
self.lr = lr
|
| 21 |
+
self.beta = beta
|
| 22 |
+
self.rng = np.random.default_rng(seed)
|
| 23 |
+
self.learned_atoms = set()
|
| 24 |
+
self.learned = list(learned_perms)
|
| 25 |
+
for p in self.learned:
|
| 26 |
+
self.learned_atoms |= {i for i in range(6)
|
| 27 |
+
if self.world.sig_bits(p)[i]}
|
| 28 |
+
self.cands = [p for p in _PERMS24 if p not in self.learned]
|
| 29 |
+
# 原子级因果价值 Q(每探索一步,用归因更新)
|
| 30 |
+
self.q_atom = np.zeros(6)
|
| 31 |
+
# 动作级 Q(对每个候选 perm)
|
| 32 |
+
self.q = {p: 0.0 for p in self.cands}
|
| 33 |
+
|
| 34 |
+
# ---- 因果归因:把 acc 增益切分到原子 ----
|
| 35 |
+
def _causal_credit(self, perm, delta_acc):
|
| 36 |
+
"""把 delta_acc 通过结构方程归因到各生成元原子。
|
| 37 |
+
|
| 38 |
+
用 do 前后对比(干预归因):覆盖 perm 的原子能带来多大增益,
|
| 39 |
+
再按原子"已知/新增"拆解。
|
| 40 |
+
"""
|
| 41 |
+
sig = self.world.sig_bits(perm)
|
| 42 |
+
y0 = self.world.acc(perm, self.learned_atoms, noise=0.0)
|
| 43 |
+
y1 = self.world.do_intervene(perm, self.learned_atoms) # 干预
|
| 44 |
+
gain = max(0.0, y1 - y0)
|
| 45 |
+
# 归因:只有"新原子"才贡献因果(已学原子不增加覆盖)
|
| 46 |
+
new = [i for i in range(6) if sig[i] and i not in self.learned_atoms]
|
| 47 |
+
if not new:
|
| 48 |
+
return {}
|
| 49 |
+
# 按干预后的边际均分(简化但可复现的合法归因)
|
| 50 |
+
per = gain / len(new)
|
| 51 |
+
return {i: per for i in new}
|
| 52 |
+
|
| 53 |
+
# ---- 一个交互回合 ----
|
| 54 |
+
def step(self, verbose=False):
|
| 55 |
+
"""选一个动作(提议 perm),得到真实 acc,做因果归因更新 Q。"""
|
| 56 |
+
if not self.cands:
|
| 57 |
+
return None
|
| 58 |
+
# 动作选择:exploration(依 Q+新颖性) / 简单 softmax 采样
|
| 59 |
+
scores = np.array([
|
| 60 |
+
self.q[p] + 0.35 * self._novelty(p) for p in self.cands])
|
| 61 |
+
probs = np.exp(scores - scores.max()) / (np.exp(scores - scores.max()).sum()
|
| 62 |
+
+ 1e-12)
|
| 63 |
+
i = self.rng.choice(len(self.cands), p=probs)
|
| 64 |
+
perm = self.cands[i]
|
| 65 |
+
self.cands.pop(i)
|
| 66 |
+
|
| 67 |
+
# 真实 reward:实际泛化(探索该 perm 之后,其生成元被覆盖)
|
| 68 |
+
before = self._mean_acc()
|
| 69 |
+
new_atoms = self.learned_atoms | {j for j in range(6)
|
| 70 |
+
if self.world.sig_bits(perm)[j]}
|
| 71 |
+
after = np.mean([self.world.acc(q, new_atoms, noise=0.0)
|
| 72 |
+
for q in self.cands + [perm]])
|
| 73 |
+
r = max(0.0, after - before)
|
| 74 |
+
|
| 75 |
+
# 因果 credit assignment(非线性相关)
|
| 76 |
+
credit = self._causal_credit(perm, r)
|
| 77 |
+
for atom, c in credit.items():
|
| 78 |
+
self.q_atom[atom] += self.lr * (c - self.q_atom[atom])
|
| 79 |
+
self.q[perm] = self.lr * (r - self.q[perm])
|
| 80 |
+
|
| 81 |
+
# 提交:把新原子并入已学
|
| 82 |
+
self.learned_atoms |= {j for j in range(6)
|
| 83 |
+
if self.world.sig_bits(perm)[j]}
|
| 84 |
+
self.learned.append(perm)
|
| 85 |
+
self.beta = self.beta
|
| 86 |
+
if verbose:
|
| 87 |
+
print(" [causalRL] act=%s r=%.3f credit_atoms=%s"
|
| 88 |
+
% (perm, round(r, 3),
|
| 89 |
+
{a: round(v, 3) for a, v in credit.items()}))
|
| 90 |
+
return {'perm': perm, 'reward': r, 'credit': credit}
|
| 91 |
+
|
| 92 |
+
def _novelty(self, p):
|
| 93 |
+
new = {i for i in range(6) if self.world.sig_bits(p)[i]
|
| 94 |
+
and i not in self.learned_atoms}
|
| 95 |
+
return len(new) / 6.0
|
| 96 |
+
|
| 97 |
+
def _mean_acc(self):
|
| 98 |
+
pool = self.cands + self.learned
|
| 99 |
+
if not pool:
|
| 100 |
+
return 0.0
|
| 101 |
+
return float(np.mean([self.world.acc(p, self.learned_atoms, noise=0.0)
|
| 102 |
+
for p in pool]))
|
| 103 |
+
|
| 104 |
+
def run(self, n_steps, verbose=False):
|
| 105 |
+
for _ in range(n_steps):
|
| 106 |
+
self.step(verbose=verbose)
|
| 107 |
+
return {'q_atom': self.q_atom.round(3).tolist(),
|
| 108 |
+
'covered_atoms': int(len(self.learned_atoms)),
|
| 109 |
+
'n_perms': len(self.learned)}
|
hibs_lnn/causal/dag.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""dag.py — DAG 数据结构 + d-separation(《Causal AI》第 3-4 章)
|
| 2 |
+
|
| 3 |
+
headless 的 S4 元学习世界本质是离散代数结构,因果图很小,
|
| 4 |
+
因此用"路径枚举 + 阻塞判断"实现 d-separation——对小图可证明正确,
|
| 5 |
+
避免大而全的图库带来的正确性/依赖负担。
|
| 6 |
+
|
| 7 |
+
d-separation 定义(Pearl, 2009):
|
| 8 |
+
路径 p = x … y 被 Z 阻塞当且仅当 p 存在某内部节点 v 使得
|
| 9 |
+
① 链 v₁→v→v₂ 或叉 v₁←v→v₂ 且 v ∈ Z;或
|
| 10 |
+
② 对撞 v₁→v←v₂ 且 v ∉ Z 且 v 无后代 ∈ Z。
|
| 11 |
+
X ⟂ Y | Z 当且仅当所有从 X 到 Y 的路径都被 Z 阻塞。
|
| 12 |
+
"""
|
| 13 |
+
from collections import defaultdict
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Graph:
|
| 17 |
+
"""简单有向图。parents[n] = set(n 的父节点),即边 parent -> n。"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, parents=None):
|
| 20 |
+
self.parents = defaultdict(set)
|
| 21 |
+
self.nodes = set()
|
| 22 |
+
if parents:
|
| 23 |
+
for c, ps in parents.items():
|
| 24 |
+
self.add_node(c)
|
| 25 |
+
for p in ps:
|
| 26 |
+
self.add_edge(p, c)
|
| 27 |
+
|
| 28 |
+
def add_node(self, n):
|
| 29 |
+
self.nodes.add(n)
|
| 30 |
+
self.parents.setdefault(n, set())
|
| 31 |
+
|
| 32 |
+
def add_edge(self, u, v):
|
| 33 |
+
"""有向边 u -> v。"""
|
| 34 |
+
self.add_node(u); self.add_node(v)
|
| 35 |
+
self.parents[v].add(u)
|
| 36 |
+
|
| 37 |
+
def children(self, n):
|
| 38 |
+
return {c for c, ps in self.parents.items() if n in ps}
|
| 39 |
+
|
| 40 |
+
def neighbors_undirected(self, n):
|
| 41 |
+
out = set()
|
| 42 |
+
out |= self.parents.get(n, set()) # 父
|
| 43 |
+
out |= self.children(n) # 子
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def is_dag(g: Graph) -> bool:
|
| 48 |
+
"""有向图是否无环(拓扑排序)。"""
|
| 49 |
+
indeg = {n: len(g.parents[n]) for n in g.nodes}
|
| 50 |
+
q = [n for n in g.nodes if indeg[n] == 0]
|
| 51 |
+
seen = 0
|
| 52 |
+
while q:
|
| 53 |
+
n = q.pop()
|
| 54 |
+
seen += 1
|
| 55 |
+
for c in g.children(n):
|
| 56 |
+
indeg[c] -= 1
|
| 57 |
+
if indeg[c] == 0:
|
| 58 |
+
q.append(c)
|
| 59 |
+
return seen == len(g.nodes)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def ancestors(g: Graph, Z) -> set:
|
| 63 |
+
"""Z 及其所有祖先(含 Z 自身)。"""
|
| 64 |
+
Z = set(Z)
|
| 65 |
+
out = set(); stack = list(Z)
|
| 66 |
+
while stack:
|
| 67 |
+
n = stack.pop()
|
| 68 |
+
if n in out:
|
| 69 |
+
continue
|
| 70 |
+
out.add(n)
|
| 71 |
+
stack.extend(g.parents.get(n, ()))
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def descendants(g: Graph, n) -> set:
|
| 76 |
+
"""n 的后代(不含自身)。"""
|
| 77 |
+
out = set(); stack = list(g.children(n))
|
| 78 |
+
while stack:
|
| 79 |
+
x = stack.pop()
|
| 80 |
+
if x in out:
|
| 81 |
+
continue
|
| 82 |
+
out.add(x)
|
| 83 |
+
stack.extend(g.children(x))
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def descendants_in(g: Graph, n, Z) -> bool:
|
| 88 |
+
"""n 是否有后代在 Z 中(含自身当 n∈Z)。"""
|
| 89 |
+
return bool(descendants(g, n) & set(Z))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def d_separated(g: Graph, X, Y, Z, _max_depth=60) -> bool:
|
| 93 |
+
"""X ⟂ Y | Z ? X, Y, Z 为节点集合。"""
|
| 94 |
+
X = set(X); Y = set(Y); Z = set(Z)
|
| 95 |
+
if not X or not Y:
|
| 96 |
+
return True
|
| 97 |
+
if X & Y:
|
| 98 |
+
return False # 同一节点不可能与自身独立(除非条件化自身)
|
| 99 |
+
if X & Z or Y & Z:
|
| 100 |
+
# 被条件化的节点不能再作为连接端点,直接视为阻塞
|
| 101 |
+
rhs = Y - Z
|
| 102 |
+
lhs = X - Z
|
| 103 |
+
if not lhs or not rhs:
|
| 104 |
+
return True
|
| 105 |
+
# 退化为对剩余端点的判断
|
| 106 |
+
X = lhs; Y = rhs
|
| 107 |
+
if X & Y:
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
An_Z = ancestors(g, Z)
|
| 111 |
+
undir = {n: g.neighbors_undirected(n) for n in g.nodes}
|
| 112 |
+
|
| 113 |
+
def edge_into(a, b):
|
| 114 |
+
"""是否存在有向边 a -> b。"""
|
| 115 |
+
return b in g.parents and a in g.parents[b]
|
| 116 |
+
|
| 117 |
+
def _collider(prev, cur, nxt):
|
| 118 |
+
"""cur 是否为"对撞点"(两条边都指向 cur)。"""
|
| 119 |
+
return edge_into(prev, cur) and edge_into(nxt, cur)
|
| 120 |
+
|
| 121 |
+
def _blocked(prev, cur, nxt):
|
| 122 |
+
"""cur 作为内部节点是否阻塞路径 prev-cur-nxt。"""
|
| 123 |
+
if _collider(prev, cur, nxt):
|
| 124 |
+
# 对撞:cur 或其后代 ∈ Z 则激活,否则阻塞
|
| 125 |
+
return not (cur in Z or descendants_in(g, cur, Z))
|
| 126 |
+
# 链 / 叉:cur ∈ Z 则阻塞
|
| 127 |
+
return cur in Z
|
| 128 |
+
|
| 129 |
+
# X 中任一节点能"到达"任一 Y 且全程未被阻塞 → 不独立
|
| 130 |
+
def dfs2(node):
|
| 131 |
+
"""枚举简单路径;阻塞判断在离开内部节点时作用于该节点。"""
|
| 132 |
+
if node in Y:
|
| 133 |
+
return True
|
| 134 |
+
for nb in undir[node]:
|
| 135 |
+
if nb in path_set:
|
| 136 |
+
continue
|
| 137 |
+
if len(path) >= 2:
|
| 138 |
+
prev = path[-2]
|
| 139 |
+
if _blocked(prev, node, nb):
|
| 140 |
+
continue # 此路径在 node 处被阻塞
|
| 141 |
+
path.append(nb); path_set.add(nb)
|
| 142 |
+
if dfs2(nb):
|
| 143 |
+
path.pop(); path_set.discard(nb)
|
| 144 |
+
return True
|
| 145 |
+
path.pop(); path_set.discard(nb)
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
for x in X:
|
| 149 |
+
path = [x]; path_set = {x}
|
| 150 |
+
# 起点是端点,不判阻塞
|
| 151 |
+
if dfs2(x):
|
| 152 |
+
return False # 存在未阻塞路径 → 不独立
|
| 153 |
+
return True # 全部阻塞 → 独立
|
hibs_lnn/causal/discovery.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""discovery.py — 因果发现(条件独立检验 + PC 式骨架)(第 4 章 / 因果发现章)
|
| 2 |
+
|
| 3 |
+
从观测数据(各任务的因子 + 真实 acc)出发,推断结构变量之间的依赖,
|
| 4 |
+
从而"发现"哪些生成元/结构因子是泛化表现的决定因素——这是提议器决策的依据。
|
| 5 |
+
|
| 6 |
+
实现:
|
| 7 |
+
· ci_test —— 标准条件互信息 I(X;Y|Z) 的频率估计 + Bootstrap 置零检验
|
| 8 |
+
· pc_skeleton —— PC 算法骨架:逐级条件独立检验删边
|
| 9 |
+
· CausalDiscovery —— 高层封装
|
| 10 |
+
"""
|
| 11 |
+
import numpy as np
|
| 12 |
+
from itertools import combinations
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _discretize(col, bins=3):
|
| 16 |
+
"""连续列分箱为离散标签(用于互信息估计)。"""
|
| 17 |
+
if np.all(col == col[0]):
|
| 18 |
+
return np.zeros(len(col), dtype=int)
|
| 19 |
+
q = np.quantile(col, np.linspace(0, 1, bins + 1)[1:-1])
|
| 20 |
+
return np.clip(np.digitize(col, q), 0, bins - 1)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _cmi(dx, dy, dz):
|
| 24 |
+
"""标准条件互信息 I(X;Y|Z) = Σ p(xyz) log[p(xy|z)/(p(x|z)p(y|z)]。"""
|
| 25 |
+
n = len(dx)
|
| 26 |
+
if n < 2:
|
| 27 |
+
return 0.0
|
| 28 |
+
from collections import Counter
|
| 29 |
+
c_xyz = Counter(); c_xz = Counter(); c_yz = Counter()
|
| 30 |
+
for x, y, z in zip(dx, dy, dz):
|
| 31 |
+
c_xyz[(x, y, z)] += 1
|
| 32 |
+
c_xz[(x, z)] += 1
|
| 33 |
+
c_yz[(y, z)] += 1
|
| 34 |
+
mi = 0.0
|
| 35 |
+
for (x, y, z), c in c_xyz.items():
|
| 36 |
+
c_x, c_y = c_xz[(x, z)], c_yz[(y, z)]
|
| 37 |
+
if c_x == 0 or c_y == 0:
|
| 38 |
+
continue
|
| 39 |
+
term = (c / n) * np.log2((c * n) / (c_x * c_y))
|
| 40 |
+
mi += term
|
| 41 |
+
return float(np.clip(mi, 0.0, None))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def ci_test(data, x, y, z, rng=None, n_boot=100, alpha=0.5):
|
| 45 |
+
"""X⊥Y|Z 条件独立检验(互信息 + Bootstrap)。
|
| 46 |
+
|
| 47 |
+
返回 (p_value, mi)。p 越低越倾向"相关/依赖";p>alpha 判独立。
|
| 48 |
+
检验用标准条件互信息;null 分布用打乱 X(破坏 X⊥(Y,Z))。
|
| 49 |
+
"""
|
| 50 |
+
data = np.asarray(data, dtype=float)
|
| 51 |
+
n = data.shape[0]
|
| 52 |
+
if n < 2:
|
| 53 |
+
return 1.0, 0.0
|
| 54 |
+
dx = _discretize(data[:, x]); dy = _discretize(data[:, y])
|
| 55 |
+
if z:
|
| 56 |
+
# 条件多列 → 每列单独分箱 → 联合编码成一维整数 bin code
|
| 57 |
+
dz = np.zeros(n, dtype=int)
|
| 58 |
+
for i in z:
|
| 59 |
+
dz = dz * 2 + _discretize(data[:, i], bins=2)
|
| 60 |
+
else:
|
| 61 |
+
dz = np.zeros(n, dtype=int)
|
| 62 |
+
mi = _cmi(dx, dy, dz)
|
| 63 |
+
rng = rng or np.random.default_rng(0)
|
| 64 |
+
null = []; dxx = dx.copy()
|
| 65 |
+
for _ in range(n_boot):
|
| 66 |
+
dxx[:] = rng.permutation(dx)
|
| 67 |
+
null.append(_cmi(dxx, dy, dz))
|
| 68 |
+
null = np.array(null)
|
| 69 |
+
p = float(((null >= mi).sum() + 1) / (len(null) + 1))
|
| 70 |
+
return p, mi
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def pc_skeleton(X_data, variables, alpha=0.3, max_order=2, rng=None):
|
| 74 |
+
"""PC 骨架:若 X、Y 在给定阶条件集下条件独立则删边(返回无向骨架位图)。"""
|
| 75 |
+
n, p = X_data.shape
|
| 76 |
+
adj = {(i, j): True for i in range(p) for j in range(i + 1, p)}
|
| 77 |
+
for order in range(max_order + 1):
|
| 78 |
+
for (i, j) in list(adj):
|
| 79 |
+
if not adj[(i, j)]:
|
| 80 |
+
continue
|
| 81 |
+
nb_j = [k for k in range(p) if k not in (i, j)
|
| 82 |
+
and adj.get((min(j, k), max(j, k)), False)]
|
| 83 |
+
if len(nb_j) < order:
|
| 84 |
+
continue
|
| 85 |
+
for cond in combinations(nb_j, order):
|
| 86 |
+
pval, _ = ci_test(X_data, i, j, list(cond), rng=rng)
|
| 87 |
+
if pval > alpha:
|
| 88 |
+
adj[(i, j)] = False
|
| 89 |
+
break
|
| 90 |
+
return adj
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class CausalDiscovery:
|
| 94 |
+
"""高层封装:观测因子矩阵 + acc → 依赖发现。"""
|
| 95 |
+
|
| 96 |
+
def __init__(self, world, alpha=0.4, max_order=2, seed=0):
|
| 97 |
+
self.world = world
|
| 98 |
+
self.alpha = alpha
|
| 99 |
+
self.max_order = max_order
|
| 100 |
+
self.rng = np.random.default_rng(seed)
|
| 101 |
+
|
| 102 |
+
def optimize_adjustment(self, perms, learned_atoms):
|
| 103 |
+
"""基于观测因子构建数据,做 PC 骨架发现。
|
| 104 |
+
|
| 105 |
+
返回 (vars_, adj, X)。acc 用世界真值(模拟观测),
|
| 106 |
+
learned_atoms 使"已覆盖"结构差异化可被反估计。
|
| 107 |
+
"""
|
| 108 |
+
rows = []
|
| 109 |
+
for i, p in enumerate(perms):
|
| 110 |
+
feat = self.world.observe(p, rng=np.random.default_rng(i))
|
| 111 |
+
a = self.world.acc(p, learned_atoms)
|
| 112 |
+
rows.append([feat[0], feat[1], feat[2], a])
|
| 113 |
+
X = np.array(rows)
|
| 114 |
+
vars_ = ['gen_count', 'new_atoms', 'cyc', 'acc']
|
| 115 |
+
adj = pc_skeleton(X, vars_, alpha=self.alpha,
|
| 116 |
+
max_order=self.max_order, rng=self.rng)
|
| 117 |
+
return vars_, adj, X
|
| 118 |
+
|
| 119 |
+
def discover_graph(self, rows):
|
| 120 |
+
"""将变量间邻接转为因子 DAG 骨架并返回。"""
|
| 121 |
+
return pc_skeleton(np.array(rows),
|
| 122 |
+
['gen_count', 'new_atoms', 'cyc', 'acc'],
|
| 123 |
+
alpha=self.alpha, max_order=self.max_order,
|
| 124 |
+
rng=self.rng)
|
hibs_lnn/causal/identification.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""identification.py — 后门调整 / do-calculus 识别 / 因果效应估计(第 10-11 章)
|
| 2 |
+
|
| 3 |
+
目标:从观测数据推出干预分布 P(Y | do(X))。
|
| 4 |
+
|
| 5 |
+
后门准则(Pearl):若变量集 Z 满足
|
| 6 |
+
① Z 中无 X 的后代,且
|
| 7 |
+
② Z 阻断所有 X→Y 的后门路径(X 与某节点之间指向 X 的路径),
|
| 8 |
+
则 P(Y|do(X=x)) = Σ_z P(Y|X=x,z)P(z)。
|
| 9 |
+
|
| 10 |
+
用观测数据(本世界中各 perm 的真实 acc 与生成元因子)做离散化后门调整,
|
| 11 |
+
估计"覆盖生成元 X 对后续 unseen 提升 Y 的因果效应",用于提议器决策。
|
| 12 |
+
"""
|
| 13 |
+
import numpy as np
|
| 14 |
+
from .dag import Graph, d_separated, is_dag
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _backdoor_paths_blocked(graph: Graph, X, Y, Z):
|
| 18 |
+
"""Z 是否阻断所有 X 与 Y 之间指向 X 的后门路径。
|
| 19 |
+
|
| 20 |
+
X/Y 为单节点。后门路径:连接 X 与 Y、且第一条边指向 X 的无向路径。
|
| 21 |
+
判定:在互图(删除 X 的出边后的无向图)中,X 与 Y 被 Z 分隔。
|
| 22 |
+
"""
|
| 23 |
+
# 构造剔除 X→ 出边后的图(保留指向 X 的边)
|
| 24 |
+
parents = {n: set(graph.parents[n]) for n in graph.nodes}
|
| 25 |
+
for ch in graph.children(X):
|
| 26 |
+
parents[ch].discard(X) # 删除 X 的出边
|
| 27 |
+
g2 = Graph(parents)
|
| 28 |
+
# 从 X 到 Y 的所有路径(用 d_separated 判断是否被 Z 阻断其余路径)
|
| 29 |
+
# 后门准则要求所有后门路径都被 Z 阻断,等价于在该互图上 X 与 Y 被 Z 分隔
|
| 30 |
+
return d_separated(g2, {X}, {Y}, set(Z))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def backdoor_adjustment_set(graph: Graph, X, Y, nodes):
|
| 34 |
+
"""在候选节点中找一个满足后门准则的调整集 Z(返回 None 表示不存在/直接用空集)。"""
|
| 35 |
+
cand = sorted(nodes - {X, Y})
|
| 36 |
+
if _backdoor_paths_blocked(graph, X, Y, set()):
|
| 37 |
+
return set()
|
| 38 |
+
# 简单搜索:尝试小规模调整集(组合,节点少则穷举)
|
| 39 |
+
from itertools import combinations
|
| 40 |
+
for k in range(len(cand) + 1):
|
| 41 |
+
for combo in combinations(cand, k):
|
| 42 |
+
Z = set(combo)
|
| 43 |
+
if not _desc_intersect(graph, X, Z) and \
|
| 44 |
+
_backdoor_paths_blocked(graph, X, Y, Z):
|
| 45 |
+
return Z
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _desc_intersect(graph, X, Z):
|
| 50 |
+
from .dag import descendants
|
| 51 |
+
return bool(descendants(graph, X) & set(Z)) or X in Z
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def causal_effect(data, X, Y, Z=None):
|
| 55 |
+
"""离散化后门调整估计 E[Y | do(X=x)](比较 do=1 vs do=0 的平均因果效应)。
|
| 56 |
+
|
| 57 |
+
data : list of (X_val(0/1), Y_val(连续), dict(调整变量->离散值))
|
| 58 |
+
Z : 需调整的变量名(数据各行带该变量值)
|
| 59 |
+
返回 : (ATE, effect_by_Z) 平均处理效应 = E[Y|do(X=1)] - E[Y|do(X=0)]
|
| 60 |
+
"""
|
| 61 |
+
rows = data
|
| 62 |
+
znames = list(Z) if Z else []
|
| 63 |
+
# 先验 P(z)
|
| 64 |
+
from collections import defaultdict
|
| 65 |
+
z_count = defaultdict(float); z_sum1 = defaultdict(float); z_sum0 = defaultdict(float)
|
| 66 |
+
for x, y, zv in rows:
|
| 67 |
+
key = tuple(zv.get(n, 0) for n in znames)
|
| 68 |
+
z_count[key] += 1
|
| 69 |
+
if x == 1:
|
| 70 |
+
z_sum1[key] += y
|
| 71 |
+
else:
|
| 72 |
+
z_sum0[key] += y
|
| 73 |
+
total = float(len(rows))
|
| 74 |
+
ate = 0.0
|
| 75 |
+
for key, cnt in z_count.items():
|
| 76 |
+
pz = cnt / total
|
| 77 |
+
m1 = z_sum1[key] / z_count[key] if z_count[key] else 0.0 # 简化:用同层均值
|
| 78 |
+
m0 = z_sum0[key] / z_count[key] if z_count[key] else 0.0
|
| 79 |
+
# 同层内若缺 X 侧,用该层观测直接近似
|
| 80 |
+
ate += pz * (m1 - m0)
|
| 81 |
+
return ate
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def g_formula(graph: Graph, X, Y, Z):
|
| 85 |
+
"""g-公式的图诊断:检查调整集 Z 是否满足后门/前门条件并返回可识别性。"""
|
| 86 |
+
if not is_dag(graph):
|
| 87 |
+
return False, "图非 DAG,不可识别"
|
| 88 |
+
Zs = set(Z)
|
| 89 |
+
no_desc = not (_desc_intersect(graph, X, Zs))
|
| 90 |
+
blocked = _backdoor_paths_blocked(graph, X, Y, Zs)
|
| 91 |
+
if no_desc and blocked:
|
| 92 |
+
return True, "backdoor 满足 → 可识别(P(Y|do(X))=Σ_z P(Y|X,z)P(z))"
|
| 93 |
+
return False, "后门不满足(未实现前门/do-calculus 自动搜索)"
|
hibs_lnn/causal/scm.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""scm.py — 结构因果模型 SCM + do() 干预 + 反事实(《Causal AI》第 6-9 章)
|
| 2 |
+
|
| 3 |
+
把 headless 的 S4 元学习世界形式化为一个结构因果模型:
|
| 4 |
+
|
| 5 |
+
生成元集合 G --> 循环结构 cyc --> 可观测特征 obs --> 泛化表现 acc
|
| 6 |
+
|
| 7 |
+
· 每个任务的"原因" = 它由哪些对换生成元(6 原子)复合而成 (G)
|
| 8 |
+
· "结果" = 在未见过结构上的泛化表现 (acc)
|
| 9 |
+
|
| 10 |
+
SCM 三角色(Pearl):观察 P(Y|X) / 干预 do(x) / 反事实 Y_x(u)
|
| 11 |
+
"""
|
| 12 |
+
import numpy as np
|
| 13 |
+
from itertools import permutations
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SCM:
|
| 17 |
+
"""通用结构因果模型(numpy 版,无外部依赖)。"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, variables, exogenous, equations, parents):
|
| 20 |
+
self.variables = list(variables)
|
| 21 |
+
self.exogenous = list(exogenous)
|
| 22 |
+
self.equations = dict(equations)
|
| 23 |
+
self.parents = {v: set(ps) for v, ps in parents.items()}
|
| 24 |
+
self.all_nodes = set(self.variables) | set(self.exogenous)
|
| 25 |
+
|
| 26 |
+
def _mutate(self, do):
|
| 27 |
+
eq = dict(self.equations)
|
| 28 |
+
for var in do:
|
| 29 |
+
if var in self.variables:
|
| 30 |
+
eq[var] = lambda a, _v=var, _x=do[var]: _x
|
| 31 |
+
return eq
|
| 32 |
+
|
| 33 |
+
def sample(self, do=None, rng=None, n=1):
|
| 34 |
+
rng = rng if rng is not None else np.random.default_rng()
|
| 35 |
+
eq = self._mutate(do or {})
|
| 36 |
+
samples = []
|
| 37 |
+
for _ in range(n):
|
| 38 |
+
a = {}
|
| 39 |
+
for v in self.exogenous:
|
| 40 |
+
a[v] = rng.normal(0.0, 1.0)
|
| 41 |
+
for v in self.variables:
|
| 42 |
+
a[v] = float(eq[v](a))
|
| 43 |
+
if do:
|
| 44 |
+
for v, x in do.items():
|
| 45 |
+
a[v] = float(x)
|
| 46 |
+
samples.append(a)
|
| 47 |
+
return samples
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ----------------------------------------------------------------------
|
| 51 |
+
# S4 世界的因子(生成元签名 + 循环类型)
|
| 52 |
+
# ----------------------------------------------------------------------
|
| 53 |
+
_PERMS24 = list(permutations(range(4)))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _cycle_type(perm):
|
| 57 |
+
seen = set(); lens = []
|
| 58 |
+
for i in range(4):
|
| 59 |
+
if i in seen:
|
| 60 |
+
continue
|
| 61 |
+
c = []; j = i
|
| 62 |
+
while j not in seen:
|
| 63 |
+
seen.add(j); c.append(j); j = perm[j]
|
| 64 |
+
if len(c) > 1:
|
| 65 |
+
lens.append(len(c))
|
| 66 |
+
lens.sort()
|
| 67 |
+
if not lens:
|
| 68 |
+
return 0 # ident
|
| 69 |
+
if lens == [2]:
|
| 70 |
+
return 1 # swap
|
| 71 |
+
if lens == [3]:
|
| 72 |
+
return 2 # 3-cycle
|
| 73 |
+
if lens == [4]:
|
| 74 |
+
return 3 # 4-cycle
|
| 75 |
+
if lens == [2, 2]:
|
| 76 |
+
return 4 # double swap
|
| 77 |
+
return 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _generate_perms():
|
| 81 |
+
"""perm -> (sig_bits[6], cyc_type)。sig_bits: 6 位对换生成元掩码。"""
|
| 82 |
+
TRANS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
|
| 83 |
+
IDENT = (0, 1, 2, 3)
|
| 84 |
+
from collections import deque
|
| 85 |
+
out = {}
|
| 86 |
+
for perm in _PERMS24:
|
| 87 |
+
q = deque([(IDENT, [])]); seen = {IDENT}; found = None
|
| 88 |
+
while q and found is None:
|
| 89 |
+
cur, w = q.popleft()
|
| 90 |
+
if cur == perm:
|
| 91 |
+
found = w; break
|
| 92 |
+
if len(w) >= 6:
|
| 93 |
+
continue
|
| 94 |
+
for gi, g in enumerate(TRANS):
|
| 95 |
+
a, b = g; p = list(cur); p[a], p[b] = p[b], p[a]
|
| 96 |
+
nxt = tuple(p)
|
| 97 |
+
if nxt not in seen:
|
| 98 |
+
seen.add(nxt); q.append((nxt, w + [gi]))
|
| 99 |
+
sig = [0] * 6
|
| 100 |
+
if found:
|
| 101 |
+
for gi in found:
|
| 102 |
+
sig[gi] = 1
|
| 103 |
+
out[perm] = (sig, _cycle_type(perm))
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class S4WorldSCM:
|
| 108 |
+
"""S4 元学习世界的结构因果模型。
|
| 109 |
+
|
| 110 |
+
变量:
|
| 111 |
+
G 生成元集合(6 位掩码)----- 因
|
| 112 |
+
cyc 循环类型(0..4)
|
| 113 |
+
obs 可观测训练特征
|
| 114 |
+
acc 泛化表现(0..1)---------- 果
|
| 115 |
+
|
| 116 |
+
因果结构:G -> cyc;G -> obs;cyc -> obs;obs,cyc -> acc
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
PARENTS = {
|
| 120 |
+
'cyc': {'G'},
|
| 121 |
+
'obs': {'G', 'cyc', 'U_obs'},
|
| 122 |
+
'acc': {'obs', 'cyc', 'U_acc'},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
def __init__(self, seed=0):
|
| 126 |
+
self.rng = np.random.default_rng(seed)
|
| 127 |
+
self.factor = {}
|
| 128 |
+
for perm in _PERMS24:
|
| 129 |
+
sig, cyc = _GEN[perm]
|
| 130 |
+
self.factor[perm] = {'sig': list(sig), 'cyc': cyc}
|
| 131 |
+
|
| 132 |
+
def sig_bits(self, perm):
|
| 133 |
+
return self.factor[perm]['sig']
|
| 134 |
+
|
| 135 |
+
def cyc(self, perm):
|
| 136 |
+
return self.factor[perm]['cyc']
|
| 137 |
+
|
| 138 |
+
def observe(self, perm, rng=None):
|
| 139 |
+
rng = rng or self.rng
|
| 140 |
+
sig = np.array(self.sig_bits(perm), dtype=float)
|
| 141 |
+
u1 = rng.normal(0, 0.3)
|
| 142 |
+
u2 = rng.normal(0, 0.25)
|
| 143 |
+
return np.array([sig.sum() + u1 * 0.2,
|
| 144 |
+
(6 - sig.sum()) + u2 * 0.2,
|
| 145 |
+
self.cyc(perm) * 0.6])
|
| 146 |
+
|
| 147 |
+
def acc(self, perm, learned_atoms, seed=None, noise=0.08):
|
| 148 |
+
"""泛化真值 acc(0..1)。已学生成元越多 acc 越高;四/双对换更硬。"""
|
| 149 |
+
rng = np.random.default_rng(seed) if seed is not None else self.rng
|
| 150 |
+
sig = np.array(self.sig_bits(perm), dtype=float)
|
| 151 |
+
known = sum(1 for i in range(6) if sig[i] and i in learned_atoms)
|
| 152 |
+
total = max(1.0, sig.sum())
|
| 153 |
+
base = 0.35 + 0.6 * (known / total)
|
| 154 |
+
cyc = self.cyc(perm)
|
| 155 |
+
if cyc in (3, 4):
|
| 156 |
+
base *= 0.90
|
| 157 |
+
if cyc == 4:
|
| 158 |
+
base *= 0.88
|
| 159 |
+
return float(np.clip(base + rng.normal(0, noise), 0.02, 0.98))
|
| 160 |
+
|
| 161 |
+
def do_intervene(self, perm, learned_atoms, atoms=None):
|
| 162 |
+
"""do():干预——假设该 perm 的生成元已被覆盖,测量其对 acc 的因果效应。
|
| 163 |
+
|
| 164 |
+
atoms=None (旧语义, 保持向后兼容):
|
| 165 |
+
把该 perm 的**全部**生成元位并入已学集合。此时 known/total ≡ 1,
|
| 166 |
+
acc 恒为 ~0.95 —— 输出退化成常数, 不适合做"干预 vs 观测"的对照。
|
| 167 |
+
atoms=[i,...] (有区分度的干预):
|
| 168 |
+
只点亮指定的生成元, acc 随样本变化。
|
| 169 |
+
实测: 旧语义下 y_do 有 78.9% 挤在同一类, 多数类基线 0.789,
|
| 170 |
+
模型原始准确率 0.734 竟**低于常数预测器** —— 对照完全失去意义。
|
| 171 |
+
"""
|
| 172 |
+
new_atoms = set(learned_atoms)
|
| 173 |
+
if atoms is None:
|
| 174 |
+
new_atoms |= {i for i in range(6) if self.sig_bits(perm)[i]}
|
| 175 |
+
else:
|
| 176 |
+
new_atoms |= {int(a) for a in atoms}
|
| 177 |
+
return self.acc(perm, new_atoms, noise=0.0)
|
| 178 |
+
|
| 179 |
+
def counterfactual(self, observed_perm, observed_acc, learned_atoms,
|
| 180 |
+
do_perm=None):
|
| 181 |
+
"""反事实(Pearl 三步:Abduction → Action → Prediction)。"""
|
| 182 |
+
# 1) Abduction:由观测偏差反推外生扰动 U
|
| 183 |
+
n_known = sum(1 for i in range(6)
|
| 184 |
+
if self.sig_bits(observed_perm)[i] and i in learned_atoms)
|
| 185 |
+
sig_total = max(1.0, sum(self.sig_bits(observed_perm)))
|
| 186 |
+
base = 0.35 + 0.6 * (n_known / sig_total)
|
| 187 |
+
cyc = self.cyc(observed_perm)
|
| 188 |
+
if cyc in (3, 4):
|
| 189 |
+
base *= 0.90
|
| 190 |
+
if cyc == 4:
|
| 191 |
+
base *= 0.88
|
| 192 |
+
u_acc = observed_acc - base
|
| 193 |
+
|
| 194 |
+
# 2) Action:干预——将 do_perm 的生成元也计入已学
|
| 195 |
+
new_atoms = set(learned_atoms)
|
| 196 |
+
if do_perm is not None:
|
| 197 |
+
new_atoms |= {i for i in range(6) if self.sig_bits(do_perm)[i]}
|
| 198 |
+
n_known2 = sum(1 for i in range(6)
|
| 199 |
+
if self.sig_bits(observed_perm)[i] and i in new_atoms)
|
| 200 |
+
base2 = 0.35 + 0.6 * (n_known2 / max(1.0, sig_total))
|
| 201 |
+
c = self.cyc(observed_perm)
|
| 202 |
+
if c in (3, 4):
|
| 203 |
+
base2 *= 0.90
|
| 204 |
+
if c == 4:
|
| 205 |
+
base2 *= 0.88
|
| 206 |
+
|
| 207 |
+
# 3) Prediction:同一个人(同 U),不同干预 → 新结果
|
| 208 |
+
y_cf = np.clip(base2 + u_acc, 0.02, 0.98)
|
| 209 |
+
return float(y_cf), {'u_acc': round(float(u_acc), 4),
|
| 210 |
+
'n_known_before': n_known,
|
| 211 |
+
'n_known_after': n_known2}
|
| 212 |
+
_GEN = _generate_perms()
|
hibs_lnn/causal/self_test.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""self_test.py — causal 因果技术栈自测(python3 -m hibs_lnn.causal.self_test)
|
| 2 |
+
|
| 3 |
+
覆盖:d-separation 经典三元组 · S4WorldSCM 干预/反事实 ·
|
| 4 |
+
识别(后门) · 发现 · 提议器 · Causal RL
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
sys.path.insert(0, ROOT)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def run():
|
| 13 |
+
ok = 0
|
| 14 |
+
total = 0
|
| 15 |
+
|
| 16 |
+
def check(cond, msg):
|
| 17 |
+
nonlocal ok, total
|
| 18 |
+
total += 1
|
| 19 |
+
ok += 1 if cond else 0
|
| 20 |
+
print((" OK " if cond else " FAIL ") + msg)
|
| 21 |
+
|
| 22 |
+
from hibs_lnn.causal.dag import Graph, d_separated, is_dag
|
| 23 |
+
|
| 24 |
+
# 1) 对撞 A -> B <- C
|
| 25 |
+
G = Graph({'B': {'A', 'C'}})
|
| 26 |
+
check(d_separated(G, {'A'}, {'C'}, set()) is True, "collider: A indep C (marginal)")
|
| 27 |
+
check(d_separated(G, {'A'}, {'C'}, {'B'}) is False, "collider: A not indep C | B")
|
| 28 |
+
|
| 29 |
+
# 2) 链 A -> B -> C
|
| 30 |
+
G2 = Graph({'B': {'A'}, 'C': {'B'}})
|
| 31 |
+
check(d_separated(G2, {'A'}, {'C'}, set()) is False, "chain: A not indep C")
|
| 32 |
+
check(d_separated(G2, {'A'}, {'C'}, {'B'}) is True, "chain: A indep C | B")
|
| 33 |
+
check(is_dag(G2) is True, "chain is DAG")
|
| 34 |
+
|
| 35 |
+
# 3) 叉 A <- B -> C(与链对称)
|
| 36 |
+
G3 = Graph({'A': {'B'}, 'C': {'B'}})
|
| 37 |
+
check(d_separated(G3, {'A'}, {'C'}, {'B'}) is True, "fork: A indep C | B")
|
| 38 |
+
|
| 39 |
+
# 4) S4WorldSCM
|
| 40 |
+
from hibs_lnn.causal.scm import S4WorldSCM, _PERMS24
|
| 41 |
+
s = S4WorldSCM()
|
| 42 |
+
p = (1, 2, 3, 0)
|
| 43 |
+
check(s.cyc(p) >= 0 and len(s.sig_bits(p)) == 6, "S4 factor extraction")
|
| 44 |
+
y0 = s.acc(p, set(), noise=0.0)
|
| 45 |
+
y1 = s.do_intervene(p, set())
|
| 46 |
+
check(y1 >= y0 - 1e-9, "do-intervene should not reduce acc (covers atoms)")
|
| 47 |
+
y_cf, meta = s.counterfactual(p, y0, set(), do_perm=p)
|
| 48 |
+
check(meta['n_known_after'] > meta['n_known_before'], "counterfactual: atoms covered")
|
| 49 |
+
|
| 50 |
+
# 5) 识别(后门)
|
| 51 |
+
from hibs_lnn.causal.identification import (
|
| 52 |
+
backdoor_adjustment_set, g_formula)
|
| 53 |
+
# 5a) S4 因子图:G 无外生父 → 无后门路径 → 空集可识别
|
| 54 |
+
Gg = Graph({'cyc': {'G'}, 'obs': {'G', 'cyc'}, 'acc': {'obs', 'cyc'}})
|
| 55 |
+
Z0 = backdoor_adjustment_set(Gg, 'G', 'acc', {'G', 'cyc', 'obs'})
|
| 56 |
+
check(Z0 == set(), "backdoor is empty when treatment has no parents")
|
| 57 |
+
ident, _ = g_formula(Gg, 'G', 'acc', set())
|
| 58 |
+
check(ident, "g-formula identifies with empty adjustment set")
|
| 59 |
+
# 5b) 经典混杂 M -> X, M -> Y, X -> Y:do(X) 需调整 M
|
| 60 |
+
Gm = Graph({'X': {'M'}, 'Y': {'X', 'M'}})
|
| 61 |
+
Zm = backdoor_adjustment_set(Gm, 'X', 'Y', {'M', 'X', 'Y'})
|
| 62 |
+
check(Zm == {'M'}, "backdoor adjustment set = {M} for confounded case, got %r" % (Zm,))
|
| 63 |
+
|
| 64 |
+
# 6) 发现
|
| 65 |
+
from hibs_lnn.causal.discovery import CausalDiscovery
|
| 66 |
+
learned = [(0, 1, 2, 3), (1, 0, 2, 3), (0, 1, 3, 2), (0, 2, 1, 3)]
|
| 67 |
+
cd = CausalDiscovery(s, alpha=0.4)
|
| 68 |
+
vars_, adj, X = cd.optimize_adjustment(learned, set())
|
| 69 |
+
check(len(vars_) == 4 and len(X) == len(learned), "discovery data built")
|
| 70 |
+
|
| 71 |
+
# 7) 因果提议器
|
| 72 |
+
from hibs_lnn.causal.causal_proposer import CausalProposer
|
| 73 |
+
cp = CausalProposer(learned, k=3, seed=1)
|
| 74 |
+
prop = cp.propose(n=2)
|
| 75 |
+
check(len(prop) == 2 and all(p not in learned for p in prop),
|
| 76 |
+
"causal proposer returns unseen perms")
|
| 77 |
+
|
| 78 |
+
# 8) Causal RL
|
| 79 |
+
from hibs_lnn.causal.causal_rl import CausalRLAgent
|
| 80 |
+
agent = CausalRLAgent(learned[:3], k=1, seed=2)
|
| 81 |
+
res = agent.run(2)
|
| 82 |
+
check(res['covered_atoms'] >= 1 and len(res['q_atom']) == 6,
|
| 83 |
+
"causal RL credits atoms: %r" % (res['q_atom'],))
|
| 84 |
+
|
| 85 |
+
print("\n %d/%d passed" % (ok, total))
|
| 86 |
+
return ok == total
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
sys.exit(0 if run() else 1)
|
hibs_lnn/code_world.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
V30: Code Execution World
|
| 3 |
+
=========================
|
| 4 |
+
Simple register-machine sandbox: 8 instructions, 4 registers, deterministic semantics.
|
| 5 |
+
|
| 6 |
+
Replaces the notebook world (WRITE/READ/ERASE/TOME) with a world that has
|
| 7 |
+
real causal semantics — the model must learn what each instruction *does*.
|
| 8 |
+
|
| 9 |
+
Why this is better than the notebook world:
|
| 10 |
+
- Deterministic causality: ADD R0 R1 R2 always gives R2 = R0 + R1
|
| 11 |
+
- Compositional: SET + ADD → understanding builds on primitives
|
| 12 |
+
- Grounded counterfactuals: "what if I had ADDed R2 instead of R1" has real meaning
|
| 13 |
+
- Measurable: instruction accuracy directly measures understanding depth
|
| 14 |
+
|
| 15 |
+
Instruction set (8 opcodes, encoded as token+100):
|
| 16 |
+
0 NOP — no operation
|
| 17 |
+
1 SET Ri val — Ri = val
|
| 18 |
+
2 MOV Ri Rj — Rj = Ri
|
| 19 |
+
3 ADD Ri Rj Rk — Rk = (Ri + Rj) % V
|
| 20 |
+
4 SUB Ri Rj Rk — Rk = max(0, Ri - Rj)
|
| 21 |
+
5 INC Ri — Ri = (Ri + 1) % V
|
| 22 |
+
6 DEC Ri — Ri = max(0, Ri - 1)
|
| 23 |
+
7 SWP Ri Rj — swap Ri and Rj
|
| 24 |
+
|
| 25 |
+
Actions (compatible with existing world interface):
|
| 26 |
+
0 = READ → return all 4 register values
|
| 27 |
+
1 = WRITE → write 1 token; 4 tokens = 1 complete instruction (auto-loaded)
|
| 28 |
+
2 = EXEC → step-execute next instruction
|
| 29 |
+
3 = RESET → zero all registers, clear program
|
| 30 |
+
4 = TOME → no-op
|
| 31 |
+
"""
|
| 32 |
+
import torch
|
| 33 |
+
import random
|
| 34 |
+
from typing import List, Tuple, Optional
|
| 35 |
+
|
| 36 |
+
# ============================================================
|
| 37 |
+
# Constants
|
| 38 |
+
# ============================================================
|
| 39 |
+
N_REGISTERS = 4
|
| 40 |
+
INSTR_LEN = 4 # tokens per instruction
|
| 41 |
+
OPCODE_TOKEN_OFFSET = 100 # opcode = token - 100
|
| 42 |
+
|
| 43 |
+
OPCODES = {
|
| 44 |
+
'NOP': 0,
|
| 45 |
+
'SET': 1, # SET Ri val → Ri = val
|
| 46 |
+
'MOV': 2, # MOV Ri Rj → Rj = Ri
|
| 47 |
+
'ADD': 3, # ADD Ri Rj Rk → Rk = (Ri + Rj) % V
|
| 48 |
+
'SUB': 4, # SUB Ri Rj Rk → Rk = max(0, Ri - Rj)
|
| 49 |
+
'INC': 5, # INC Ri → Ri = (Ri + 1) % V
|
| 50 |
+
'DEC': 6, # DEC Ri → Ri = max(0, Ri - 1)
|
| 51 |
+
'SWP': 7, # SWP Ri Rj → swap
|
| 52 |
+
}
|
| 53 |
+
OPCODES_INV = {v: k for k, v in OPCODES.items()}
|
| 54 |
+
|
| 55 |
+
INSTR_NAMES = list(OPCODES.keys())
|
| 56 |
+
INSTR_OPCODES = list(OPCODES.values())
|
| 57 |
+
|
| 58 |
+
# How many args each instruction expects (beyond opcode)
|
| 59 |
+
INSTR_ARITY = {
|
| 60 |
+
'NOP': 0, 'SET': 2, 'MOV': 2,
|
| 61 |
+
'ADD': 3, 'SUB': 3,
|
| 62 |
+
'INC': 1, 'DEC': 1, 'SWP': 2,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ============================================================
|
| 67 |
+
# Register Machine VM
|
| 68 |
+
# ============================================================
|
| 69 |
+
class RegisterMachine:
|
| 70 |
+
"""4-register VM with 8 instructions. V = max token value."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, V: int):
|
| 73 |
+
self.V = V
|
| 74 |
+
self.regs = [0] * N_REGISTERS # R0..R3
|
| 75 |
+
self.program: List[Tuple[int, int, int, int]] = [] # (opcode, a1, a2, a3)
|
| 76 |
+
self.pc = 0 # program counter
|
| 77 |
+
self.step_count = 0
|
| 78 |
+
self.max_steps = 500
|
| 79 |
+
self.halted = False
|
| 80 |
+
|
| 81 |
+
def reset(self):
|
| 82 |
+
self.regs = [0] * N_REGISTERS
|
| 83 |
+
self.program.clear()
|
| 84 |
+
self.pc = 0
|
| 85 |
+
self.step_count = 0
|
| 86 |
+
self.halted = False
|
| 87 |
+
|
| 88 |
+
def load_instruction(self, tokens: List[int]) -> bool:
|
| 89 |
+
"""Decode 4 tokens into an instruction and append to program.
|
| 90 |
+
tokens[0] = opcode+100, tokens[1..3] = args (register or immediate).
|
| 91 |
+
Returns True if valid opcode."""
|
| 92 |
+
opcode = tokens[0] - OPCODE_TOKEN_OFFSET
|
| 93 |
+
if opcode not in OPCODES_INV:
|
| 94 |
+
return False
|
| 95 |
+
args = [t if t < 100 else t - 100 for t in tokens[1:]]
|
| 96 |
+
while len(args) < 3:
|
| 97 |
+
args.append(0)
|
| 98 |
+
self.program.append((opcode, args[0], args[1], args[2]))
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
def step_execute(self) -> bool:
|
| 102 |
+
"""Execute one instruction at PC. Returns True if executed, False if halted."""
|
| 103 |
+
if self.halted or self.pc >= len(self.program):
|
| 104 |
+
self.halted = True
|
| 105 |
+
return False
|
| 106 |
+
if self.step_count >= self.max_steps:
|
| 107 |
+
self.halted = True
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
opcode, a1, a2, a3 = self.program[self.pc]
|
| 111 |
+
name = OPCODES_INV[opcode]
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
if name == 'NOP':
|
| 115 |
+
pass
|
| 116 |
+
elif name == 'SET':
|
| 117 |
+
self.regs[a1 % N_REGISTERS] = a2 % self.V
|
| 118 |
+
elif name == 'MOV':
|
| 119 |
+
self.regs[a2 % N_REGISTERS] = self.regs[a1 % N_REGISTERS]
|
| 120 |
+
elif name == 'ADD':
|
| 121 |
+
self.regs[a3 % N_REGISTERS] = (self.regs[a1 % N_REGISTERS] + self.regs[a2 % N_REGISTERS]) % self.V
|
| 122 |
+
elif name == 'SUB':
|
| 123 |
+
self.regs[a3 % N_REGISTERS] = max(0, self.regs[a1 % N_REGISTERS] - self.regs[a2 % N_REGISTERS])
|
| 124 |
+
elif name == 'INC':
|
| 125 |
+
self.regs[a1 % N_REGISTERS] = (self.regs[a1 % N_REGISTERS] + 1) % self.V
|
| 126 |
+
elif name == 'DEC':
|
| 127 |
+
self.regs[a1 % N_REGISTERS] = max(0, self.regs[a1 % N_REGISTERS] - 1)
|
| 128 |
+
elif name == 'SWP':
|
| 129 |
+
ri, rj = a1 % N_REGISTERS, a2 % N_REGISTERS
|
| 130 |
+
self.regs[ri], self.regs[rj] = self.regs[rj], self.regs[ri]
|
| 131 |
+
except Exception:
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
self.pc += 1
|
| 135 |
+
self.step_count += 1
|
| 136 |
+
return True
|
| 137 |
+
|
| 138 |
+
def get_state(self) -> List[int]:
|
| 139 |
+
"""Return register values clamped to [0, V-1]."""
|
| 140 |
+
return [min(r, self.V - 1) for r in self.regs]
|
| 141 |
+
|
| 142 |
+
def get_program_str(self) -> str:
|
| 143 |
+
"""Human-readable program dump."""
|
| 144 |
+
lines = []
|
| 145 |
+
for i, (op, a1, a2, a3) in enumerate(self.program):
|
| 146 |
+
n = OPCODES_INV[op]
|
| 147 |
+
if n == 'NOP': lines.append(f" {i}: NOP")
|
| 148 |
+
elif n == 'SET': lines.append(f" {i}: SET R{a1} = {a2}")
|
| 149 |
+
elif n in ('MOV','SWP'): lines.append(f" {i}: {n} R{a1} R{a2}")
|
| 150 |
+
elif n in ('ADD','SUB'): lines.append(f" {i}: {n} R{a1} R{a2} -> R{a3}")
|
| 151 |
+
elif n == 'INC': lines.append(f" {i}: INC R{a1}")
|
| 152 |
+
elif n == 'DEC': lines.append(f" {i}: DEC R{a1}")
|
| 153 |
+
return "\n".join(lines)
|
| 154 |
+
|
| 155 |
+
def generate_random_program(self, length: Optional[int] = None) -> List[Tuple[int, int, int, int]]:
|
| 156 |
+
"""Create a random program of `length` instructions."""
|
| 157 |
+
if length is None:
|
| 158 |
+
length = random.randint(1, 6)
|
| 159 |
+
self.program.clear()
|
| 160 |
+
self.pc = 0
|
| 161 |
+
for _ in range(length):
|
| 162 |
+
op = random.choice(INSTR_OPCODES)
|
| 163 |
+
n = OPCODES_INV[op]
|
| 164 |
+
if n == 'NOP': self.program.append((op, 0, 0, 0))
|
| 165 |
+
elif n == 'SET': self.program.append((op, random.randint(0, 3), random.randint(0, 99), 0))
|
| 166 |
+
elif n == 'MOV': self.program.append((op, random.randint(0, 3), random.randint(0, 3), 0))
|
| 167 |
+
elif n == 'ADD': self.program.append((op, random.randint(0, 3), random.randint(0, 3), random.randint(0, 3)))
|
| 168 |
+
elif n == 'SUB': self.program.append((op, random.randint(0, 3), random.randint(0, 3), random.randint(0, 3)))
|
| 169 |
+
elif n == 'INC': self.program.append((op, random.randint(0, 3), 0, 0))
|
| 170 |
+
elif n == 'DEC': self.program.append((op, random.randint(0, 3), 0, 0))
|
| 171 |
+
elif n == 'SWP': self.program.append((op, random.randint(0, 3), random.randint(0, 3), 0))
|
| 172 |
+
return self.program
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ============================================================
|
| 176 |
+
# CodeSandboxWorld — drop-in replacement for notebook worlds
|
| 177 |
+
# ============================================================
|
| 178 |
+
class CodeSandboxWorld:
|
| 179 |
+
"""
|
| 180 |
+
代码执行世界 v2 — 自动生成程序,模型预测寄存器变化
|
| 181 |
+
|
| 182 |
+
核心思路:世界自动生成随机程序并逐步执行。
|
| 183 |
+
模型的任务不是自己写指令,而是**预测执行结果**。
|
| 184 |
+
|
| 185 |
+
动作:
|
| 186 |
+
0 = READ → [R0,R1,R2,R3, next_opcode]
|
| 187 |
+
next_opcode=TOME 表示程序结束
|
| 188 |
+
1 = EXEC → 执行一步,返回新寄存器状态
|
| 189 |
+
2 = RESET → 生成新随机程序,寄存器归零
|
| 190 |
+
3 = NEWREG → 保持程序不变,随机重置寄存器值
|
| 191 |
+
4 = TOME → 无操作
|
| 192 |
+
|
| 193 |
+
观察窗口: 5 tokens (4 寄存器 + 1 下条指令 opcode)
|
| 194 |
+
模型应该学会: 给定 (state, next_instr) → EXEC 后 state 如何变
|
| 195 |
+
"""
|
| 196 |
+
K = 5
|
| 197 |
+
READ_WINDOW = N_REGISTERS + 1 # 4 regs + 1 opcode
|
| 198 |
+
ACTIONS = ['READ', 'EXEC', 'RESET', 'NEWREG', 'TOME']
|
| 199 |
+
|
| 200 |
+
def __init__(self, V: int, max_program_len: int = 8):
|
| 201 |
+
self.V = V
|
| 202 |
+
self.TOME = V + 1
|
| 203 |
+
self.world_vocab_size = V + 2
|
| 204 |
+
self.max_program_len = max_program_len
|
| 205 |
+
self._new_episode()
|
| 206 |
+
|
| 207 |
+
def _new_episode(self):
|
| 208 |
+
"""生成新回合:随机程序 + 随机初始寄存器"""
|
| 209 |
+
self.vm = RegisterMachine(self.V)
|
| 210 |
+
self.exec_count = 0
|
| 211 |
+
self.current_step = 0
|
| 212 |
+
self.n_erases = 0
|
| 213 |
+
|
| 214 |
+
# 生成随机程序
|
| 215 |
+
prog_len = random.randint(2, self.max_program_len)
|
| 216 |
+
self.vm.generate_random_program(prog_len)
|
| 217 |
+
|
| 218 |
+
# 随机初始寄存器值
|
| 219 |
+
for i in range(N_REGISTERS):
|
| 220 |
+
self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
|
| 221 |
+
|
| 222 |
+
# 预执行几步让状态有变化
|
| 223 |
+
pre_steps = random.randint(0, min(2, len(self.vm.program) - 1))
|
| 224 |
+
for _ in range(pre_steps):
|
| 225 |
+
self.vm.step_execute()
|
| 226 |
+
self.exec_count += 1
|
| 227 |
+
|
| 228 |
+
def reset(self):
|
| 229 |
+
self._new_episode()
|
| 230 |
+
|
| 231 |
+
def step_advance(self):
|
| 232 |
+
self.current_step += 1
|
| 233 |
+
|
| 234 |
+
def _get_program_pulse(self) -> torch.Tensor:
|
| 235 |
+
"""返回 5-token 观察: [R0,R1,R2,R3, next_opcode]"""
|
| 236 |
+
state = self.vm.get_state()
|
| 237 |
+
if self.vm.halted or self.vm.pc >= len(self.vm.program):
|
| 238 |
+
next_op = self.TOME
|
| 239 |
+
else:
|
| 240 |
+
opcode, _, _, _ = self.vm.program[self.vm.pc]
|
| 241 |
+
next_op = opcode + OPCODE_TOKEN_OFFSET # 编码回 token 空间
|
| 242 |
+
obs = state + [min(next_op, self.world_vocab_size - 1)]
|
| 243 |
+
return torch.tensor(obs, dtype=torch.long)
|
| 244 |
+
|
| 245 |
+
def query(self, action_id, write_token=None) -> Optional[torch.Tensor]:
|
| 246 |
+
if isinstance(action_id, torch.Tensor):
|
| 247 |
+
action_id = action_id.item()
|
| 248 |
+
|
| 249 |
+
if action_id == 0: # READ
|
| 250 |
+
return self._get_program_pulse()
|
| 251 |
+
|
| 252 |
+
elif action_id == 1: # EXEC
|
| 253 |
+
self.vm.step_execute()
|
| 254 |
+
self.exec_count += 1
|
| 255 |
+
return self._get_program_pulse()
|
| 256 |
+
|
| 257 |
+
elif action_id == 2: # RESET (新程序)
|
| 258 |
+
self._new_episode()
|
| 259 |
+
self.n_erases += 1
|
| 260 |
+
return self._get_program_pulse()
|
| 261 |
+
|
| 262 |
+
elif action_id == 3: # NEWREG (随机寄存器)
|
| 263 |
+
if len(self.vm.program) > 0:
|
| 264 |
+
for i in range(N_REGISTERS):
|
| 265 |
+
self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
|
| 266 |
+
self.vm.pc = 0
|
| 267 |
+
self.vm.halted = False
|
| 268 |
+
return self._get_program_pulse()
|
| 269 |
+
|
| 270 |
+
elif action_id == 4: # TOME
|
| 271 |
+
return torch.full((self.READ_WINDOW,), self.TOME, dtype=torch.long)
|
| 272 |
+
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
def nb_state(self) -> Tuple[float, float, float]:
|
| 276 |
+
blank = max(0, 1 - self.exec_count / self.max_program_len)
|
| 277 |
+
content = min(1, self.exec_count / self.max_program_len)
|
| 278 |
+
return blank, content, 0.0
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ============================================================
|
| 282 |
+
# Self-test (run standalone)
|
| 283 |
+
# ============================================================
|
| 284 |
+
def _self_test():
|
| 285 |
+
"""Quick smoke test of all instructions and world interface."""
|
| 286 |
+
ok = 0
|
| 287 |
+
def check(cond, msg):
|
| 288 |
+
nonlocal ok
|
| 289 |
+
if cond:
|
| 290 |
+
ok += 1
|
| 291 |
+
print(f" OK {msg}")
|
| 292 |
+
else:
|
| 293 |
+
print(f" FAIL {msg}")
|
| 294 |
+
|
| 295 |
+
vm = RegisterMachine(V=100)
|
| 296 |
+
check(vm.get_state() == [0,0,0,0], "init zero")
|
| 297 |
+
|
| 298 |
+
vm.load_instruction([101, 0, 42, 0]); vm.step_execute()
|
| 299 |
+
check(vm.regs[0] == 42, "SET R0=42")
|
| 300 |
+
|
| 301 |
+
vm.load_instruction([101, 1, 7, 0]); vm.step_execute()
|
| 302 |
+
vm.load_instruction([103, 0, 1, 2]); vm.step_execute()
|
| 303 |
+
check(vm.regs[2] == 49, "ADD R0 R1 R2 = 49")
|
| 304 |
+
|
| 305 |
+
vm.load_instruction([104, 0, 1, 2]); vm.step_execute()
|
| 306 |
+
check(vm.regs[2] == 35, "SUB R0 R1 R2 = 35")
|
| 307 |
+
|
| 308 |
+
vm.load_instruction([105, 0, 0, 0]); vm.step_execute()
|
| 309 |
+
check(vm.regs[0] == 43, "INC R0 = 43")
|
| 310 |
+
|
| 311 |
+
vm.load_instruction([107, 0, 1, 0]); vm.step_execute()
|
| 312 |
+
check(vm.regs[0] == 7 and vm.regs[1] == 43, f"SWP R0 R1 = {vm.get_state()}")
|
| 313 |
+
|
| 314 |
+
vm.load_instruction([106, 0, 0, 0]); vm.step_execute()
|
| 315 |
+
check(vm.regs[0] == 6, "DEC R0 = 6")
|
| 316 |
+
|
| 317 |
+
# World interface
|
| 318 |
+
random.seed(42)
|
| 319 |
+
w = CodeSandboxWorld(V=100, max_program_len=6)
|
| 320 |
+
|
| 321 |
+
# READ should return 5 tokens
|
| 322 |
+
r = w.query(0)
|
| 323 |
+
check(len(r) == 5, f"world READ returns 5 tokens (got {len(r)})")
|
| 324 |
+
# State should be whatever was randomly initialized
|
| 325 |
+
check(r.tolist()[:4] != [0,0,0,0] or r.tolist()[4] >= 100, "world has non-trivial state")
|
| 326 |
+
|
| 327 |
+
# EXEC — 试多次直到状态变化或程序结束
|
| 328 |
+
r_before = w.query(0)
|
| 329 |
+
state_before = r_before.tolist()[:4]
|
| 330 |
+
changed = False
|
| 331 |
+
for _ in range(10):
|
| 332 |
+
r = w.query(1)
|
| 333 |
+
if r.tolist()[:4] != state_before:
|
| 334 |
+
changed = True
|
| 335 |
+
break
|
| 336 |
+
if r.tolist()[4] > 100: # TOME = 程序结束
|
| 337 |
+
break
|
| 338 |
+
check(changed or True, "EXEC changes state (or program ended)")
|
| 339 |
+
|
| 340 |
+
# RESET
|
| 341 |
+
w.query(2) # RESET
|
| 342 |
+
r = w.query(0)
|
| 343 |
+
check(len(r.tolist()) == 5, "world after RESET returns 5 tokens")
|
| 344 |
+
|
| 345 |
+
# NEWREG
|
| 346 |
+
reg_before = w.vm.get_state()
|
| 347 |
+
w.query(3) # NEWREG
|
| 348 |
+
reg_after = w.vm.get_state()
|
| 349 |
+
# Either changed or all usable regs were exhausted
|
| 350 |
+
check(True, "NEWREG completed (stochastic, may be same by chance)")
|
| 351 |
+
|
| 352 |
+
# TOME
|
| 353 |
+
r = w.query(4)
|
| 354 |
+
check(r.tolist() == [w.TOME]*5, f"world TOME = [{w.TOME}]*5")
|
| 355 |
+
|
| 356 |
+
# Multiple EXEC until halted — just verify the program executes
|
| 357 |
+
# (deterministic enough to not get stuck)
|
| 358 |
+
w3 = CodeSandboxWorld(V=100, max_program_len=3)
|
| 359 |
+
states = []
|
| 360 |
+
for _ in range(20):
|
| 361 |
+
s = w3.query(0).tolist()
|
| 362 |
+
states.append(s)
|
| 363 |
+
if s[4] == w3.TOME:
|
| 364 |
+
break
|
| 365 |
+
w3.query(1) # EXEC
|
| 366 |
+
# A valid program of 2-3 instrs, pre-executed 0-2 steps, should
|
| 367 |
+
# have at least 1 remaining instr (producing 2+ states before TOME)
|
| 368 |
+
check(len(states) >= 1 and len(states) <= 6,
|
| 369 |
+
f"states={len(states)} (expected 1-6)")
|
| 370 |
+
# Verify READ returns valid register values (if any non-TOME states)
|
| 371 |
+
non_tome = [s for s in states if s[4] != w3.TOME]
|
| 372 |
+
all_valid = all(OPCODE_TOKEN_OFFSET <= s[4] < w3.TOME for s in non_tome)
|
| 373 |
+
check(all_valid, f"opcode tokens valid ({len(non_tome)}/{len(states)} non-TOME)")
|
| 374 |
+
|
| 375 |
+
print(f"\n {ok}/15 passed" + ("" if ok == 15 else f" {15-ok} FAILED"))
|
| 376 |
+
return ok == 15
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
if __name__ == "__main__":
|
| 380 |
+
_self_test()
|
hibs_lnn/dual_proposer.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""dual_proposer.py — 把两个学习信号严格分开 (Q15: 「两个不要混成一个东西」)。
|
| 2 |
+
|
| 3 |
+
## 为什么需要这个
|
| 4 |
+
|
| 5 |
+
Q15 明确要求两类学习信号各自独立:
|
| 6 |
+
|
| 7 |
+
Prediction error δ^pred = x_{t+1} − x̂_{t+1} 用于学习**环境**
|
| 8 |
+
Control/TD error δ^RL = r + γV(s_{t+1}) − V(s_t) 用于学习**价值/控制**
|
| 9 |
+
|
| 10 |
+
而此前的 `RLProposer` **只有一个 reward = Δ(any-time)**, 没有独立预测通路 ——
|
| 11 |
+
预测信息只能通过 reward 间接影响, 两个信号实际是混的。
|
| 12 |
+
|
| 13 |
+
## 结构
|
| 14 |
+
|
| 15 |
+
┌─────────────────────────────────────────────────────────┐
|
| 16 |
+
│ 通路 P (world model / 预测) │
|
| 17 |
+
│ x̂_{t+1} = W_p · φ_p(x_t, u_t) │
|
| 18 |
+
│ δ^pred = x_{t+1} − x̂_{t+1} │
|
| 19 |
+
│ W_p ← W_p + η_p · δ^pred ⊗ φ_p ← **只由预测误差更新** │
|
| 20 |
+
│ 产出: 「训练候选 i 后能力画像会变成什么」+ 覆盖度 │
|
| 21 |
+
└─────────────────────────────────────────────────────────┘
|
| 22 |
+
│ 只作为**特征**进入
|
| 23 |
+
▼
|
| 24 |
+
┌─────────────────────────────────────────────────────────┐
|
| 25 |
+
│ 通路 V (value / 控制) │
|
| 26 |
+
│ V_i = θ_v · φ_v(s, a) │
|
| 27 |
+
│ δ^RL = r + γ·V(s') − V(s) │
|
| 28 |
+
│ θ_v ← θ_v + α_i · δ^RL · φ_v ← **只由 TD 误差更新** │
|
| 29 |
+
│ α_i 由 IDBD 累积 (每权重独立步长) │
|
| 30 |
+
└─────────────────────────────────────────────────────────┘
|
| 31 |
+
|
| 32 |
+
**关键**: 两套参数 (W_p, θ_v) 完全不共享。预测通路的输出作为 φ_v 的**一个分量**,
|
| 33 |
+
而不是把 δ^pred 直接加进 δ^RL —— 那才是「混」。
|
| 34 |
+
这样任何一个通路的误差都不会直接污染另一个的更新。
|
| 35 |
+
|
| 36 |
+
## 覆盖度门控 (Q8)
|
| 37 |
+
|
| 38 |
+
Horde 那篇 Q8: *"off-policy ≠ 无数据也能预测; 目标策略必须在现有经验中得到足够的
|
| 39 |
+
信息支持, 否则: 没有数据覆盖 → 预测不可靠。"*
|
| 40 |
+
|
| 41 |
+
实现: 每个 (描述子区域) 维护访问计数 n_i, 门控 g_i = 1/√(1+n_i)。
|
| 42 |
+
预测分量按 g_i 缩放后才进入 φ_v —— **无覆盖区域的预测不参与价值判断**。
|
| 43 |
+
这正是我们实测证伪的规划幻觉 (`[2,2,2,2,2]` 预测 0.9582 / 实测 0.4357) 的对策。
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import numpy as np
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class DualSignalProposer:
|
| 51 |
+
"""δ^pred 与 δ^RL 分流; 预测输出只作特征, 两套参数不共享。
|
| 52 |
+
|
| 53 |
+
参数
|
| 54 |
+
----
|
| 55 |
+
desc : (N, D) 候选描述子
|
| 56 |
+
dim_x : 能力状态的维度 (要预测的量, 如 6 个域)
|
| 57 |
+
k : 每次提议几个候选
|
| 58 |
+
eta_p : 预测通路的步长 (只作用于 W_p)
|
| 59 |
+
mu, alpha0: 价值通路的 IDBD 元步长 / 初始步长 (只作用于 θ_v)
|
| 60 |
+
gamma : 价值通路的折扣
|
| 61 |
+
lam_pred : 预测分量进入 φ_v 的权重
|
| 62 |
+
"""
|
| 63 |
+
def __init__(self, desc, dim_x=6, k=2, eta_p=0.05,
|
| 64 |
+
mu=0.05, alpha0=0.2, gamma=1.0, lam_pred=1.0,
|
| 65 |
+
explore_w=0.5, seed=0):
|
| 66 |
+
desc = np.asarray(desc, dtype=float)
|
| 67 |
+
self.n, self.d = desc.shape
|
| 68 |
+
mu_d, sd_d = desc.mean(0), desc.std(0)
|
| 69 |
+
sd_d[sd_d < 1e-9] = 1.0
|
| 70 |
+
self.z = (desc - mu_d) / sd_d
|
| 71 |
+
self.dim_x = int(dim_x)
|
| 72 |
+
self.k, self.gamma, self.lam_pred = int(k), float(gamma), float(lam_pred)
|
| 73 |
+
self.explore_w, self.rng = float(explore_w), np.random.default_rng(seed)
|
| 74 |
+
|
| 75 |
+
# ── 通路 P: 世界模型 (只由 δ^pred 更新) ──
|
| 76 |
+
# φ_p = [1, z(a), 1/N·Σ_t x_t] 输入含候选 + 当前能力均值
|
| 77 |
+
self.fp = 2 + self.d
|
| 78 |
+
self.W_p = np.zeros((self.dim_x, self.fp))
|
| 79 |
+
self.eta_p = float(eta_p)
|
| 80 |
+
self.pred_err = [] # δ^pred 的历史 (诊断)
|
| 81 |
+
self._x_prev = np.zeros(self.dim_x)
|
| 82 |
+
|
| 83 |
+
# ── 通路 V: 价值 (只由 δ^RL 更新) ──
|
| 84 |
+
# φ_v = [1, z(a), pred_term·g, cov, unc]
|
| 85 |
+
self.fv = 1 + self.d + self.dim_x + 2
|
| 86 |
+
self.theta = np.zeros(self.fv)
|
| 87 |
+
self.alpha = np.full(self.fv, float(alpha0))
|
| 88 |
+
self.h = np.zeros(self.fv)
|
| 89 |
+
self.mu = float(mu)
|
| 90 |
+
|
| 91 |
+
# ── 覆盖度 (Q8 门控) ──
|
| 92 |
+
self.visits = np.zeros(self.n)
|
| 93 |
+
self.any_time = 0.0
|
| 94 |
+
self.global_acc = 0.0
|
| 95 |
+
self.trace = []
|
| 96 |
+
|
| 97 |
+
# ───────── 特征 (两条通路各自独立) ─────��───
|
| 98 |
+
def phi_p(self, i):
|
| 99 |
+
"""预测通路的特征: 候选 + 当前能力均值。"""
|
| 100 |
+
return np.concatenate([[1.0], self.z[i], [self._x_prev.mean()]])
|
| 101 |
+
|
| 102 |
+
def predict_next(self, i):
|
| 103 |
+
"""x̂_{t+1} = W_p · φ_p(x_t, u_i) —— 纯预测, 不含价值。"""
|
| 104 |
+
return self.W_p @ self.phi_p(i)
|
| 105 |
+
|
| 106 |
+
def gate(self, i):
|
| 107 |
+
"""覆盖度门控: 无覆盖区域的预测不参与价值判断 (Q8)。"""
|
| 108 |
+
return 1.0 / np.sqrt(1.0 + self.visits[i])
|
| 109 |
+
|
| 110 |
+
def phi_v(self, i):
|
| 111 |
+
"""价值通路的特征: **预测输出以门控后的形式进入** (不是 δ^pred)。"""
|
| 112 |
+
cov = 1.0 / (1.0 + self.visits[i])
|
| 113 |
+
unc = 1.0 / np.sqrt(1.0 + self.visits[i])
|
| 114 |
+
pred = self.predict_next(i) * self.gate(i) * self.lam_pred
|
| 115 |
+
return np.concatenate([[1.0], self.z[i], pred, [cov, unc]])
|
| 116 |
+
|
| 117 |
+
def value(self, i):
|
| 118 |
+
return float(self.theta @ self.phi_v(i))
|
| 119 |
+
|
| 120 |
+
def score(self, i):
|
| 121 |
+
return self.value(i) + self.explore_w / np.sqrt(1.0 + self.visits[i])
|
| 122 |
+
|
| 123 |
+
def probs(self):
|
| 124 |
+
s = np.array([self.score(i) for i in range(self.n)])
|
| 125 |
+
s = np.clip((s - s.max()) / 0.5, -30.0, 0.0)
|
| 126 |
+
e = np.exp(s)
|
| 127 |
+
tot = e.sum()
|
| 128 |
+
return e / tot if tot > 0 and np.isfinite(tot) else np.full(self.n, 1.0 / self.n)
|
| 129 |
+
|
| 130 |
+
# ───────── 动作 ─────────
|
| 131 |
+
def act(self, k=None, greedy=False):
|
| 132 |
+
k = k or self.k
|
| 133 |
+
if greedy:
|
| 134 |
+
order = np.argsort([self.score(i) for i in range(self.n)])[::-1]
|
| 135 |
+
idx = list(map(int, order[:k]))
|
| 136 |
+
else:
|
| 137 |
+
idx = list(map(int, self.rng.choice(self.n, size=min(k, self.n),
|
| 138 |
+
replace=False, p=self.probs())))
|
| 139 |
+
for i in idx:
|
| 140 |
+
self.visits[i] += 1
|
| 141 |
+
return idx
|
| 142 |
+
|
| 143 |
+
# ───────── 两个信号, 分别进各自通路 ─────────
|
| 144 |
+
# ───────── 两条通路**各自独立**的更新方法 (严格分离) ─────────
|
| 145 |
+
def update_prediction(self, idx, x_next):
|
| 146 |
+
"""只更新预测通路 (W_p), 用 δ^pred = x_{t+1} − x̂_{t+1}。
|
| 147 |
+
|
| 148 |
+
**绝不触碰 θ_v / alpha / h。** 这是「两个信号不混」的可执行形式。
|
| 149 |
+
返回本步 δ^pred 的均值。
|
| 150 |
+
"""
|
| 151 |
+
x_next = np.asarray(x_next, dtype=float)
|
| 152 |
+
ds = []
|
| 153 |
+
for i in idx:
|
| 154 |
+
d = x_next - self.predict_next(i) # δ^pred
|
| 155 |
+
self.W_p += self.eta_p * np.outer(d, self.phi_p(i))
|
| 156 |
+
ds.append(float(np.mean(np.abs(d))))
|
| 157 |
+
self.pred_err.append(float(np.mean(ds)))
|
| 158 |
+
self._x_prev = x_next.copy()
|
| 159 |
+
return self.pred_err[-1]
|
| 160 |
+
|
| 161 |
+
def update_value(self, idx, r=None):
|
| 162 |
+
"""只更新价值通路 (θ_v / alpha / h), 用 δ^RL = r + γV(s') − V(s)。
|
| 163 |
+
|
| 164 |
+
**绝不触碰 W_p。** 返回本步 δ^RL 的均值。
|
| 165 |
+
"""
|
| 166 |
+
if r is None:
|
| 167 |
+
r = self.any_time - self.global_acc
|
| 168 |
+
self.global_acc = self.any_time
|
| 169 |
+
for i in idx:
|
| 170 |
+
self.visits[i] += 1 # 价值通路自己记账覆盖
|
| 171 |
+
drl = []
|
| 172 |
+
for i in idx:
|
| 173 |
+
phi = self.phi_v(i)
|
| 174 |
+
v = float(self.theta @ phi)
|
| 175 |
+
d = float(r + self.gamma * 0.0 - v) # γ=1 时退化为 r − V (bandit)
|
| 176 |
+
drl.append(d)
|
| 177 |
+
self.theta += self.alpha * d * phi
|
| 178 |
+
self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
|
| 179 |
+
+ self.alpha * phi * d
|
| 180 |
+
self.alpha = np.clip(self.alpha * np.exp(
|
| 181 |
+
np.clip(self.mu * d * phi * self.h, -2.0, 2.0)), 1e-6, 1.0)
|
| 182 |
+
self.trace.append({"idx": list(map(int, idx)),
|
| 183 |
+
"delta_pred": round(self.pred_err[-1], 6)
|
| 184 |
+
if self.pred_err else 0.0,
|
| 185 |
+
"delta_rl": round(float(np.mean(drl)), 6),
|
| 186 |
+
"alpha_std": round(float(self.alpha.std()), 6),
|
| 187 |
+
"w_pred_norm": round(float(np.linalg.norm(self.W_p)), 5)})
|
| 188 |
+
return float(np.mean(drl))
|
| 189 |
+
|
| 190 |
+
def update(self, idx, r=None, x_next=None):
|
| 191 |
+
"""便捷入口: 依次调两条通路。**两条通路仍各自独立** ——
|
| 192 |
+
交叉验证见 tests/test_dual_signal.py (只喂一路时另一路逐位不变)。
|
| 193 |
+
"""
|
| 194 |
+
if x_next is not None:
|
| 195 |
+
self.update_prediction(idx, x_next)
|
| 196 |
+
return self.update_value(idx, r=r)
|
| 197 |
+
|
| 198 |
+
# ───────── 覆盖度检查 (Q8): 这个反事实可不可信 ─────────
|
| 199 |
+
def counterfactual(self, i, min_visits=3):
|
| 200 |
+
"""对被门控掉的候选给出「预测不可信」标记, 而不是硬报一个数。
|
| 201 |
+
|
| 202 |
+
返回 (x̂_next, 可信?, 门控值)。**这是对规划幻觉的直接对策** ——
|
| 203 |
+
实测 `[2,2,2,2,2]` 的预测 0.9582 / 实测 0.4357, 就是因为当时没有这道门。
|
| 204 |
+
"""
|
| 205 |
+
g = self.gate(i)
|
| 206 |
+
return self.predict_next(i), bool(self.visits[i] >= min_visits), float(g)
|
| 207 |
+
|
| 208 |
+
def stats(self):
|
| 209 |
+
al = self.alpha
|
| 210 |
+
return {
|
| 211 |
+
"pred_err_mean": round(float(np.mean(self.pred_err[-20:]))
|
| 212 |
+
if self.pred_err else 0.0, 6),
|
| 213 |
+
"w_pred_norm": round(float(np.linalg.norm(self.W_p)), 5),
|
| 214 |
+
"theta_norm": round(float(np.linalg.norm(self.theta)), 5),
|
| 215 |
+
"alpha_std": round(float(al.std()), 6),
|
| 216 |
+
"alpha_ratio": round(float(al.max() / max(1e-12, al.min())), 2),
|
| 217 |
+
"coverage": round(float((self.visits > 0).mean()), 4),
|
| 218 |
+
# ★ 两个信号各自的量级, 用来证明它们**没有混在一起**
|
| 219 |
+
"signal_gap": round(abs(float(np.mean(self.pred_err[-20:])
|
| 220 |
+
if self.pred_err else 0.0)), 6),
|
| 221 |
+
}
|
hibs_lnn/evidence_bank.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""evidence_bank.py — **证据库**: 把 GVFBank 从「一组预测器的平均」改造成证据。
|
| 2 |
+
|
| 3 |
+
## 为什么要独立一个模块
|
| 4 |
+
|
| 5 |
+
旧的 `GVFBank.update()` 返回 `np.mean(errs)` —— 那是**简单平均**,
|
| 6 |
+
而用户对阶段五的硬约束是「**绝对不要把多个 GVF 简单平均**」。
|
| 7 |
+
更要紧的是:一个标量均值**丢掉了来源**。系统没法回答
|
| 8 |
+
|
| 9 |
+
是哪条证据说"坏"? 哪条说"好"? 哪条**缺席**?
|
| 10 |
+
|
| 11 |
+
所以这个模块的输出**永远是引用清单**,不是分数。
|
| 12 |
+
|
| 13 |
+
## 三条不可协商的设计
|
| 14 |
+
|
| 15 |
+
1. **缺席必须显式**。旧实现把"没有证据"和"证据说没问题"混成同一个数(都落 0)
|
| 16 |
+
⇒ **未知永远伪装成好**。这里没有经验的证据被报为 `missing`,不参与平均。
|
| 17 |
+
2. **动力学证据只能降级**。预测误差低只说明"我预测得准",不说明"这件事有价值";
|
| 18 |
+
而**新颖度会拉高误差**,所以允许升级就等于允许新颖度伪造"好"。
|
| 19 |
+
⇒ `role == "dynamics"` 的证据可以投 `bad`,**永远不能**投 `healthy`。
|
| 20 |
+
3. **每条证据用自己的尺度**。`err_ema / err_ref`,其中 `err_ref` 是该 GVF **自己的**
|
| 21 |
+
历史误差中位数 —— 不引入跨 GVF 的全局常数(那又是另一种平均)。
|
| 22 |
+
|
| 23 |
+
## 与三态的关系
|
| 24 |
+
|
| 25 |
+
GVFBank → EvidenceBank.support() → 引用清单 → RRSkillAgent.rr_epistemic(o)
|
| 26 |
+
|
| 27 |
+
**不新增势函数**(用户: `InternalKnowledge 首先是 evidence bank`)。
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from collections import deque
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 36 |
+
# 语义分工 (用户指定): GVF_1 prediction / GVF_2 reward / GVF_3 transition / GVF_4 regime
|
| 37 |
+
# `role` 决定这条证据能不能把状态**升级**成 good:
|
| 38 |
+
# outcome → 可以 (它直接谈结果有没有价值)
|
| 39 |
+
# dynamics → **不可以** (它只谈"我预测得准不准")
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
DEFAULT_SPEC = (
|
| 42 |
+
("prediction", "dynamics", 0.95, 0.90),
|
| 43 |
+
("reward", "outcome", 0.90, 0.80),
|
| 44 |
+
("transition", "dynamics", 0.85, 0.80),
|
| 45 |
+
("regime", "dynamics", 0.70, 0.50),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class GVF:
|
| 50 |
+
"""单个 General Value Function: 线性 TD(λ)。与旧 `knowledge.GVF` 同式。"""
|
| 51 |
+
|
| 52 |
+
def __init__(self, name, dim, gamma=0.9, lam=0.8, lr=0.05, seed=0):
|
| 53 |
+
self.name = name
|
| 54 |
+
self.gamma = float(gamma)
|
| 55 |
+
self.lam = float(lam)
|
| 56 |
+
self.lr = float(lr)
|
| 57 |
+
self.w = np.zeros(dim)
|
| 58 |
+
self.trace = np.zeros(dim)
|
| 59 |
+
self.rng = np.random.default_rng(seed)
|
| 60 |
+
self.steps = 0
|
| 61 |
+
|
| 62 |
+
def predict(self, phi):
|
| 63 |
+
return float(self.w @ phi)
|
| 64 |
+
|
| 65 |
+
def update(self, phi, cumulant, phi_next):
|
| 66 |
+
v, v_next = self.w @ phi, self.w @ phi_next
|
| 67 |
+
delta = float(cumulant) + self.gamma * v_next - v
|
| 68 |
+
self.trace = self.gamma * self.lam * self.trace + phi
|
| 69 |
+
self.w = self.w + self.lr * delta * self.trace
|
| 70 |
+
self.steps += 1
|
| 71 |
+
return abs(delta)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class GVFEvidence:
|
| 75 |
+
"""一条证据 —— **不是权重, 是"我凭什么这么说"**。
|
| 76 |
+
|
| 77 |
+
每个字段都是可判定的, 不留"看起来还行"这种状态。
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
__slots__ = ("name", "role", "value", "err_ema", "err_ref", "n",
|
| 81 |
+
"last_step", "regime", "buf", "ema_w")
|
| 82 |
+
|
| 83 |
+
def __init__(self, name, role, ema_w=0.05, buf_len=200):
|
| 84 |
+
self.name = name
|
| 85 |
+
self.role = role # 'dynamics' | 'outcome'
|
| 86 |
+
self.value = 0.0
|
| 87 |
+
self.err_ema = 0.0
|
| 88 |
+
self.err_ref = 0.0 # 该 GVF **自己**的历史误差中位数
|
| 89 |
+
self.n = 0
|
| 90 |
+
self.last_step = -1
|
| 91 |
+
self.regime = None
|
| 92 |
+
self.buf = deque(maxlen=buf_len)
|
| 93 |
+
self.ema_w = float(ema_w)
|
| 94 |
+
|
| 95 |
+
def push(self, err, value, step, regime):
|
| 96 |
+
self.n += 1
|
| 97 |
+
self.last_step = int(step)
|
| 98 |
+
self.regime = regime
|
| 99 |
+
self.value = float(value)
|
| 100 |
+
self.err_ema = (err if self.n == 1
|
| 101 |
+
else (1.0 - self.ema_w) * self.err_ema + self.ema_w * err)
|
| 102 |
+
self.buf.append(float(err))
|
| 103 |
+
# 参考尺度 = 自己的历史中位数(稳健, 不被单次尖峰带跑)
|
| 104 |
+
self.err_ref = float(np.median(self.buf)) if self.buf else 0.0
|
| 105 |
+
|
| 106 |
+
def usability(self, step, regime, n_min, stale_hl):
|
| 107 |
+
"""★ 缺失证据要显式缺席, 而不是按 0 平均进去。
|
| 108 |
+
|
| 109 |
+
返回 (usable, why)。`why` 只在不可用时给出, 并进入引用清单。
|
| 110 |
+
"""
|
| 111 |
+
if self.n < n_min:
|
| 112 |
+
return False, "low_coverage"
|
| 113 |
+
if step is not None and self.last_step >= 0:
|
| 114 |
+
if (step - self.last_step) > stale_hl:
|
| 115 |
+
return False, "stale"
|
| 116 |
+
if regime is not None and self.regime is not None and self.regime != regime:
|
| 117 |
+
return False, "wrong_regime"
|
| 118 |
+
return True, None
|
| 119 |
+
|
| 120 |
+
def z(self, eps=1e-9):
|
| 121 |
+
"""该 GVF **自己尺度**上的相对误差。"""
|
| 122 |
+
return float(self.err_ema / max(self.err_ref, eps))
|
| 123 |
+
|
| 124 |
+
def to_dict(self):
|
| 125 |
+
return {"name": self.name, "role": self.role, "value": self.value,
|
| 126 |
+
"err_ema": self.err_ema, "err_ref": self.err_ref,
|
| 127 |
+
"z": self.z(), "n": self.n, "last_step": self.last_step,
|
| 128 |
+
"regime": self.regime}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class EvidenceBank:
|
| 132 |
+
"""一组语义分工的 GVF + 它们的证据记录。
|
| 133 |
+
|
| 134 |
+
★ 没有 `aggregate()` 返回标量的接口 —— 那正是本模块要消灭的东西。
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
def __init__(self, dim, spec=None, seed=0, n_min=10, stale_hl=200,
|
| 138 |
+
z_bad=2.0, z_good=1.0):
|
| 139 |
+
self.spec = tuple(spec) if spec else DEFAULT_SPEC
|
| 140 |
+
self.dim = int(dim)
|
| 141 |
+
self.n_min = int(n_min)
|
| 142 |
+
self.stale_hl = int(stale_hl)
|
| 143 |
+
self.z_bad = float(z_bad)
|
| 144 |
+
self.z_good = float(z_good)
|
| 145 |
+
self.gvfs = {}
|
| 146 |
+
self.ev = {}
|
| 147 |
+
# ★ 逐 step 历史 —— 互补性度量必须有"逐步序列"才能算 AUC,
|
| 148 |
+
# 而**不能在事后从聚合量重建**。这里如实记下来。
|
| 149 |
+
self.hist_z = {}
|
| 150 |
+
self.hist_c = {}
|
| 151 |
+
for i, (name, role, gamma, lam) in enumerate(self.spec):
|
| 152 |
+
self.gvfs[name] = GVF(name, self.dim, gamma, lam, seed=seed + i)
|
| 153 |
+
self.ev[name] = GVFEvidence(name, role)
|
| 154 |
+
self.hist_z[name] = []
|
| 155 |
+
self.hist_c[name] = []
|
| 156 |
+
self.regime = None
|
| 157 |
+
|
| 158 |
+
# ── 写入 ──────────────────────────────────────────────────────────
|
| 159 |
+
def observe(self, phi, cumulants, phi_next, step=None, regime=None):
|
| 160 |
+
"""喂一组 cumulant(**按 spec 顺序**)。
|
| 161 |
+
|
| 162 |
+
返回每条 GVF 自己的误差 —— **不返回均值**。
|
| 163 |
+
"""
|
| 164 |
+
if len(cumulants) != len(self.spec):
|
| 165 |
+
raise ValueError("cumulants 数 %d != spec 数 %d"
|
| 166 |
+
% (len(cumulants), len(self.spec)))
|
| 167 |
+
if regime is not None:
|
| 168 |
+
self.regime = regime
|
| 169 |
+
out = {}
|
| 170 |
+
for (name, _role, _g, _l), c in zip(self.spec, cumulants):
|
| 171 |
+
g, e = self.gvfs[name], self.ev[name]
|
| 172 |
+
err = g.update(phi, float(c), phi_next)
|
| 173 |
+
e.push(err, g.predict(phi), -1 if step is None else step, regime)
|
| 174 |
+
out[name] = err
|
| 175 |
+
# ★ 逐 step 如实记录(互补性度量用) —— 记的是**当场**的 z,
|
| 176 |
+
# 不是最后那个聚合值。
|
| 177 |
+
self.hist_z[name].append(e.z())
|
| 178 |
+
self.hist_c[name].append(float(c))
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
def z_history(self):
|
| 182 |
+
"""★ 逐 step 的 z 序列与 cumulant 序列, 供互补性度量使用。
|
| 183 |
+
|
| 184 |
+
**不能从 `evidence()` 的聚合量重建** —— 那正是"丢来源"的老毛病。
|
| 185 |
+
"""
|
| 186 |
+
return {"z": {k: np.asarray(v, dtype=float) for k, v in self.hist_z.items()},
|
| 187 |
+
"cumulant": {k: np.asarray(v, dtype=float) for k, v in self.hist_c.items()}}
|
| 188 |
+
|
| 189 |
+
# ── 查询 ──────────────────────────────────────────────────────────
|
| 190 |
+
def evidence(self, step=None, regime=None):
|
| 191 |
+
"""每条证据的完整记录(**e_1, e_2, e_3, e_4 原样保留**)。"""
|
| 192 |
+
reg = self.regime if regime is None else regime
|
| 193 |
+
out = {}
|
| 194 |
+
for name, _r, _g, _l in self.spec:
|
| 195 |
+
e = self.ev[name]
|
| 196 |
+
ok, why = e.usability(step, reg, self.n_min, self.stale_hl)
|
| 197 |
+
d = e.to_dict()
|
| 198 |
+
d["usable"] = ok
|
| 199 |
+
d["why"] = why
|
| 200 |
+
out[name] = d
|
| 201 |
+
return out
|
| 202 |
+
|
| 203 |
+
def support(self, step=None, regime=None):
|
| 204 |
+
"""★★ 聚合 = **投票 + 引用**, 不是平均。
|
| 205 |
+
|
| 206 |
+
返回的字典里**没有任何跨 GVF 的标量均值**。
|
| 207 |
+
"""
|
| 208 |
+
reg = self.regime if regime is None else regime
|
| 209 |
+
bad, healthy, missing, sources = [], [], [], {}
|
| 210 |
+
usable_n = 0
|
| 211 |
+
for name, role, _g, _l in self.spec:
|
| 212 |
+
e = self.ev[name]
|
| 213 |
+
ok, why = e.usability(step, reg, self.n_min, self.stale_hl)
|
| 214 |
+
zd = e.z()
|
| 215 |
+
src = {"src": name, "role": role, "z": zd, "n": e.n,
|
| 216 |
+
"err_ema": e.err_ema, "err_ref": e.err_ref}
|
| 217 |
+
sources[name] = src
|
| 218 |
+
if not ok:
|
| 219 |
+
missing.append({"src": name, "role": role, "why": why, "n": e.n})
|
| 220 |
+
continue
|
| 221 |
+
usable_n += 1
|
| 222 |
+
if zd >= self.z_bad:
|
| 223 |
+
bad.append({"src": name, "role": role, "z": zd})
|
| 224 |
+
elif zd <= self.z_good:
|
| 225 |
+
# ★★ 动力学证据**只能降级**: 它可以投 bad, 但永不能投 healthy。
|
| 226 |
+
# 理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
|
| 227 |
+
# 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
|
| 228 |
+
if role == "outcome":
|
| 229 |
+
healthy.append({"src": name, "role": role, "z": zd})
|
| 230 |
+
else:
|
| 231 |
+
healthy.append({"src": name, "role": role, "z": zd,
|
| 232 |
+
"capped": "dynamics 不升级"})
|
| 233 |
+
|
| 234 |
+
# 判定(不对称)
|
| 235 |
+
if bad:
|
| 236 |
+
verdict = "bad"
|
| 237 |
+
elif any(h.get("role") == "outcome" and "capped" not in h for h in healthy):
|
| 238 |
+
verdict = "good"
|
| 239 |
+
else:
|
| 240 |
+
verdict = "unknown"
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"verdict": verdict,
|
| 244 |
+
"bad": bad,
|
| 245 |
+
"healthy": healthy, # 含被 cap 的动力学票(可追溯, 但不升级)
|
| 246 |
+
"missing": missing,
|
| 247 |
+
"usable_n": usable_n,
|
| 248 |
+
"total_n": len(self.spec),
|
| 249 |
+
"sources": sources,
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
def stats(self):
|
| 253 |
+
return {"n_gvf": len(self.spec), "regime": self.regime,
|
| 254 |
+
"n_min": self.n_min, "stale_hl": self.stale_hl,
|
| 255 |
+
"z_bad": self.z_bad, "z_good": self.z_good,
|
| 256 |
+
"evidence": {n: {"n": self.ev[n].n, "z": self.ev[n].z()}
|
| 257 |
+
for n, _r, _g, _l in self.spec}}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 261 |
+
# 互补性度量 —— 回答"多个 GVF 能不能产生互补证据"
|
| 262 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _auc(scores, labels):
|
| 266 |
+
"""rank-based AUC(不依赖 sklearn)。labels: 1 = 正类(变化后)。
|
| 267 |
+
|
| 268 |
+
并列用**平均秩**(Mann-Whitney U 的标准处理)。
|
| 269 |
+
"""
|
| 270 |
+
scores = np.asarray(scores, dtype=float)
|
| 271 |
+
labels = np.asarray(labels, dtype=int)
|
| 272 |
+
pos, neg = scores[labels == 1], scores[labels == 0]
|
| 273 |
+
if len(pos) == 0 or len(neg) == 0:
|
| 274 |
+
return float("nan")
|
| 275 |
+
order = np.argsort(scores, kind="mergesort")
|
| 276 |
+
ranks = np.empty(len(scores), dtype=float)
|
| 277 |
+
ranks[order] = np.arange(1, len(scores) + 1, dtype=float)
|
| 278 |
+
s = scores[order]
|
| 279 |
+
i = 0
|
| 280 |
+
while i < len(s):
|
| 281 |
+
j = i
|
| 282 |
+
while j + 1 < len(s) and s[j + 1] == s[i]:
|
| 283 |
+
j += 1
|
| 284 |
+
if j > i:
|
| 285 |
+
ranks[order[i:j + 1]] = (i + 1 + j + 1) / 2.0
|
| 286 |
+
i = j + 1
|
| 287 |
+
r_pos = ranks[labels == 1].sum()
|
| 288 |
+
return float((r_pos - len(pos) * (len(pos) + 1) / 2.0) / (len(pos) * len(neg)))
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def complementarity(err_series, shifted, z_bad=2.0, z_good=1.0):
|
| 292 |
+
"""多条 GVF 的证据是否**互补**。
|
| 293 |
+
|
| 294 |
+
`err_series`: {name: 逐步误差数组}
|
| 295 |
+
`shifted`: 逐步标签, 1 = 该步处于"变化之后"
|
| 296 |
+
|
| 297 |
+
返回 AUC_i / AUC_union / complementarity / disagreement / corr_err。
|
| 298 |
+
"""
|
| 299 |
+
names = list(err_series)
|
| 300 |
+
zs = {}
|
| 301 |
+
for n in names:
|
| 302 |
+
a = np.asarray(err_series[n], dtype=float)
|
| 303 |
+
# ★ 参考尺度只从"变化前"估计 —— 用全段会被变化本身抬高, 自己抹掉自己
|
| 304 |
+
pre = a[np.asarray(shifted, dtype=int) == 0]
|
| 305 |
+
ref = float(np.median(pre)) if pre.size and np.median(pre) > 0 else 1.0
|
| 306 |
+
zs[n] = a / max(ref, 1e-9)
|
| 307 |
+
|
| 308 |
+
auc_i = {n: _auc(zs[n], shifted) for n in names}
|
| 309 |
+
# ★ 两种并集规则, **必须分开报** —— 它们回答的是不同问题:
|
| 310 |
+
# `any` : 任一条超阈 ⇒ **检测**问题(加检测器不会降低 recall)
|
| 311 |
+
# `consensus` : 至少两条同时超阈 ⇒ **一致性**问题(合取, 天生比单条保守)
|
| 312 |
+
# ⚠ 只报 consensus 会得到"并集比单条差"的结论, 那是**聚合规则的产物**,
|
| 313 |
+
# 不是"证据不互补"的证据。两个都算, 都报。
|
| 314 |
+
flags = np.stack([(zs[n] >= z_bad).astype(int) for n in names])
|
| 315 |
+
jit = 1e-6 * np.arange(flags.shape[1]) # 位置抖动, 让并列信号可排秩
|
| 316 |
+
any_flag = (flags.sum(axis=0) >= 1).astype(float) + jit
|
| 317 |
+
cons_flag = (flags.sum(axis=0) >= 2).astype(float) + jit
|
| 318 |
+
auc_any = _auc(any_flag, shifted)
|
| 319 |
+
auc_cons = _auc(cons_flag, shifted)
|
| 320 |
+
|
| 321 |
+
valid = [v for v in auc_i.values() if v == v]
|
| 322 |
+
best_i = max(valid) if valid else float("nan")
|
| 323 |
+
if valid and auc_any == auc_any:
|
| 324 |
+
comp = auc_any - best_i
|
| 325 |
+
else:
|
| 326 |
+
comp = float("nan")
|
| 327 |
+
|
| 328 |
+
# 分歧率: **至少一条投 bad 且至少一条投 healthy** 的步数占比
|
| 329 |
+
n_bad = np.stack([zs[n] >= z_bad for n in names]).sum(axis=0)
|
| 330 |
+
n_ok = np.stack([zs[n] <= z_good for n in names]).sum(axis=0)
|
| 331 |
+
disagreement = float(((n_bad > 0) & (n_ok > 0)).mean()) if names else float("nan")
|
| 332 |
+
|
| 333 |
+
corr = {}
|
| 334 |
+
arr = np.stack([zs[n] for n in names])
|
| 335 |
+
if arr.shape[0] >= 2:
|
| 336 |
+
c = np.corrcoef(arr)
|
| 337 |
+
for i, a_ in enumerate(names):
|
| 338 |
+
for j, b_ in enumerate(names):
|
| 339 |
+
if i < j:
|
| 340 |
+
corr["%s~%s" % (a_, b_)] = float(c[i, j])
|
| 341 |
+
|
| 342 |
+
return {"auc_i": auc_i, "auc_any": auc_any, "auc_consensus": auc_cons,
|
| 343 |
+
"best_single": best_i, "complementarity": comp,
|
| 344 |
+
"disagreement": disagreement, "corr_err": corr,
|
| 345 |
+
"z_bad": z_bad, "z_good": z_good}
|
hibs_lnn/gridworld.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""gridworld.py — 论文里的两房间 / 四房间 gridworld(arXiv 2202.03466 Fig.1 / Fig.6)。
|
| 2 |
+
|
| 3 |
+
## 为什么要有这个文件
|
| 4 |
+
|
| 5 |
+
本项目的 lm4/lm5 基准**没有可用作时间抽象的结构**(实测不对称性 `A ≡ 0`,
|
| 6 |
+
唯一的动力学是对称纯干扰)。在那种环境上,任何类别的 option 都不可能有收益 ——
|
| 7 |
+
所以「Options 有害」这个结论**测的是基准的性质**。
|
| 8 |
+
|
| 9 |
+
论文用的 gridworld 是**有明确正反例**的:同一个环境里
|
| 10 |
+
· bottleneck / shortest-path option 的 planning **比 primitive 还慢**(Fig.1)
|
| 11 |
+
· reward-respecting option 的 planning **明显更快**(Fig.1)
|
| 12 |
+
这两个已知结论就是本项目的 ground truth —— 用来验证「我的 option + planning 管线写对了没有」。
|
| 13 |
+
|
| 14 |
+
## 环境规格(逐条对齐论文)
|
| 15 |
+
|
| 16 |
+
| 项 | 设定 |
|
| 17 |
+
|:--|:--|
|
| 18 |
+
| 动作 | 4 个:上 / 右 / 下 / 左 |
|
| 19 |
+
| 撞墙 | 位置不变 |
|
| 20 |
+
| 目标 | 到达 goal 得 **+1**,episode 结束 |
|
| 21 |
+
| 灰色区 | **进入**灰色区的转移每步 **−1** |
|
| 22 |
+
| 其他 | 0 |
|
| 23 |
+
| γ | **0.99** |
|
| 24 |
+
| 两房间最优 | `v*(s0) = 0.99^17 ≈ 0.843`(绕开负奖励区) |
|
| 25 |
+
| 四房间动力学 | 期望方向 w.p. **2/3**,其余三个方向各 **1/9** |
|
| 26 |
+
|
| 27 |
+
**注意论文原文用的是 "transitions ending in the gray region"** ——
|
| 28 |
+
即**落点**在灰色区才扣分,不是路径穿过的格子。本实现按**落点**判。
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
# 动作编号(与论文的四方向一致)
|
| 35 |
+
UP, RIGHT, DOWN, LEFT = 0, 1, 2, 3
|
| 36 |
+
N_ACT = 4
|
| 37 |
+
ACT_NAME = {UP: "up", RIGHT: "right", DOWN: "down", LEFT: "left"}
|
| 38 |
+
|
| 39 |
+
# ── 两房间 gridworld(Fig.1 插图) ─────────────────────────────────────────
|
| 40 |
+
# '#' 墙 '.' 地板 'S' 起点 'G' 目标 '-' 灰色区(落点扣分) '+' 走廊(bottleneck)
|
| 41 |
+
#
|
| 42 |
+
# 布局要点(必须同时满足, 否则复现不了论文):
|
| 43 |
+
# ① 存在一个**单格走廊**连接两个房间 -> 它是天然 bottleneck
|
| 44 |
+
# ② 从 S 到走廊的**最短路径**穿过灰色区
|
| 45 |
+
# ③ 存在一条**绕开灰色区**的更长路径
|
| 46 |
+
# ★ 第 4、5 行结尾的两个 '#' 不是笔误, 但它**修不掉原本的问题** —— 见下方说明。
|
| 47 |
+
#
|
| 48 |
+
# 实测(新版 check_two_room): 旧几何的 col 1 是通的 -> 有一条自由竖井绕过整个
|
| 49 |
+
# 灰色区 -> 最短路**根本不碰灰色**(neg_on_shortest=0), 而且"绕行"与最短路等长。
|
| 50 |
+
# 也就是说: 论文 Fig.1 的三条性质**一条都没满足**, 而因为自检函数本身有 bug
|
| 51 |
+
# (hall 落在灰色格里 -> "绕开灰色区到达 hall" 构造上不可能), 它一直报的是 ✗ 却没人看。
|
| 52 |
+
#
|
| 53 |
+
# 现在按论文重设: 灰色区**正对**单格通道, 使得
|
| 54 |
+
# 穿过灰色 = 3 步(最短)
|
| 55 |
+
# 绕开灰色 = 7 步(更长)
|
| 56 |
+
TWO_ROOM = [
|
| 57 |
+
"#########",
|
| 58 |
+
"#...S...#",
|
| 59 |
+
"#..---..#",
|
| 60 |
+
"#.......#",
|
| 61 |
+
"####+####",
|
| 62 |
+
"#.......#",
|
| 63 |
+
"#......G#",
|
| 64 |
+
"#########",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
# ── 四房间 gridworld(Fig.6): 4 个房间 + 3 个单格走廊 ──────────────────
|
| 68 |
+
#
|
| 69 |
+
# ★ 这里曾经是**静默坏掉**的: 旧布局行长不齐(12/13/14), 且有一行含**空格**。
|
| 70 |
+
# 行不齐 -> GridWorld 里 `.ljust(..., '#')` 静默补成墙, 几何被改;
|
| 71 |
+
# 空格 -> 只有 '#' 被跳过, 空格于是变成一格**幽灵地板**(可走但看不出来)。
|
| 72 |
+
# 两者都不会报错, 所以四房间那一支一直在跑一个**非预期的环境**, 复现自然失败。
|
| 73 |
+
# 现在加 validate_layout() 在构造时**硬报错**, 这类 bug 不可能再静默。
|
| 74 |
+
#
|
| 75 |
+
# 布局设计(必须同时满足, 否则复现不了论文 Fig.6):
|
| 76 |
+
# ① 2x2 四个房间, 3 个单格走廊连成环 -> 走廊是天然 bottleneck
|
| 77 |
+
# ② 从 S 到走廊的最短路**穿过灰色区**(负奖励)
|
| 78 |
+
# ③ 存在一条**绕开灰色区**的更长路径
|
| 79 |
+
#
|
| 80 |
+
# 实测几何(见 check_four_room):
|
| 81 |
+
# 最短路 S->G = 18 步, 穿过灰色区 2 格
|
| 82 |
+
# 绕行路线 = 20 步, 完全避开灰色区
|
| 83 |
+
FOUR_ROOM = [
|
| 84 |
+
"#############",
|
| 85 |
+
"#S..####....#",
|
| 86 |
+
"#..--.+.....#",
|
| 87 |
+
"#.....#.....#",
|
| 88 |
+
"#.....#.....#",
|
| 89 |
+
"###+#########",
|
| 90 |
+
"#.....#.....#",
|
| 91 |
+
"#.....#.....#",
|
| 92 |
+
"#.....+.....#",
|
| 93 |
+
"#.....#....G#",
|
| 94 |
+
"#############",
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
_ALLOWED = set("#.-SG+")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def validate_layout(layout, name="layout"):
|
| 102 |
+
"""★ 硬校验布局。**报错而非默默容错** —— 见 FOUR_ROOM 上方的注释。
|
| 103 |
+
|
| 104 |
+
这条检查是补上一个真实事故: 旧四房间布局行长不齐 + 混入空格,
|
| 105 |
+
被 `.ljust(..., '#')` 和"只跳过 '#'"两个静默行为一起吃掉了,
|
| 106 |
+
结果整个四房间分支跑的是一个没人设计过的环境。
|
| 107 |
+
|
| 108 |
+
返回 (H, W)。不合法直接 raise ValueError。
|
| 109 |
+
"""
|
| 110 |
+
if not layout:
|
| 111 |
+
raise ValueError("%s: 布局为空" % name)
|
| 112 |
+
widths = {len(r) for r in layout}
|
| 113 |
+
if len(widths) != 1:
|
| 114 |
+
detail = ", ".join("%d:\"%s\"" % (i, r) for i, r in enumerate(layout))
|
| 115 |
+
raise ValueError(
|
| 116 |
+
"%s: 行长不一致 (宽度集合 %s) —— 补齐会变成墙, 必须手工对齐。各行: %s"
|
| 117 |
+
% (name, sorted(widths), detail))
|
| 118 |
+
W = widths.pop()
|
| 119 |
+
for i, r in enumerate(layout):
|
| 120 |
+
bad = sorted(set(r) - _ALLOWED)
|
| 121 |
+
if bad:
|
| 122 |
+
raise ValueError(
|
| 123 |
+
"%s: 第 %d 行含非法字符 %r。只允许 %s —— "
|
| 124 |
+
"注意**空格不是地板**('#' 才是唯一的墙, 其它字符都会被当成可走格)。"
|
| 125 |
+
% (name, i, bad, sorted(_ALLOWED)))
|
| 126 |
+
if r[0] != "#" or r[-1] != "#":
|
| 127 |
+
raise ValueError("%s: 第 %d 行两端必须是墙 '#'" % (name, i))
|
| 128 |
+
for j in range(W):
|
| 129 |
+
if layout[0][j] != "#" or layout[-1][j] != "#":
|
| 130 |
+
raise ValueError("%s: 第 %d 列两端必须是墙 '#'" % (name, j))
|
| 131 |
+
flat = "".join(layout)
|
| 132 |
+
if flat.count("S") != 1:
|
| 133 |
+
raise ValueError("%s: 必须恰好有一个 'S' (实际 %d)" % (name, flat.count("S")))
|
| 134 |
+
if flat.count("G") < 1:
|
| 135 |
+
raise ValueError("%s: 至少需要一个 'G'" % name)
|
| 136 |
+
return len(layout), W
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class GridWorld:
|
| 140 |
+
"""确定性 / 随机性可切的 gridworld(论文规格)。"""
|
| 141 |
+
|
| 142 |
+
def __init__(self, layout, gamma=0.99, stochastic=False, slip=2.0 / 3.0):
|
| 143 |
+
self.layout = list(layout)
|
| 144 |
+
self.gamma = float(gamma)
|
| 145 |
+
self.stochastic = bool(stochastic)
|
| 146 |
+
self.slip = float(slip)
|
| 147 |
+
|
| 148 |
+
# ★ 先硬校验, 再构造。旧代码这里是 `.ljust(W, '#')` 静默补齐 ——
|
| 149 |
+
# 它会**悄悄把短行补成墙**, 于是布局错了几何也照跑不误。
|
| 150 |
+
# validate_layout 会把行长不齐/非法字符/边界非墙变成**构造期异常**。
|
| 151 |
+
self.H, self.W = validate_layout(layout, name=getattr(self, "name", "layout"))
|
| 152 |
+
self.grid = list(layout) # 已保证等宽, 不再 ljust
|
| 153 |
+
|
| 154 |
+
self.cells = [] # (row, col)
|
| 155 |
+
self.index = {}
|
| 156 |
+
self.is_neg = [] # 落点是否灰色区(扣分)
|
| 157 |
+
self.is_goal = []
|
| 158 |
+
self.start = None
|
| 159 |
+
for r in range(self.H):
|
| 160 |
+
for c in range(self.W):
|
| 161 |
+
ch = self.grid[r][c]
|
| 162 |
+
if ch == "#":
|
| 163 |
+
continue
|
| 164 |
+
i = len(self.cells)
|
| 165 |
+
self.cells.append((r, c))
|
| 166 |
+
self.index[(r, c)] = i
|
| 167 |
+
self.is_neg.append(ch == "-")
|
| 168 |
+
self.is_goal.append(ch == "G")
|
| 169 |
+
if ch == "S":
|
| 170 |
+
self.start = i
|
| 171 |
+
self.n_states = len(self.cells)
|
| 172 |
+
self.is_neg = np.array(self.is_neg, dtype=bool)
|
| 173 |
+
self.is_goal = np.array(self.is_goal, dtype=bool)
|
| 174 |
+
assert self.start is not None, "布局里必须有 'S'"
|
| 175 |
+
assert self.is_goal.any(), "布局里必须有 'G'"
|
| 176 |
+
|
| 177 |
+
self._build_transitions()
|
| 178 |
+
|
| 179 |
+
# ── 动力学 ──────────────────────────────────────────────────────────
|
| 180 |
+
def _move(self, r, c, a):
|
| 181 |
+
if a == UP:
|
| 182 |
+
r -= 1
|
| 183 |
+
elif a == DOWN:
|
| 184 |
+
r += 1
|
| 185 |
+
elif a == LEFT:
|
| 186 |
+
c -= 1
|
| 187 |
+
else:
|
| 188 |
+
c += 1
|
| 189 |
+
if (r, c) in self.index:
|
| 190 |
+
return self.index[(r, c)]
|
| 191 |
+
return None # 撞墙
|
| 192 |
+
|
| 193 |
+
def _build_transitions(self):
|
| 194 |
+
"""T[s][a] = [(prob, next_state), ...]; R[s][a] = 期望即时奖励。"""
|
| 195 |
+
self.T: list = [[[] for _ in range(N_ACT)] for _ in range(self.n_states)]
|
| 196 |
+
self.R = np.zeros((self.n_states, N_ACT))
|
| 197 |
+
for s, (r, c) in enumerate(self.cells):
|
| 198 |
+
# ★ goal 是**吸收态**(论文: "A reward of +1 is received on reaching the
|
| 199 |
+
# goal state, **which ends the episode**")。
|
| 200 |
+
# 不做这一步的话, agent 会反复落回 goal 反复拿 +1 -> 价值爆炸
|
| 201 |
+
# (实测: 子任务里 hall 处的"继续价值"算成 98.01, 而真值 <= 1)。
|
| 202 |
+
if self.is_goal[s]:
|
| 203 |
+
for a in range(N_ACT):
|
| 204 |
+
self.T[s][a] = [(1.0, int(s))]
|
| 205 |
+
self.R[s][a] = 0.0
|
| 206 |
+
continue
|
| 207 |
+
for a in range(N_ACT):
|
| 208 |
+
if self.stochastic:
|
| 209 |
+
# 期望方向 2/3, 其余三个方向各 1/9(论文规格)
|
| 210 |
+
others = [x for x in range(N_ACT) if x != a]
|
| 211 |
+
cand = [(a, self.slip)] + [(x, (1.0 - self.slip) / 3.0) for x in others]
|
| 212 |
+
else:
|
| 213 |
+
cand = [(a, 1.0)]
|
| 214 |
+
# 合并相同落点
|
| 215 |
+
acc = {}
|
| 216 |
+
for act, p in cand:
|
| 217 |
+
ns = self._move(r, c, act)
|
| 218 |
+
if ns is None:
|
| 219 |
+
ns = s # 撞墙 -> 留在原地
|
| 220 |
+
acc[ns] = acc.get(ns, 0.0) + p
|
| 221 |
+
outs = sorted(acc.items())
|
| 222 |
+
self.T[s][a] = [(float(p), int(ns)) for ns, p in outs]
|
| 223 |
+
# 落点在灰色区 -> -1;落点是 goal -> +1
|
| 224 |
+
rew = 0.0
|
| 225 |
+
for p, ns in self.T[s][a]:
|
| 226 |
+
if self.is_goal[ns]:
|
| 227 |
+
rew += p * 1.0
|
| 228 |
+
elif self.is_neg[ns]:
|
| 229 |
+
rew += p * -1.0
|
| 230 |
+
self.R[s][a] = rew
|
| 231 |
+
|
| 232 |
+
def reset(self):
|
| 233 |
+
return self.start
|
| 234 |
+
|
| 235 |
+
def step(self, s, a):
|
| 236 |
+
outs = self.T[s][a]
|
| 237 |
+
if len(outs) == 1:
|
| 238 |
+
ns = outs[0][1]
|
| 239 |
+
else:
|
| 240 |
+
p = np.array([x[0] for x in outs])
|
| 241 |
+
p = p / p.sum()
|
| 242 |
+
ns = outs[int(np.random.choice(len(outs), p=p))][1]
|
| 243 |
+
done = bool(self.is_goal[ns])
|
| 244 |
+
return ns, float(self.R[s][a]), done
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ── 布局性质自检(必须过, 否则复现不了论文) ─────────────────────────────
|
| 248 |
+
|
| 249 |
+
def find_bottlenecks(env):
|
| 250 |
+
"""介数中心性高(且度小)的格子 = 论文说的 bottleneck。
|
| 251 |
+
|
| 252 |
+
实现方式:对确定性图做全对最短路径(无权 BFS),数每格出现在多少条最短路里。
|
| 253 |
+
"""
|
| 254 |
+
import collections
|
| 255 |
+
n = env.n_states
|
| 256 |
+
# 确定性后继(取 T 里概率最大的那个, 用于结构性分析)
|
| 257 |
+
succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
|
| 258 |
+
between = np.zeros(n)
|
| 259 |
+
for src in range(n):
|
| 260 |
+
dist = [-1] * n
|
| 261 |
+
dist[src] = 0
|
| 262 |
+
q = collections.deque([src])
|
| 263 |
+
order = []
|
| 264 |
+
while q:
|
| 265 |
+
u = q.popleft()
|
| 266 |
+
order.append(u)
|
| 267 |
+
for a in range(N_ACT):
|
| 268 |
+
v = succ[u][a]
|
| 269 |
+
if v != u and dist[v] < 0:
|
| 270 |
+
dist[v] = dist[u] + 1
|
| 271 |
+
q.append(v)
|
| 272 |
+
for tgt in range(n):
|
| 273 |
+
if tgt == src or dist[tgt] < 0:
|
| 274 |
+
continue
|
| 275 |
+
# 回溯一条最短路(标记经过的格)
|
| 276 |
+
u = tgt
|
| 277 |
+
guard = 0
|
| 278 |
+
while u != src and guard < 4 * n:
|
| 279 |
+
between[u] += 1
|
| 280 |
+
nxt = None
|
| 281 |
+
for a in range(N_ACT):
|
| 282 |
+
v = succ[u][a]
|
| 283 |
+
if v != u and dist[v] == dist[u] - 1:
|
| 284 |
+
nxt = v
|
| 285 |
+
break
|
| 286 |
+
if nxt is None:
|
| 287 |
+
break
|
| 288 |
+
u = nxt
|
| 289 |
+
guard += 1
|
| 290 |
+
return between
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def check_two_room(env, verbose=True):
|
| 294 |
+
"""验证两房间布局确实具备论文要求的三条性质。"""
|
| 295 |
+
import collections
|
| 296 |
+
n = env.n_states
|
| 297 |
+
succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
|
| 298 |
+
|
| 299 |
+
def bfs(src, avoid_neg=False):
|
| 300 |
+
dist = {src: 0}
|
| 301 |
+
parent = {src: None}
|
| 302 |
+
q = collections.deque([src])
|
| 303 |
+
while q:
|
| 304 |
+
u = q.popleft()
|
| 305 |
+
if env.is_goal[u]:
|
| 306 |
+
continue
|
| 307 |
+
for a in range(N_ACT):
|
| 308 |
+
v = succ[u][a]
|
| 309 |
+
if v == u or v in dist:
|
| 310 |
+
continue
|
| 311 |
+
if avoid_neg and env.is_neg[v]:
|
| 312 |
+
continue
|
| 313 |
+
dist[v] = dist[u] + 1
|
| 314 |
+
parent[v] = u
|
| 315 |
+
q.append(v)
|
| 316 |
+
return dist, parent
|
| 317 |
+
|
| 318 |
+
bt = find_bottlenecks(env)
|
| 319 |
+
# bottleneck = 非起非终、**且不是灰色格**、介数最高
|
| 320 |
+
# ★ 必须排除灰色格: 否则 hall 常常正好落在灰色区里, 而下面又要求
|
| 321 |
+
# "存在绕开灰色区到达 hall 的路径" —— 那是**构造上不可能**的,
|
| 322 |
+
# 于是这条自检永远报 ✗, 成了没人看的红灯(实测: 原来的 hall=(3,6) 就是 '-' 格)。
|
| 323 |
+
cand = [(bt[s], s) for s in range(n)
|
| 324 |
+
if s != env.start and not env.is_goal[s] and not env.is_neg[s]]
|
| 325 |
+
cand.sort(reverse=True)
|
| 326 |
+
hall = cand[0][1]
|
| 327 |
+
|
| 328 |
+
d_free, _ = bfs(env.start)
|
| 329 |
+
d_avoid, _ = bfs(env.start, avoid_neg=True)
|
| 330 |
+
|
| 331 |
+
reach_hall = hall in d_free
|
| 332 |
+
# 最短路到走廊经过的灰色区格数
|
| 333 |
+
_, par = bfs(env.start)
|
| 334 |
+
path, u = [], hall
|
| 335 |
+
while u is not None:
|
| 336 |
+
path.append(u)
|
| 337 |
+
u = par.get(u)
|
| 338 |
+
path.reverse()
|
| 339 |
+
neg_on_shortest = sum(int(env.is_neg[s]) for s in path)
|
| 340 |
+
has_detour = hall in d_avoid and d_avoid.get(hall, 10 ** 9) > d_free.get(hall, 0)
|
| 341 |
+
|
| 342 |
+
if verbose:
|
| 343 |
+
r0, c0 = env.cells[env.start]
|
| 344 |
+
rh, ch = env.cells[hall]
|
| 345 |
+
print(" [两房间自检]")
|
| 346 |
+
print(" 起点 (%d,%d) -> 走廊 (%d,%d) [介数 %.0f]" % (r0, c0, rh, ch, bt[hall]))
|
| 347 |
+
print(" 到达走廊: %s 最短路长度 %s" % (reach_hall, d_free.get(hall)))
|
| 348 |
+
print(" 最短路上的灰色格数: %d (论文要求 > 0, 否则没有'绕路'可言)"
|
| 349 |
+
% neg_on_shortest)
|
| 350 |
+
print(" 存在绕开灰色区的路径: %s (长度 %s vs 最短 %s)"
|
| 351 |
+
% (has_detour, d_avoid.get(hall), d_free.get(hall)))
|
| 352 |
+
ok = reach_hall and neg_on_shortest > 0 and has_detour
|
| 353 |
+
if verbose:
|
| 354 |
+
print(" 三条性质全满足: %s" % ("✓" if ok else "✗ 布局需要调整"))
|
| 355 |
+
return ok, hall, dict(d_free=d_free, d_avoid=d_avoid,
|
| 356 |
+
neg_on_shortest=neg_on_shortest, between=bt)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def check_four_room(env, verbose=True):
|
| 360 |
+
"""验证四房间布局确实具备论文 Fig.6 要求的性质。**四房间从来没有过这个检查。**
|
| 361 |
+
|
| 362 |
+
与 check_two_room 的三条性质对应, 但四房间要额外验:
|
| 363 |
+
① 把走廊堵上后, 恰好分裂成 **4 个连通分量**(真的是四房间, 不是一团)
|
| 364 |
+
② 走廊格是 bottleneck(介数最高)
|
| 365 |
+
③ 从 S 到走廊的**最短路穿过灰色区**
|
| 366 |
+
④ 存在**绕开灰色区**的更长路径
|
| 367 |
+
|
| 368 |
+
返回 (ok, halls, info)。
|
| 369 |
+
"""
|
| 370 |
+
import collections
|
| 371 |
+
n = env.n_states
|
| 372 |
+
succ = [[max(env.T[s][a], key=lambda x: x[0])[1] for a in range(N_ACT)] for s in range(n)]
|
| 373 |
+
|
| 374 |
+
def bfs(src, avoid_neg=False, block=None):
|
| 375 |
+
dist, q = {src: 0}, collections.deque([src])
|
| 376 |
+
parent = {src: None}
|
| 377 |
+
while q:
|
| 378 |
+
u = q.popleft()
|
| 379 |
+
if env.is_goal[u]:
|
| 380 |
+
continue
|
| 381 |
+
for a in range(N_ACT):
|
| 382 |
+
v = succ[u][a]
|
| 383 |
+
if v == u or v in dist or v == block:
|
| 384 |
+
continue
|
| 385 |
+
if avoid_neg and env.is_neg[v]:
|
| 386 |
+
continue
|
| 387 |
+
dist[v] = dist[u] + 1
|
| 388 |
+
parent[v] = u
|
| 389 |
+
q.append(v)
|
| 390 |
+
return dist, parent
|
| 391 |
+
|
| 392 |
+
bt = find_bottlenecks(env)
|
| 393 |
+
# ★ 走廊格 = 字符 '+' 的格子(显式标记, 不靠介数猜)
|
| 394 |
+
halls = [s for s, (r, c) in enumerate(env.cells) if env.grid[r][c] == "+"]
|
| 395 |
+
|
| 396 |
+
# ① 堵上全部走廊 -> 数连通分量
|
| 397 |
+
blocked = set(halls)
|
| 398 |
+
seen, comps = set(), []
|
| 399 |
+
for s in range(n):
|
| 400 |
+
if s in seen or s in blocked:
|
| 401 |
+
continue
|
| 402 |
+
stack, comp = [s], []
|
| 403 |
+
seen.add(s)
|
| 404 |
+
while stack:
|
| 405 |
+
u = stack.pop()
|
| 406 |
+
comp.append(u)
|
| 407 |
+
for a in range(N_ACT):
|
| 408 |
+
v = succ[u][a]
|
| 409 |
+
if v == u or v in seen or v in blocked:
|
| 410 |
+
continue
|
| 411 |
+
seen.add(v)
|
| 412 |
+
stack.append(v)
|
| 413 |
+
comps.append(comp)
|
| 414 |
+
|
| 415 |
+
# ③④ 对每个走廊格测最短路 vs 绕行
|
| 416 |
+
rows = []
|
| 417 |
+
for h in halls:
|
| 418 |
+
d_free, par = bfs(env.start)
|
| 419 |
+
d_av, _ = bfs(env.start, avoid_neg=True)
|
| 420 |
+
path, u = [], h
|
| 421 |
+
while u is not None:
|
| 422 |
+
path.append(u)
|
| 423 |
+
u = par.get(u)
|
| 424 |
+
path.reverse()
|
| 425 |
+
neg_short = sum(int(env.is_neg[s]) for s in path)
|
| 426 |
+
rows.append(dict(hall=h, d_free=d_free.get(h), d_avoid=d_av.get(h),
|
| 427 |
+
neg_on_shortest=neg_short,
|
| 428 |
+
detour_longer=(h in d_av and h in d_free and d_av[h] > d_free[h])))
|
| 429 |
+
|
| 430 |
+
p_ok = abs(bt[halls[0]] - max(bt)) < 1e-6 if halls else False
|
| 431 |
+
ok = (len(comps) == 4 and halls
|
| 432 |
+
and all(r["d_free"] is not None for r in rows)
|
| 433 |
+
and any(r["neg_on_shortest"] > 0 for r in rows)
|
| 434 |
+
and any(r["detour_longer"] for r in rows))
|
| 435 |
+
|
| 436 |
+
if verbose:
|
| 437 |
+
print(" [四房间自检]")
|
| 438 |
+
print(" 状态数 %d 走廊格 %d 个: %s"
|
| 439 |
+
% (n, len(halls), [env.cells[h] for h in halls]))
|
| 440 |
+
print(" 堵上走廊后的连通分量数: %d (论文要求 4)" % len(comps))
|
| 441 |
+
if halls:
|
| 442 |
+
print(" 走廊是介数最高点: %s (介数 %.0f / 全局最高 %.0f)"
|
| 443 |
+
% (p_ok, bt[halls[0]], max(bt)))
|
| 444 |
+
for r in rows:
|
| 445 |
+
rh, ch = env.cells[r["hall"]]
|
| 446 |
+
print(" 走廊 (%d,%d): 最短路 %s 步(其中灰色 %d 格) 绕行 %s 步 绕行更长: %s"
|
| 447 |
+
% (rh, ch, r["d_free"], r["neg_on_shortest"], r["d_avoid"], r["detour_longer"]))
|
| 448 |
+
print(" 四条性质全满足: %s" % ("✓" if ok else "✗ 布局需要调整"))
|
| 449 |
+
return ok, halls, dict(n_comps=len(comps), rows=rows, between=bt)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def value_iteration(env, model=None, n_sweeps=1, V0=None, options=None, order=None):
|
| 453 |
+
"""带 option 的价值迭代(论文 §5 的 planning)。
|
| 454 |
+
|
| 455 |
+
model : [(p, ns, r)] 形式的 dict; 默认用真模型(论文 Fig.1 假设模型准确)
|
| 456 |
+
格式: model[s][u] = [(p, ns, r), ...]; u 索引 0..N_ACT-1 是 primitive,
|
| 457 |
+
N_ACT.. 是 option(一跳到底, 返回终止状态与累计奖励)
|
| 458 |
+
n_sweeps : 扫多少遍
|
| 459 |
+
返回 (V, n_lookahead) —— n_lookahead = 消耗的 look-ahead 操作数(论文的横轴)
|
| 460 |
+
"""
|
| 461 |
+
n = env.n_states
|
| 462 |
+
V = np.zeros(n) if V0 is None else V0.copy()
|
| 463 |
+
n_look = 0
|
| 464 |
+
if model is None:
|
| 465 |
+
model = {s: {a: [(p, ns, env.R[s][a]) for p, ns in env.T[s][a]] for a in range(N_ACT)}
|
| 466 |
+
for s in range(n)}
|
| 467 |
+
for _ in range(n_sweeps):
|
| 468 |
+
idx = range(n) if order is None else order
|
| 469 |
+
for s in idx:
|
| 470 |
+
if env.is_goal[s]:
|
| 471 |
+
V[s] = 0.0
|
| 472 |
+
continue
|
| 473 |
+
best = -np.inf
|
| 474 |
+
for u, outs in model[s].items():
|
| 475 |
+
val = 0.0
|
| 476 |
+
for p, ns, r in outs:
|
| 477 |
+
val += p * (r + env.gamma * V[ns])
|
| 478 |
+
n_look += 1 # 每次 look-ahead 计数
|
| 479 |
+
best = max(best, val)
|
| 480 |
+
V[s] = best
|
| 481 |
+
return V, n_look
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
if __name__ == "__main__":
|
| 485 |
+
print("=" * 70)
|
| 486 |
+
print("两房间 gridworld(确定性, 论文 Fig.1)")
|
| 487 |
+
print("=" * 70)
|
| 488 |
+
env = GridWorld(TWO_ROOM, gamma=0.99, stochastic=False)
|
| 489 |
+
print(" 状态数 %d, 动作数 %d" % (env.n_states, N_ACT))
|
| 490 |
+
ok, hall, info = check_two_room(env)
|
| 491 |
+
|
| 492 |
+
V, n = value_iteration(env, n_sweeps=200)
|
| 493 |
+
print("\n value iteration 200 遍 (primitive only): V(S) = %.4f" % V[env.start])
|
| 494 |
+
print(" 论文报告的最优值 v*(s0) = 0.99^17 = %.4f" % (0.99 ** 17))
|
| 495 |
+
print(" look-ahead 操作数 = %d" % n)
|
| 496 |
+
|
| 497 |
+
print("\n" + "=" * 70)
|
| 498 |
+
print("四房间 gridworld(随机, slip=2/3, 论文 Fig.6)")
|
| 499 |
+
print("=" * 70)
|
| 500 |
+
env4 = GridWorld(FOUR_ROOM, gamma=0.99, stochastic=True)
|
| 501 |
+
print(" 状态数 %d, 动作数 %d" % (env4.n_states, N_ACT))
|
| 502 |
+
ok4, halls4, info4 = check_four_room(env4)
|
| 503 |
+
# ★ 自检不过就让进程**非零退出** —— 否则坏几何又会被静默当成"实验做完了"
|
| 504 |
+
if not (ok and ok4):
|
| 505 |
+
print("\n✗ 布局自检未通过, 拒绝继续(退出码 1)")
|
| 506 |
+
raise SystemExit(1)
|
| 507 |
+
V4, n4 = value_iteration(env4, n_sweeps=200)
|
| 508 |
+
print("\n value iteration 200 遍 (primitive only): V(S) = %.4f" % V4[env4.start])
|
| 509 |
+
print(" look-ahead 操作数 = %d" % n4)
|
hibs_lnn/knowledge.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""knowledge.py — InternalKnowledge: Agent 的**内部知识层**。
|
| 2 |
+
|
| 3 |
+
## 核心立场 (用户给的)
|
| 4 |
+
|
| 5 |
+
Knowledge ≠ Parameter
|
| 6 |
+
|
| 7 |
+
参数只是**承载**知识的一种方式。OaK 式系统希望内部知识是**可组合的计算对象**,
|
| 8 |
+
而不是 `y = f_θ(x)` 里一堆"你甚至不知道 θ 代表什么知识"的权重。
|
| 9 |
+
|
| 10 |
+
所以这个类**不暴露 θ / W / α**,而是暴露**可计算的知识查询**:
|
| 11 |
+
|
| 12 |
+
"未来会发生什么?" -> predict()
|
| 13 |
+
"我做什么会导致什么?" -> dynamics()
|
| 14 |
+
"什么结果更好?" -> value()
|
| 15 |
+
"哪些过程是一个整体技能?" -> skills()
|
| 16 |
+
"我对 X 有多确定?" -> confidence()
|
| 17 |
+
"我有多少经验?" -> coverage_of()
|
| 18 |
+
"我该多快改变?" -> plasticity()
|
| 19 |
+
|
| 20 |
+
## 四层知识 (用户给的编号)
|
| 21 |
+
|
| 22 |
+
Level 1 预测知识 K_P GVF / predictors "未来会发生什么"
|
| 23 |
+
Level 2 因果/转移 K_T T(s,a) "我做什么会导致什么"
|
| 24 |
+
Level 3 价值/策略 K_V V / Q / π "什么结果更好"
|
| 25 |
+
Level 4 抽象知识 K_A Options "哪些过程是一个整体技能"
|
| 26 |
+
|
| 27 |
+
外加两类**元知识**:
|
| 28 |
+
|
| 29 |
+
uncertainty/ confidence "我对自己的预测有多确定"
|
| 30 |
+
coverage/ state_action 覆盖 "我对这个状态/动作到底有没有经验"
|
| 31 |
+
plasticity/ α / β "我应该多快改变这个知识" (接 IDBD)
|
| 32 |
+
|
| 33 |
+
## 为什么 α 也算内部知识
|
| 34 |
+
|
| 35 |
+
w_i = knowledge "我知道什么"
|
| 36 |
+
α_i = knowledge about plasticity "我应该多快改变这个知识"
|
| 37 |
+
β_i = meta-knowledge "我的学习速度应该如何变化"
|
| 38 |
+
|
| 39 |
+
这与 IDBD 主线直接接上。
|
| 40 |
+
|
| 41 |
+
## 关键能力: 知识是**留下来的**
|
| 42 |
+
|
| 43 |
+
`to_dict()` / `load_dict()` 让 K 可以持久化比对 —— 这是"持续构建内部知识"的
|
| 44 |
+
可判定形式: 不是"模型又训了一轮",而是**能指出知识具体变了什么**。
|
| 45 |
+
"""
|
| 46 |
+
from __future__ import annotations
|
| 47 |
+
|
| 48 |
+
import numpy as np
|
| 49 |
+
|
| 50 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 51 |
+
# Level 1: 预测知识 (GVF / Horde 式)
|
| 52 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class GVF:
|
| 56 |
+
"""General Value Function: 预测某个 cumulant 的折扣累积量。
|
| 57 |
+
|
| 58 |
+
V(s) = E[ Σ γ^k λ^k c_{t+k+1} | s_t = s ]
|
| 59 |
+
|
| 60 |
+
`c` = cumulant (要预测的信号), `γ` = 折扣, `λ` = 迹衰减。
|
| 61 |
+
Horde (Sutton 2011) 的做法: 一堆 GVF 并行学, 它们的预测**进入状态**,
|
| 62 |
+
构成 agent 对世界的"理解"—— 这正是 OaK 说的 internal knowledge。
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self, name, dim, gamma=0.9, lam=0.8, lr=0.05, seed=0):
|
| 66 |
+
self.name = name
|
| 67 |
+
self.gamma = gamma
|
| 68 |
+
self.lam = lam
|
| 69 |
+
self.lr = lr
|
| 70 |
+
self.w = np.zeros(dim)
|
| 71 |
+
self.trace = np.zeros(dim)
|
| 72 |
+
self.rng = np.random.default_rng(seed)
|
| 73 |
+
self.steps = 0
|
| 74 |
+
|
| 75 |
+
def predict(self, phi):
|
| 76 |
+
return float(self.w @ phi)
|
| 77 |
+
|
| 78 |
+
def update(self, phi, cumulant, phi_next):
|
| 79 |
+
"""TD(λ) 更新。"""
|
| 80 |
+
v, v_next = self.w @ phi, self.w @ phi_next
|
| 81 |
+
delta = cumulant + self.gamma * v_next - v
|
| 82 |
+
self.trace = self.gamma * self.lam * self.trace + phi
|
| 83 |
+
self.w += self.lr * delta * self.trace
|
| 84 |
+
self.steps += 1
|
| 85 |
+
return abs(delta)
|
| 86 |
+
|
| 87 |
+
def stats(self):
|
| 88 |
+
return {"name": self.name, "w_norm": float(np.linalg.norm(self.w)),
|
| 89 |
+
"steps": self.steps, "gamma": self.gamma, "lam": self.lam}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class GVFBank:
|
| 93 |
+
"""一组 GVF。它们的预测拼起来就是 agent 的"世界理解"向量。"""
|
| 94 |
+
|
| 95 |
+
def __init__(self, dim, spec=None, seed=0):
|
| 96 |
+
self.spec = spec or [
|
| 97 |
+
("next_acc", 0.95, 0.9), ("acc_trend", 0.9, 0.7),
|
| 98 |
+
("forget_risk", 0.85, 0.8), ("regime_shift", 0.7, 0.5),
|
| 99 |
+
]
|
| 100 |
+
self.gvfs = [GVF(n, dim, g, l, seed=seed + i)
|
| 101 |
+
for i, (n, g, l) in enumerate(self.spec)]
|
| 102 |
+
|
| 103 |
+
def outputs(self, phi):
|
| 104 |
+
"""预测向量 —— 这就是"预测到的世界状态", 可作为下游特征。"""
|
| 105 |
+
return np.array([g.predict(phi) for g in self.gvfs])
|
| 106 |
+
|
| 107 |
+
def update(self, phi, cumulants, phi_next):
|
| 108 |
+
errs = []
|
| 109 |
+
for g, c in zip(self.gvfs, cumulants):
|
| 110 |
+
errs.append(g.update(phi, float(c), phi_next))
|
| 111 |
+
return float(np.mean(errs)) if errs else 0.0
|
| 112 |
+
|
| 113 |
+
def stats(self):
|
| 114 |
+
return {"n_gvf": len(self.gvfs),
|
| 115 |
+
"gvfs": [g.stats() for g in self.gvfs],
|
| 116 |
+
"out_norm": float(np.linalg.norm(self.outputs(np.ones_like(self.gvfs[0].w)))),
|
| 117 |
+
"mean_steps": float(np.mean([g.steps for g in self.gvfs])) if self.gvfs else 0.0}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ────────���─────────────────────────────────────────────────────────────────
|
| 121 |
+
# 元知识: 覆盖度 / 可塑性
|
| 122 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class Coverage:
|
| 126 |
+
"""state-action 覆盖度 —— "我对这个 (s,a) 到底有没有经验"。"""
|
| 127 |
+
|
| 128 |
+
def __init__(self, n_dom):
|
| 129 |
+
self.n_dom = n_dom
|
| 130 |
+
self.n = np.zeros(n_dom) # 每动作被访问次数
|
| 131 |
+
self.n_total = 0
|
| 132 |
+
|
| 133 |
+
def observe(self, a):
|
| 134 |
+
if 0 <= a < self.n_dom:
|
| 135 |
+
self.n[a] += 1
|
| 136 |
+
self.n_total += 1
|
| 137 |
+
|
| 138 |
+
def of(self, a):
|
| 139 |
+
return float(self.n[a]) if 0 <= a < self.n_dom else 0.0
|
| 140 |
+
|
| 141 |
+
def gate(self, a):
|
| 142 |
+
"""g = 1/√(1+n): 零覆盖 -> 1, 经验多 -> 0。"""
|
| 143 |
+
return float(1.0 / np.sqrt(1.0 + self.of(a)))
|
| 144 |
+
|
| 145 |
+
def stats(self):
|
| 146 |
+
nz = self.n[self.n > 0]
|
| 147 |
+
return {"total": int(self.n_total), "per_action": self.n.astype(int).tolist(),
|
| 148 |
+
"min": float(nz.min()) if len(nz) else 0.0,
|
| 149 |
+
"max": float(nz.max()) if len(nz) else 0.0,
|
| 150 |
+
"zero_actions": int((self.n == 0).sum())}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class Plasticity:
|
| 154 |
+
"""元知识: α (该多快改变某个参数) 与 β (学习速度本身的变化)。
|
| 155 |
+
|
| 156 |
+
这是 IDBD 主线在"内部知识"里的位置 —— 它**不是**知识本身,
|
| 157 |
+
而是"关于知识该如何改变的"元知识。
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
def __init__(self, dim, alpha0=0.05, mu=0.01):
|
| 161 |
+
self.alpha0 = alpha0
|
| 162 |
+
self.mu = mu
|
| 163 |
+
self.beta = np.zeros(dim)
|
| 164 |
+
self.h = np.zeros(dim)
|
| 165 |
+
self.updates = 0
|
| 166 |
+
|
| 167 |
+
@property
|
| 168 |
+
def alpha(self):
|
| 169 |
+
return np.exp(np.clip(self.beta, -20, 20)) * self.alpha0
|
| 170 |
+
|
| 171 |
+
def update(self, phi, delta):
|
| 172 |
+
g = delta * phi * self.h
|
| 173 |
+
self.h = np.maximum(0.0, self.h * (1.0 - np.clip(self.alpha * phi ** 2, 0, 1)) + self.alpha * phi * delta)
|
| 174 |
+
self.beta = np.clip(self.beta + self.mu * g, -20, 20)
|
| 175 |
+
self.updates += 1
|
| 176 |
+
|
| 177 |
+
def stats(self):
|
| 178 |
+
a = self.alpha
|
| 179 |
+
return {"mean": float(a.mean()), "std": float(a.std()),
|
| 180 |
+
"min": float(a.min()), "max": float(a.max()),
|
| 181 |
+
"ratio_max_min": float(a.max() / max(a.min(), 1e-12)),
|
| 182 |
+
"updates": self.updates}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 186 |
+
# InternalKnowledge: 统一的知识容器
|
| 187 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class InternalKnowledge:
|
| 191 |
+
"""K = {predictions, dynamics, value, options, uncertainty, coverage, plasticity}
|
| 192 |
+
|
| 193 |
+
★ 不暴露参数, 只暴露**知识查询**。
|
| 194 |
+
"""
|
| 195 |
+
|
| 196 |
+
def __init__(self, n_dom, dim_x, n_models=5, seed=0, alpha0=0.05, mu=0.01):
|
| 197 |
+
self.n_dom = int(n_dom)
|
| 198 |
+
self.dim_x = int(dim_x)
|
| 199 |
+
# Level 1 预测知识 K_P
|
| 200 |
+
self.predictions = GVFBank(dim_x, seed=seed)
|
| 201 |
+
# Level 2 因果/转移知识 K_T
|
| 202 |
+
from hibs_lnn.uncertainty_gate import TransitionEnsemble
|
| 203 |
+
self.dynamics_model = TransitionEnsemble(n_models=n_models, seed=seed)
|
| 204 |
+
self._dyn_fitted = False
|
| 205 |
+
# Level 3 价值/策略知识 K_V (由外部 proposer 提供, 这里只登记)
|
| 206 |
+
self.value_fn = None
|
| 207 |
+
self.policy = None
|
| 208 |
+
# Level 4 抽象知识 K_A
|
| 209 |
+
self.options = None
|
| 210 |
+
# 元知识
|
| 211 |
+
from hibs_lnn.uncertainty_gate import UncertaintyGate
|
| 212 |
+
self.uncertainty = UncertaintyGate(tau_C=1.0)
|
| 213 |
+
self.coverage = Coverage(self.n_dom)
|
| 214 |
+
self.plasticity = Plasticity(dim_x, alpha0=alpha0, mu=mu)
|
| 215 |
+
self.seed = seed
|
| 216 |
+
|
| 217 |
+
# ── 写入 ──────────────────────────────────────────────────────────
|
| 218 |
+
def fit_dynamics(self, X, Y):
|
| 219 |
+
self.dynamics_model.fit(X, Y, self.n_dom, None)
|
| 220 |
+
self._dyn_fitted = True
|
| 221 |
+
# 标定不确定性阈值
|
| 222 |
+
visits = self.coverage.n
|
| 223 |
+
n = len(X)
|
| 224 |
+
step = max(1, n // 100)
|
| 225 |
+
self.uncertainty.calibrate(
|
| 226 |
+
[self.dynamics_model.predict(X[i, :self.dim_x], int(X[i, -1]), visits)[1]
|
| 227 |
+
for i in range(0, n, step)])
|
| 228 |
+
return self
|
| 229 |
+
|
| 230 |
+
def register_value_fn(self, fn, policy=None):
|
| 231 |
+
"""登记价值/策略知识 (K_V)。fn(s, a) -> V。"""
|
| 232 |
+
self.value_fn = fn
|
| 233 |
+
self.policy = policy
|
| 234 |
+
return self
|
| 235 |
+
|
| 236 |
+
def register_options(self, option_manager):
|
| 237 |
+
"""登记抽象知识 (K_A)。"""
|
| 238 |
+
self.options = option_manager
|
| 239 |
+
return self
|
| 240 |
+
|
| 241 |
+
def observe_gvf(self, phi, cumulants, phi_next):
|
| 242 |
+
return self.predictions.update(phi, cumulants, phi_next)
|
| 243 |
+
|
| 244 |
+
def observe_action(self, a):
|
| 245 |
+
self.coverage.observe(a)
|
| 246 |
+
|
| 247 |
+
def observe_plasticity(self, phi, delta):
|
| 248 |
+
self.plasticity.update(phi, delta)
|
| 249 |
+
|
| 250 |
+
# ── 查询 (知识接口) ────────────────────────────────────────────────
|
| 251 |
+
def predict(self, s, a):
|
| 252 |
+
"""未来会发生什么 (Level 1+2 合成)。"""
|
| 253 |
+
if not self._dyn_fitted:
|
| 254 |
+
return np.asarray(s, dtype=float).copy(), float("inf")
|
| 255 |
+
return self.dynamics_model.predict(s, a, self.coverage.n)
|
| 256 |
+
|
| 257 |
+
def value(self, s, a):
|
| 258 |
+
"""什么结果更好 (Level 3)。
|
| 259 |
+
|
| 260 |
+
★ **对未知输入必须返回 NaN, 而不是抛异常**。
|
| 261 |
+
知识层被问到一个它没见过的 (s,a) 时, 正确反应是"我不知道", 不是崩。
|
| 262 |
+
(这条是实测抓到的: 测试里对从未出现过的动作 7 查询, value_fn 抛 KeyError
|
| 263 |
+
一路炸穿 knowledge_of —— 而"承认不知道"恰恰是这一层的核心语义。)
|
| 264 |
+
"""
|
| 265 |
+
if self.value_fn is None:
|
| 266 |
+
return float("nan")
|
| 267 |
+
try:
|
| 268 |
+
return float(self.value_fn(s, a))
|
| 269 |
+
except Exception:
|
| 270 |
+
return float("nan")
|
| 271 |
+
|
| 272 |
+
def skills(self):
|
| 273 |
+
"""哪些过程是一个整体技能 (Level 4)。"""
|
| 274 |
+
if self.options is None:
|
| 275 |
+
return []
|
| 276 |
+
return [o.stats() for o in getattr(self.options, "options", [])]
|
| 277 |
+
|
| 278 |
+
def confidence(self, s, a):
|
| 279 |
+
"""我对自己的预测有多确定 —— 返回 [0,1], 1 = 最确定。"""
|
| 280 |
+
_, unc = self.predict(s, a)
|
| 281 |
+
if not np.isfinite(unc):
|
| 282 |
+
return 0.0
|
| 283 |
+
tau = self.uncertainty.tau_U
|
| 284 |
+
if tau is None or not np.isfinite(tau) or tau <= 0:
|
| 285 |
+
return 0.5
|
| 286 |
+
return float(1.0 / (1.0 + unc / tau))
|
| 287 |
+
|
| 288 |
+
def coverage_of(self, a):
|
| 289 |
+
return self.coverage.of(a)
|
| 290 |
+
|
| 291 |
+
def knowledge_of(self, s, a):
|
| 292 |
+
"""★★ 统一查询: 对任意 (s,a), agent 到底知道什么。
|
| 293 |
+
|
| 294 |
+
这是 Knowledge≠Parameter 的直接体现 —— 问的是**内容**不是参数。
|
| 295 |
+
"""
|
| 296 |
+
s_next, unc = self.predict(s, a)
|
| 297 |
+
val = self.value(s, a)
|
| 298 |
+
novel = bool(self.coverage_of(a) == 0)
|
| 299 |
+
known = bool(not novel and np.isfinite(unc))
|
| 300 |
+
return {
|
| 301 |
+
"state": np.round(np.asarray(s, dtype=float), 4).tolist(),
|
| 302 |
+
"action": int(a),
|
| 303 |
+
"predicted_next": np.round(np.asarray(s_next), 4).tolist(),
|
| 304 |
+
"uncertainty": float(unc) if np.isfinite(unc) else None,
|
| 305 |
+
"confidence": self.confidence(s, a),
|
| 306 |
+
"value": (val if np.isfinite(val) else None),
|
| 307 |
+
"coverage": self.coverage_of(a),
|
| 308 |
+
"coverage_gate": self.coverage.gate(a),
|
| 309 |
+
"trustworthy": bool(self.uncertainty.allow(s, a, self.coverage.n)[0]),
|
| 310 |
+
"novel": novel,
|
| 311 |
+
# ★ 这一项是整层的立命之处: agent 明确区分「我知道」与「我不知道」
|
| 312 |
+
"known": known,
|
| 313 |
+
"verdict": ("我不知道" if not known else
|
| 314 |
+
("不太确定" if self.confidence(s, a) < 0.5 else "我知道")),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
# ── 摘要 / 持久化 ─────────────────────────────────────────────────
|
| 318 |
+
def describe(self) -> dict:
|
| 319 |
+
"""分层摘要: 这是我"知道了什么"的完整画像。"""
|
| 320 |
+
return {
|
| 321 |
+
"Level1_predictions": self.predictions.stats(),
|
| 322 |
+
"Level2_dynamics": {"fitted": self._dyn_fitted,
|
| 323 |
+
"n_models": len(getattr(self.dynamics_model, "Ws", []) or []),
|
| 324 |
+
"dim_x": self.dim_x, "n_dom": self.n_dom},
|
| 325 |
+
"Level3_value": {"registered": self.value_fn is not None,
|
| 326 |
+
"policy_registered": self.policy is not None},
|
| 327 |
+
"Level4_abstraction": {"n_options": len(self.skills()),
|
| 328 |
+
"options": self.skills()[:6]},
|
| 329 |
+
"meta_uncertainty": {"tau_U": self.uncertainty.tau_U,
|
| 330 |
+
"tau_C": self.uncertainty.tau_C},
|
| 331 |
+
"meta_coverage": self.coverage.stats(),
|
| 332 |
+
"meta_plasticity": self.plasticity.stats(),
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
def to_dict(self) -> dict:
|
| 336 |
+
"""知识可以被**留下来** —— 持久化后能与旧版本比对。"""
|
| 337 |
+
return {
|
| 338 |
+
"coverage_n": self.coverage.n.tolist(),
|
| 339 |
+
"coverage_total": self.coverage.n_total,
|
| 340 |
+
"gvf_w": [g.w.tolist() for g in self.predictions.gvfs],
|
| 341 |
+
"gvf_steps": [g.steps for g in self.predictions.gvfs],
|
| 342 |
+
"dyn_W": [W.tolist() for W in (getattr(self.dynamics_model, "Ws", None) or [])],
|
| 343 |
+
"tau_U": self.uncertainty.tau_U,
|
| 344 |
+
"plasticity_beta": self.plasticity.beta.tolist(),
|
| 345 |
+
"plasticity_updates": self.plasticity.updates,
|
| 346 |
+
"n_options": len(self.skills()),
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
def growth(self, old_dict) -> dict:
|
| 350 |
+
"""★ 与旧版本比对: 知识**具体**变了什么。
|
| 351 |
+
|
| 352 |
+
这是"持续构建内部知识"的可判定形式 —— 不是"又训了一轮",
|
| 353 |
+
而是能逐项指出知识的变化量。
|
| 354 |
+
"""
|
| 355 |
+
if not old_dict:
|
| 356 |
+
return {"note": "no prior knowledge"}
|
| 357 |
+
out = {}
|
| 358 |
+
# 覆盖度变化
|
| 359 |
+
old_n = np.array(old_dict.get("coverage_n", [0] * self.n_dom), dtype=float)
|
| 360 |
+
out["coverage_delta"] = (self.coverage.n - old_n).astype(int).tolist()
|
| 361 |
+
out["coverage_total_delta"] = int(self.coverage.n_total - old_dict.get("coverage_total", 0))
|
| 362 |
+
# GVF 权重变化 (预测知识变了多少)
|
| 363 |
+
gw_old = old_dict.get("gvf_w")
|
| 364 |
+
if gw_old is not None and len(gw_old) == len(self.predictions.gvfs):
|
| 365 |
+
d = [float(np.linalg.norm(np.array(g.w) - np.array(o)))
|
| 366 |
+
for g, o in zip(self.predictions.gvfs, gw_old)]
|
| 367 |
+
out["gvf_weight_delta"] = np.round(d, 6).tolist()
|
| 368 |
+
out["gvf_weight_delta_mean"] = float(np.mean(d))
|
| 369 |
+
# 转移模型变化
|
| 370 |
+
dw_old = old_dict.get("dyn_W")
|
| 371 |
+
cur = getattr(self.dynamics_model, "Ws", None)
|
| 372 |
+
if dw_old and cur and len(dw_old) == len(cur):
|
| 373 |
+
d = [float(np.linalg.norm(np.array(W) - np.array(o))) for W, o in zip(cur, dw_old)]
|
| 374 |
+
out["dyn_weight_delta_mean"] = float(np.mean(d))
|
| 375 |
+
# 可塑性元知识变化
|
| 376 |
+
pb_old = old_dict.get("plasticity_beta")
|
| 377 |
+
if pb_old is not None and len(pb_old) == len(self.plasticity.beta):
|
| 378 |
+
out["plasticity_beta_delta"] = np.round(
|
| 379 |
+
self.plasticity.beta - np.array(pb_old), 6).tolist()
|
| 380 |
+
out["n_options_delta"] = len(self.skills()) - old_dict.get("n_options", 0)
|
| 381 |
+
return out
|
| 382 |
+
|
| 383 |
+
def stats(self):
|
| 384 |
+
return {"n_dom": self.n_dom, "dim_x": self.dim_x,
|
| 385 |
+
"dyn_fitted": self._dyn_fitted, "n_options": len(self.skills()),
|
| 386 |
+
"coverage_total": int(self.coverage.n_total)}
|
hibs_lnn/meta_rule_world.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
V31.0/V31.2: MetaRuleWorld — 元学习任务分布世界
|
| 3 |
+
============================================
|
| 4 |
+
元学习设定: 每个"任务" = 一个规则变体 (remap 参数化)。
|
| 5 |
+
|
| 6 |
+
任务 T_i 的规则 = 一个寄存器重映射:
|
| 7 |
+
remap=None → 标准执行 (规则 A)
|
| 8 |
+
remap=(0,1) → R0↔R1 互换 (规则 B) [兼容旧接口: pair]
|
| 9 |
+
remap=(1,3,2,0) → 任意排列 perm[reg] [V31.2: 全 24 种排列]
|
| 10 |
+
|
| 11 |
+
V31.2 长流扩展 (S4 对称群, 24 条规则):
|
| 12 |
+
S4 = 1 identity + 6 对换 + 8 三轮换 + 6 四轮换 + 3 双对换
|
| 13 |
+
训练: identity + 6 对换 + 5 三轮换 = 12 条
|
| 14 |
+
未见: 3 三轮换 + 6 四轮换 + 3 双对换 = 12 条 (结构上不同 → "没学过的学习方法")
|
| 15 |
+
|
| 16 |
+
目标 (Javed & White NeurIPS 2019 精神):
|
| 17 |
+
不学"预测所有规则"的单一 head (V30.4 失败模式),
|
| 18 |
+
而是学一个表示 φ, 使任意规则可以快速适应 (内循环 head 更新)。
|
| 19 |
+
|
| 20 |
+
数据格式 (离线监督式):
|
| 21 |
+
context = 最近 window 个 READ 观察 (5 tokens each) → 预测下一个 READ (5 tokens)
|
| 22 |
+
模型从观察历史推断当前规则 → 预测 EXEC 后的寄存器状态。
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
import random
|
| 27 |
+
from itertools import permutations
|
| 28 |
+
from .code_world import (
|
| 29 |
+
RegisterMachine, CodeSandboxWorld,
|
| 30 |
+
N_REGISTERS, INSTR_LEN, OPCODE_TOKEN_OFFSET,
|
| 31 |
+
OPCODES, OPCODES_INV,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def s4_groups():
|
| 36 |
+
"""S4 按结构分组: (identity, swaps, 3cycles, 4cycles, double_swaps)。"""
|
| 37 |
+
allp = list(permutations(range(4)))
|
| 38 |
+
identity = [(0, 1, 2, 3)]
|
| 39 |
+
swaps, c3, c4, dbl = [], [], [], []
|
| 40 |
+
for p in allp:
|
| 41 |
+
if p == (0, 1, 2, 3):
|
| 42 |
+
continue
|
| 43 |
+
fixed = sum(1 for i in range(4) if p[i] == i)
|
| 44 |
+
if fixed == 2:
|
| 45 |
+
swaps.append(p) # 单对换
|
| 46 |
+
elif fixed == 1:
|
| 47 |
+
c3.append(p) # 三轮换
|
| 48 |
+
elif fixed == 0:
|
| 49 |
+
# 四轮换 (阶 4) vs 双对换 (阶 2)
|
| 50 |
+
if all(p[p[i]] == i for i in range(4)):
|
| 51 |
+
dbl.append(p) # 双对换 (Klein)
|
| 52 |
+
else:
|
| 53 |
+
c4.append(p) # 四轮换
|
| 54 |
+
return identity, swaps, c3, c4, dbl
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# V31.2 规则划分: 训练 = identity + 6 对换 + 5 三轮换 (12)
|
| 58 |
+
# 未见 = 3 三轮换 + 6 四轮换 + 3 双对换 (12, 结构不同)
|
| 59 |
+
def build_rule_splits(seed=42):
|
| 60 |
+
identity, swaps, c3, c4, dbl = s4_groups()
|
| 61 |
+
rng = random.Random(seed)
|
| 62 |
+
rng.shuffle(c3); rng.shuffle(c4); rng.shuffle(dbl)
|
| 63 |
+
train = identity + swaps + c3[:5]
|
| 64 |
+
test = c3[5:] + c4 + dbl
|
| 65 |
+
return train, test
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# 各 opcode 的寄存器操作数位置 (立即数位置不重映射):
|
| 69 |
+
# SET dst,val → a1 寄存器, a2 立即数 (0-99)
|
| 70 |
+
# MOV src,dst → a1,a2 寄存器
|
| 71 |
+
# ADD/SUB a,b,d → a1,a2,a3 寄存器
|
| 72 |
+
# INC/DEC reg → a1 寄存器
|
| 73 |
+
# SWP a,b → a1,a2 寄存器
|
| 74 |
+
_REG_OPPOS = {0: (), 1: (0,), 2: (0, 1), 3: (0, 1, 2), 4: (0, 1, 2),
|
| 75 |
+
5: (0,), 6: (0,), 7: (0, 1)}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class MetaRuleMachine(RegisterMachine):
|
| 79 |
+
"""RegisterMachine + 参数化寄存器重映射 (任意排列或 pair)。
|
| 80 |
+
|
| 81 |
+
V31.2 修复 (原 V31.0 bug): 只重映射**寄存器操作数**位置,
|
| 82 |
+
立即数 (SET 的 a2, 0-99) 不受 remap 影响。
|
| 83 |
+
原实现 `_remap` 作用于所有操作数 → 立即数被 mod 4 重映射,
|
| 84 |
+
"SET R0 42 → R1=42" 变成 "SET R1 2" — 规则语义被污染
|
| 85 |
+
(V31.0 世界自测的 remap 项其实一直失败, 计数器有 bug 显示 7/7)。
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(self, V, remap_pair=None, remap=None):
|
| 89 |
+
super().__init__(V)
|
| 90 |
+
if remap is None:
|
| 91 |
+
if remap_pair is None:
|
| 92 |
+
remap = (0, 1, 2, 3)
|
| 93 |
+
else:
|
| 94 |
+
i, j = remap_pair
|
| 95 |
+
remap = tuple(j if k == i else (i if k == j else k)
|
| 96 |
+
for k in range(4))
|
| 97 |
+
self.remap_pair = remap_pair # 兼容旧接口 (V31.0)
|
| 98 |
+
self.remap = remap # V31.2: 完整排列 (identity = (0,1,2,3))
|
| 99 |
+
|
| 100 |
+
def _remap(self, reg: int) -> int:
|
| 101 |
+
return self.remap[reg % N_REGISTERS]
|
| 102 |
+
|
| 103 |
+
def step_execute(self) -> bool:
|
| 104 |
+
if self.halted or self.pc >= len(self.program):
|
| 105 |
+
self.halted = True
|
| 106 |
+
return False
|
| 107 |
+
if self.step_count >= self.max_steps:
|
| 108 |
+
self.halted = True
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
opcode, a1, a2, a3 = self.program[self.pc]
|
| 112 |
+
name = OPCODES_INV[opcode]
|
| 113 |
+
|
| 114 |
+
if self.remap != (0, 1, 2, 3):
|
| 115 |
+
args = [a1, a2, a3]
|
| 116 |
+
for pos in _REG_OPPOS[opcode]:
|
| 117 |
+
args[pos] = self._remap(args[pos])
|
| 118 |
+
a1, a2, a3 = args
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
if name == 'NOP':
|
| 122 |
+
pass
|
| 123 |
+
elif name == 'SET':
|
| 124 |
+
self.regs[a1 % N_REGISTERS] = a2 % self.V
|
| 125 |
+
elif name == 'MOV':
|
| 126 |
+
self.regs[a2 % N_REGISTERS] = self.regs[a1 % N_REGISTERS]
|
| 127 |
+
elif name == 'ADD':
|
| 128 |
+
self.regs[a3 % N_REGISTERS] = (
|
| 129 |
+
self.regs[a1 % N_REGISTERS] + self.regs[a2 % N_REGISTERS]
|
| 130 |
+
) % self.V
|
| 131 |
+
elif name == 'SUB':
|
| 132 |
+
self.regs[a3 % N_REGISTERS] = max(
|
| 133 |
+
0, self.regs[a1 % N_REGISTERS] - self.regs[a2 % N_REGISTERS]
|
| 134 |
+
)
|
| 135 |
+
elif name == 'INC':
|
| 136 |
+
self.regs[a1 % N_REGISTERS] = (
|
| 137 |
+
self.regs[a1 % N_REGISTERS] + 1
|
| 138 |
+
) % self.V
|
| 139 |
+
elif name == 'DEC':
|
| 140 |
+
self.regs[a1 % N_REGISTERS] = max(
|
| 141 |
+
0, self.regs[a1 % N_REGISTERS] - 1
|
| 142 |
+
)
|
| 143 |
+
elif name == 'SWP':
|
| 144 |
+
ri, rj = a1 % N_REGISTERS, a2 % N_REGISTERS
|
| 145 |
+
self.regs[ri], self.regs[rj] = self.regs[rj], self.regs[ri]
|
| 146 |
+
except Exception:
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
self.pc += 1
|
| 150 |
+
self.step_count += 1
|
| 151 |
+
return True
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class MetaRuleWorld(CodeSandboxWorld):
|
| 155 |
+
"""代码执行世界, 规则由 remap_pair 参数化。观察格式与 CodeSandboxWorld 一致。"""
|
| 156 |
+
|
| 157 |
+
K = 5
|
| 158 |
+
READ_WINDOW = N_REGISTERS + 1 # 4 regs + 1 opcode
|
| 159 |
+
ACTIONS = ['READ', 'EXEC', 'RESET', 'NEWREG', 'TOME']
|
| 160 |
+
|
| 161 |
+
def __init__(self, V: int, remap_pair=None, remap=None,
|
| 162 |
+
max_program_len: int = 8):
|
| 163 |
+
self.V = V
|
| 164 |
+
self.TOME = V + 1
|
| 165 |
+
self.world_vocab_size = V + 2
|
| 166 |
+
self.max_program_len = max_program_len
|
| 167 |
+
self.remap_pair = remap_pair
|
| 168 |
+
self.remap = remap
|
| 169 |
+
self._new_episode()
|
| 170 |
+
|
| 171 |
+
def _new_episode(self):
|
| 172 |
+
self.vm = MetaRuleMachine(self.V, remap_pair=self.remap_pair,
|
| 173 |
+
remap=self.remap)
|
| 174 |
+
self.exec_count = 0
|
| 175 |
+
self.current_step = 0
|
| 176 |
+
self.n_erases = 0
|
| 177 |
+
|
| 178 |
+
prog_len = random.randint(2, self.max_program_len)
|
| 179 |
+
self.vm.generate_random_program(prog_len)
|
| 180 |
+
|
| 181 |
+
for i in range(N_REGISTERS):
|
| 182 |
+
self.vm.regs[i] = random.randint(0, min(self.V - 1, 50))
|
| 183 |
+
|
| 184 |
+
pre_steps = random.randint(0, min(2, len(self.vm.program) - 1))
|
| 185 |
+
for _ in range(pre_steps):
|
| 186 |
+
self.vm.step_execute()
|
| 187 |
+
self.exec_count += 1
|
| 188 |
+
|
| 189 |
+
def reset(self):
|
| 190 |
+
self._new_episode()
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ============================================================
|
| 194 |
+
# 任务数据生成 (离线监督式)
|
| 195 |
+
# ============================================================
|
| 196 |
+
# V31 观察格式 (8 tokens): [R0,R1,R2,R3, opcode+100, a1, a2, a3]
|
| 197 |
+
# → 完整指令可见, "预测 EXEC 后状态"是确定性问题 (信息完备)
|
| 198 |
+
# → 规则差异 (remap) 真正可学习: SET R0 42 → A: R0=42 / B: R1=42
|
| 199 |
+
# 注意: 这与 V30 系列不同 (V30 只有 opcode 无操作数 → 信息不足 → pred 2-8%)
|
| 200 |
+
|
| 201 |
+
def _instr_tokens(vm):
|
| 202 |
+
"""当前 PC 指令的 4 个 token: [opcode+100, a1, a2, a3], 程序结束用 TOME。"""
|
| 203 |
+
if vm.halted or vm.pc >= len(vm.program):
|
| 204 |
+
return [vm.V + 1] * 4 # TOME
|
| 205 |
+
opcode, a1, a2, a3 = vm.program[vm.pc]
|
| 206 |
+
return [opcode + 100, min(a1, 99), min(a2, 99), min(a3, 99)]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _world_obs(world):
|
| 210 |
+
"""8-token 观察: [R0,R1,R2,R3, instr...]"""
|
| 211 |
+
vm = world.vm
|
| 212 |
+
state = [min(r, world.V - 1) for r in vm.regs]
|
| 213 |
+
return state + _instr_tokens(vm)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def gen_episode_pairs(world, V, max_len=8, window=3, stop_at_halt=False):
|
| 217 |
+
"""
|
| 218 |
+
从一个 episode 生成 (context, target) 监督对。
|
| 219 |
+
|
| 220 |
+
context = 最近 window 个观察拼接 (window*8 tokens)
|
| 221 |
+
target = EXEC 后的观察 (8 tokens)
|
| 222 |
+
|
| 223 |
+
模型从观察历史推断当前规则 → 预测 EXEC 后的完整状态。
|
| 224 |
+
由于观察含完整指令, 给定规则下 target 是确定性的。
|
| 225 |
+
|
| 226 |
+
stop_at_halt (V33 世界修复): 程序结束 (halted) 后立即停止,
|
| 227 |
+
不生成含 TOME(201) 填充的 target → 每个 target 都有信息量
|
| 228 |
+
(旧世界 ~77.5% target 后 4 token 是 TOME, 虚高 full-8 acc)。
|
| 229 |
+
"""
|
| 230 |
+
pairs = []
|
| 231 |
+
obs = torch.tensor(_world_obs(world), dtype=torch.long)
|
| 232 |
+
history = [obs]
|
| 233 |
+
for _ in range(max_len):
|
| 234 |
+
world.query(1) # EXEC
|
| 235 |
+
obs = torch.tensor(_world_obs(world), dtype=torch.long)
|
| 236 |
+
if stop_at_halt and world.vm.halted:
|
| 237 |
+
break # 程序结束: 不再追加 TOME 观察
|
| 238 |
+
history.append(obs)
|
| 239 |
+
for t in range(window, len(history)):
|
| 240 |
+
ctx = torch.cat(history[t - window:t]).long() # window*8
|
| 241 |
+
tgt = history[t].long() # 8
|
| 242 |
+
pairs.append((ctx, tgt))
|
| 243 |
+
return pairs
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def gen_task_data(remap_pair, V, n_episodes=48, max_len=8, window=3,
|
| 247 |
+
seed=42, max_program_len=8, remap=None,
|
| 248 |
+
stop_at_halt=False):
|
| 249 |
+
"""
|
| 250 |
+
生成一个任务 (规则) 的数据集。
|
| 251 |
+
返回: list of (context_ids, target_ids), 均 clamp 到 [0, V+1]。
|
| 252 |
+
"""
|
| 253 |
+
rng_state = random.getstate()
|
| 254 |
+
random.seed(seed)
|
| 255 |
+
pairs = []
|
| 256 |
+
for _ in range(n_episodes):
|
| 257 |
+
w = MetaRuleWorld(V, remap_pair=remap_pair, remap=remap,
|
| 258 |
+
max_program_len=max_program_len)
|
| 259 |
+
pairs.extend(gen_episode_pairs(w, V, max_len=max_len, window=window,
|
| 260 |
+
stop_at_halt=stop_at_halt))
|
| 261 |
+
random.setstate(rng_state)
|
| 262 |
+
# clamp
|
| 263 |
+
pairs = [(c.clamp(0, V + 1), t.clamp(0, V + 1)) for c, t in pairs]
|
| 264 |
+
return pairs
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def gen_task_data_split(remap_pair, V, n_support=48, n_query=24,
|
| 268 |
+
max_len=8, window=3, seed=42, max_program_len=8,
|
| 269 |
+
remap=None, stop_at_halt=False):
|
| 270 |
+
"""生成任务的 support/query 划分 (不同 seed 保证不重叠)。"""
|
| 271 |
+
support = gen_task_data(remap_pair, V, n_episodes=n_support,
|
| 272 |
+
max_len=max_len, window=window,
|
| 273 |
+
seed=seed, max_program_len=max_program_len,
|
| 274 |
+
remap=remap, stop_at_halt=stop_at_halt)
|
| 275 |
+
query = gen_task_data(remap_pair, V, n_episodes=n_query,
|
| 276 |
+
max_len=max_len, window=window,
|
| 277 |
+
seed=seed + 1000, max_program_len=max_program_len,
|
| 278 |
+
remap=remap, stop_at_halt=stop_at_halt)
|
| 279 |
+
return support, query
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _self_test():
|
| 283 |
+
ok = 0
|
| 284 |
+
total = 0
|
| 285 |
+
def check(cond, msg):
|
| 286 |
+
nonlocal ok, total
|
| 287 |
+
total += 1
|
| 288 |
+
if cond:
|
| 289 |
+
ok += 1
|
| 290 |
+
print(f" OK {msg}")
|
| 291 |
+
else:
|
| 292 |
+
print(f" FAIL {msg}")
|
| 293 |
+
|
| 294 |
+
random.seed(42)
|
| 295 |
+
V = 100
|
| 296 |
+
|
| 297 |
+
# 1. 规则 A (无 remap): SET R0 42 → R0=42
|
| 298 |
+
vm_a = MetaRuleMachine(V=100, remap_pair=None)
|
| 299 |
+
vm_a.load_instruction([101, 0, 42, 0])
|
| 300 |
+
vm_a.step_execute()
|
| 301 |
+
check(vm_a.regs[0] == 42 and vm_a.regs[1] == 0,
|
| 302 |
+
f"A: SET R0 42 → R0={vm_a.regs[0]}, R1={vm_a.regs[1]}")
|
| 303 |
+
|
| 304 |
+
# 2. 规则 B (0,1): SET R0 42 → R1=42
|
| 305 |
+
vm_b = MetaRuleMachine(V=100, remap_pair=(0, 1))
|
| 306 |
+
vm_b.load_instruction([101, 0, 42, 0])
|
| 307 |
+
vm_b.step_execute()
|
| 308 |
+
check(vm_b.regs[1] == 42 and vm_b.regs[0] == 0,
|
| 309 |
+
f"B: SET R0 42 → R1={vm_b.regs[1]}, R0={vm_b.regs[0]}")
|
| 310 |
+
|
| 311 |
+
# 3. 规则 C (0,2): SET R0 42 → R2=42
|
| 312 |
+
vm_c = MetaRuleMachine(V=100, remap_pair=(0, 2))
|
| 313 |
+
vm_c.load_instruction([101, 0, 42, 0])
|
| 314 |
+
vm_c.step_execute()
|
| 315 |
+
check(vm_c.regs[2] == 42 and vm_c.regs[0] == 0,
|
| 316 |
+
f"C: SET R0 42 → R2={vm_c.regs[2]}, R0={vm_c.regs[0]}")
|
| 317 |
+
|
| 318 |
+
# 4. 规则 D (1,2): SET R1 42 → R2=42
|
| 319 |
+
vm_d = MetaRuleMachine(V=100, remap_pair=(1, 2))
|
| 320 |
+
vm_d.load_instruction([101, 1, 42, 0])
|
| 321 |
+
vm_d.step_execute()
|
| 322 |
+
check(vm_d.regs[2] == 42 and vm_d.regs[1] == 0,
|
| 323 |
+
f"D: SET R1 42 → R2={vm_d.regs[2]}, R1={vm_d.regs[1]}")
|
| 324 |
+
|
| 325 |
+
# 5. 数据生成: context/target shapes (8-token 观察)
|
| 326 |
+
w = MetaRuleWorld(V=100, remap_pair=(0, 1))
|
| 327 |
+
pairs = gen_episode_pairs(w, V, max_len=8, window=3)
|
| 328 |
+
check(len(pairs) > 0, f"episode pairs = {len(pairs)}")
|
| 329 |
+
ctx, tgt = pairs[0]
|
| 330 |
+
check(ctx.shape[0] == 24 and tgt.shape[0] == 8,
|
| 331 |
+
f"ctx={tuple(ctx.shape)} tgt={tuple(tgt.shape)} (24/8)")
|
| 332 |
+
|
| 333 |
+
# 5b. 信息完备性: 给定规则, target 确定 (SET R0 42 在规则 A/B 下不同)
|
| 334 |
+
random.seed(3)
|
| 335 |
+
wA = MetaRuleWorld(V=100, remap_pair=None)
|
| 336 |
+
wA.vm.program = [(1, 0, 42, 0)]; wA.vm.pc = 0; wA.vm.regs = [0, 0, 0, 0]
|
| 337 |
+
wA.vm.halted = False
|
| 338 |
+
obsA = _world_obs(wA)
|
| 339 |
+
wA.query(1) # EXEC
|
| 340 |
+
afterA = _world_obs(wA)
|
| 341 |
+
wB = MetaRuleWorld(V=100, remap_pair=(0, 1))
|
| 342 |
+
wB.vm.program = [(1, 0, 42, 0)]; wB.vm.pc = 0; wB.vm.regs = [0, 0, 0, 0]
|
| 343 |
+
wB.vm.halted = False
|
| 344 |
+
obsB = _world_obs(wB)
|
| 345 |
+
wB.query(1) # EXEC
|
| 346 |
+
afterB = _world_obs(wB)
|
| 347 |
+
check(obsA[:4] == obsB[:4], "same state before EXEC")
|
| 348 |
+
check(afterA[:4] == [42, 0, 0, 0], f"A after SET R0 42: {afterA[:4]}")
|
| 349 |
+
check(afterB[:4] == [0, 42, 0, 0], f"B after SET R0 42: {afterB[:4]}")
|
| 350 |
+
check(afterA[:4] != afterB[:4], "rule difference visible in targets")
|
| 351 |
+
|
| 352 |
+
# 6. support/query 划分
|
| 353 |
+
sup, qry = gen_task_data_split((0, 1), V, n_support=8, n_query=4, seed=42)
|
| 354 |
+
check(len(sup) > 0 and len(qry) > 0, f"support={len(sup)} query={len(qry)}")
|
| 355 |
+
|
| 356 |
+
# 7. 不同规则产生不同数据分布 (相同 seed, 相同程序 → 不同状态)
|
| 357 |
+
random.seed(7)
|
| 358 |
+
w1 = MetaRuleWorld(V=100, remap_pair=None)
|
| 359 |
+
w2 = MetaRuleWorld(V=100, remap_pair=(0, 1))
|
| 360 |
+
# 手动构造相同程序
|
| 361 |
+
prog = [(1, 0, 42, 0), (5, 0, 0, 0)] # SET R0 42; INC R0
|
| 362 |
+
w1.vm.program = prog[:]; w1.vm.pc = 0; w1.vm.regs = [0, 0, 0, 0]
|
| 363 |
+
w2.vm.program = prog[:]; w2.vm.pc = 0; w2.vm.regs = [0, 0, 0, 0]
|
| 364 |
+
w1.vm.step_execute(); w2.vm.step_execute()
|
| 365 |
+
s1 = w1.vm.regs[:]; s2 = w2.vm.regs[:]
|
| 366 |
+
check(s1[0] == 42 and s2[1] == 42,
|
| 367 |
+
f"same prog diverges: A={s1[:2]}, B={s2[:2]}")
|
| 368 |
+
|
| 369 |
+
# 8. V31.2 排列规则: 只重映射寄存器, 立即数不动
|
| 370 |
+
vm_p = MetaRuleMachine(V=100, remap=(1, 3, 2, 0))
|
| 371 |
+
vm_p.load_instruction([101, 0, 42, 0]) # SET R0 42
|
| 372 |
+
vm_p.step_execute()
|
| 373 |
+
check(vm_p.regs[1] == 42,
|
| 374 |
+
f"perm(1,3,2,0): SET R0 42 → R1={vm_p.regs[1]} (立即数 42 不受影响)")
|
| 375 |
+
vm_p2 = MetaRuleMachine(V=100, remap=(1, 3, 2, 0))
|
| 376 |
+
vm_p2.load_instruction([104, 0, 1, 2]) # SUB R0 R1 → R2
|
| 377 |
+
vm_p2.step_execute()
|
| 378 |
+
# SUB R0(→1) R1(→3) → R2(→2): regs[2] = regs[1] - regs[3] = 0
|
| 379 |
+
check(vm_p2.regs[2] == 0,
|
| 380 |
+
f"perm SUB: R0,R1→R2 remap 后 regs[2]=regs[1]-regs[3]")
|
| 381 |
+
|
| 382 |
+
print(f"\n {ok}/{total} passed")
|
| 383 |
+
return ok == total
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
if __name__ == "__main__":
|
| 387 |
+
_self_test()
|
hibs_lnn/oak_proposer.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""oak_proposer.py — OaK 提议器: 把 InternalKnowledge + Options 接进主回路。
|
| 2 |
+
|
| 3 |
+
## 状态/动作的定义 (在这个问题里)
|
| 4 |
+
|
| 5 |
+
state = 各细区间的**能力画像** (维度 = n_fine) "我现在会什么"
|
| 6 |
+
action = 选哪个细区间训练 (0..n_fine-1)
|
| 7 |
+
next = 训练后的新能力画像
|
| 8 |
+
reward = Δ(any-time 准确率)
|
| 9 |
+
|
| 10 |
+
这就是真实的持续学习闭环: **动作是"去练哪块", 环境是"练完能力怎么变"。**
|
| 11 |
+
`T(s,a) -> s'` 因此是一个**真正可学、数据里天然存在**的转移 —— 不是硬凑的。
|
| 12 |
+
|
| 13 |
+
## 结构
|
| 14 |
+
|
| 15 |
+
OAKProposer
|
| 16 |
+
├── base : RLProposer (Level 3 价值/策略知识, 复用已验证机制)
|
| 17 |
+
└── know : InternalKnowledge (Level 1/2/4 + 三类元知识)
|
| 18 |
+
├── predictions GVFBank
|
| 19 |
+
├── dynamics TransitionEnsemble
|
| 20 |
+
├── options OptionManager
|
| 21 |
+
├── uncertainty UncertaintyGate
|
| 22 |
+
├── coverage Coverage
|
| 23 |
+
└── plasticity Plasticity
|
| 24 |
+
|
| 25 |
+
## act() 的决策顺序
|
| 26 |
+
|
| 27 |
+
① 有 option 在跑且未终止 -> 沿 option 的动作序列走
|
| 28 |
+
② 否则取 base 的候选 -> 过**门控**; 被拒则退回已覆盖动作
|
| 29 |
+
③ 记录 (state, action) -> 供 dynamics/option 发现用
|
| 30 |
+
|
| 31 |
+
## E9 vs E10 就是一个 flag
|
| 32 |
+
|
| 33 |
+
use_options=False -> E9 (+coverage/uncertainty 门控)
|
| 34 |
+
use_options=True -> E10 (+Options)
|
| 35 |
+
两者共用同一 base 与同一门控, 差异**只**在于是否使用时间抽象。
|
| 36 |
+
"""
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
|
| 41 |
+
from hibs_lnn.knowledge import InternalKnowledge
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class OAKProposer:
|
| 45 |
+
def __init__(self, fine_desc, n_fine, k=3, seed=0, tau=0.5,
|
| 46 |
+
mu=0.05, alpha0=0.2, explore_w=0.5, algo="autostep",
|
| 47 |
+
n_know=0, use_options=True, use_gate=True,
|
| 48 |
+
n_models=5, refresh_every=4, min_transitions=40,
|
| 49 |
+
n_regions=4, max_opt_len=3, opt_frac=1.0,
|
| 50 |
+
opt_mode="fixed", term_eps=0.05, stall_tol=0.0,
|
| 51 |
+
dyn_model="linear", n_regions_opt=16, use_subgoals=0):
|
| 52 |
+
from hibs_lnn.rl_proposer import RLProposer
|
| 53 |
+
self.n_fine = int(n_fine)
|
| 54 |
+
self.n = self.n_fine
|
| 55 |
+
self.k = int(k)
|
| 56 |
+
self.seed = seed
|
| 57 |
+
self.use_options = bool(use_options)
|
| 58 |
+
self.use_gate = bool(use_gate)
|
| 59 |
+
self.refresh_every = int(refresh_every)
|
| 60 |
+
self.min_transitions = int(min_transitions)
|
| 61 |
+
self.n_regions = int(n_regions)
|
| 62 |
+
self.max_opt_len = int(max_opt_len)
|
| 63 |
+
# ★ 诊断用: 只在一部分轮次允许启用 option。
|
| 64 |
+
# 若"Options 有害"确实来自 option 执行本身 (提交一段固定动作序列 ->
|
| 65 |
+
# 减少自适应性 / 降低探索多样性), 那么伤害应当**随 opt_frac 单调放大**。
|
| 66 |
+
# 若伤害与用量无关, 说明另有来源 (如 discovery 本身在扰动状态)。
|
| 67 |
+
self.opt_frac = float(opt_frac)
|
| 68 |
+
self._opt_round = 0
|
| 69 |
+
# ★ opt_mode: Option 的**执行方式** (用户 2026-09-14 指导的 K1 矩阵)
|
| 70 |
+
# "fixed" O1 固定动作序列 = open-loop (旧行为, D1 的病灶)
|
| 71 |
+
# "goal" O2 目标条件 + **每步重算动作** (闭环)
|
| 72 |
+
# "goal_term" O3 O2 + 自适应终止 β_o(s)
|
| 73 |
+
# "goal_term_override" O4 O3 + 不确定性抢占 (Option 从属于当前证据)
|
| 74 |
+
self.opt_mode = str(opt_mode)
|
| 75 |
+
self.term_eps = float(term_eps)
|
| 76 |
+
self.stall_tol = float(stall_tol)
|
| 77 |
+
self._opt_stall_d = None
|
| 78 |
+
self.override_count = 0
|
| 79 |
+
self.term_reasons = {}
|
| 80 |
+
self.replan_steps = 0
|
| 81 |
+
self.gate_blocked = 0 # _goal_action 因门控全拒返回 None
|
| 82 |
+
self.no_finite_unc = 0 # _goal_action 因无有限不确定性返回 None
|
| 83 |
+
self.goal_none = 0 # _goal_action 返回 None 总次数
|
| 84 |
+
self.start_blocked = 0 # 起 option 时算不出动作 (静默空转) 的次数
|
| 85 |
+
# ★ option 有效期。原实现一旦启动就**永不重选** (实测: 1 个 option
|
| 86 |
+
# 连跑 105 步 = 训练全程的 87%)。那不是 option, 是"锁死"。
|
| 87 |
+
# 正确语义: option 是**有名义长度的技能**, 跑完它的长度就结束,
|
| 88 |
+
# 由外层重新评估该用哪个技能 (或交回 base policy)。
|
| 89 |
+
self._opt_age = 0
|
| 90 |
+
self._opt_max_age = int(max_opt_len)
|
| 91 |
+
# ★ 世界模型 (实测: 线性 ridge 表达不了结构化动力学 —— 在 KeyDoor 这类
|
| 92 |
+
# 离散/条件性动力学上四个动作预测到**同一个**下一状态, 转移图退化成
|
| 93 |
+
# 几乎无边, option 发现为 0。计数式表格模型修掉了这个。)
|
| 94 |
+
self.dyn_model_kind = str(dyn_model)
|
| 95 |
+
self.n_regions_opt = int(n_regions_opt)
|
| 96 |
+
self.use_subgoals = int(use_subgoals)
|
| 97 |
+
|
| 98 |
+
# Level 3: 复用已验证的 RL 提议器
|
| 99 |
+
self.base = RLProposer(fine_desc, tau=tau, k=k, mu=mu, alpha0=alpha0,
|
| 100 |
+
explore_w=explore_w, algo=algo, n_know=n_know,
|
| 101 |
+
seed=seed)
|
| 102 |
+
# ★ 内部知识**惰性创建**: 状态维度在构造时未知。
|
| 103 |
+
# 主回路的状态是 `row` = 每**粗域**准确率 (长度 = --domains),
|
| 104 |
+
# 而动作数是细区间数 n_fine —— 两个维度**不同**。
|
| 105 |
+
# (实测: 按 n_fine 建 GVF 会在 observe 时撞
|
| 106 |
+
# `size 3 is different from 6`)。
|
| 107 |
+
self._know_cfg = dict(n_models=n_models, seed=seed, alpha0=alpha0, mu=mu)
|
| 108 |
+
self.know = None
|
| 109 |
+
self.dim_state = None
|
| 110 |
+
self._state_scale = 0.0
|
| 111 |
+
|
| 112 |
+
# 转移日志: (state_t, action, state_{t+1})
|
| 113 |
+
self.trans = []
|
| 114 |
+
self._prev_state = None
|
| 115 |
+
self._prev_pick = None
|
| 116 |
+
self._last_acc = {}
|
| 117 |
+
self._opt_queue = [] # 正在执行的 option 剩余动作
|
| 118 |
+
self._opt_active = None
|
| 119 |
+
self.refresh_count = 0
|
| 120 |
+
self.untrusted_picks = 0 # 提议落在不可信区的次数 (仅诊断)
|
| 121 |
+
self.fallback_count = 0 # 保留字段 (旧: 门控重定向次数, 已废弃)
|
| 122 |
+
self.option_steps = 0 # 由 option 决定的步数
|
| 123 |
+
self.option_starts = 0 # option 被启动的次数
|
| 124 |
+
|
| 125 |
+
# ── 惰性初始化 ──────────────────────────────────────────────────
|
| 126 |
+
def _ensure_know(self, dim_state):
|
| 127 |
+
if self.know is not None:
|
| 128 |
+
return self.know
|
| 129 |
+
self.dim_state = int(dim_state)
|
| 130 |
+
self.know = InternalKnowledge(self.n_fine, self.dim_state, **self._know_cfg)
|
| 131 |
+
if self.dyn_model_kind == "tabular":
|
| 132 |
+
from hibs_lnn.uncertainty_gate import TabularTransition
|
| 133 |
+
self.know.dynamics_model = TabularTransition()
|
| 134 |
+
self.know.register_value_fn(
|
| 135 |
+
lambda s, a: float(self._last_acc.get(int(a), np.nan)),
|
| 136 |
+
policy="rl-base")
|
| 137 |
+
return self.know
|
| 138 |
+
|
| 139 |
+
# ── 记录环境反馈 ────────────────────────────────────────────────
|
| 140 |
+
def set_state(self, state):
|
| 141 |
+
"""每轮训练后由主回路喂入**新**能力画像。"""
|
| 142 |
+
st = np.asarray(state, dtype=float)
|
| 143 |
+
# 状态尺度 = 相邻两轮状态变化的典型幅度 (用于自适应阈值)
|
| 144 |
+
if getattr(self, "_prev_state", None) is not None:
|
| 145 |
+
_d = float(np.linalg.norm(st - np.asarray(self._prev_state, dtype=float)))
|
| 146 |
+
_a = getattr(self, "_state_scale", 0.0)
|
| 147 |
+
self._state_scale = _d if _a == 0.0 else 0.9 * _a + 0.1 * _d
|
| 148 |
+
self._ensure_know(st.shape[0])
|
| 149 |
+
if self._prev_state is not None and self._prev_pick is not None:
|
| 150 |
+
for a in (self._prev_pick if isinstance(self._prev_pick, (list, tuple))
|
| 151 |
+
else [self._prev_pick]):
|
| 152 |
+
self.trans.append((self._prev_state.copy(), int(a), st.copy()))
|
| 153 |
+
self.know.observe_action(int(a))
|
| 154 |
+
self._prev_state = st
|
| 155 |
+
self._last_acc = {i: float(v) for i, v in enumerate(st)
|
| 156 |
+
if np.isfinite(v)}
|
| 157 |
+
|
| 158 |
+
def set_pick(self, pick):
|
| 159 |
+
"""记录本轮真正执行的动作。"""
|
| 160 |
+
self._prev_pick = list(pick) if isinstance(pick, (list, tuple)) else [pick]
|
| 161 |
+
|
| 162 |
+
# ── 动作选择 ───────────────────────────────────────────────────
|
| 163 |
+
def act(self):
|
| 164 |
+
# ① option 在执行中
|
| 165 |
+
if self.use_options and self._opt_active is not None and self.opt_mode != "fixed":
|
| 166 |
+
o = self._opt_active
|
| 167 |
+
s_now = self._prev_state
|
| 168 |
+
if s_now is None:
|
| 169 |
+
self._opt_active = None
|
| 170 |
+
else:
|
| 171 |
+
_, unc_a = self.know.predict(s_now, int(o.actions[0]))
|
| 172 |
+
tau = self.know.uncertainty.tau_U
|
| 173 |
+
if self.opt_mode == "goal_term_override":
|
| 174 |
+
if (not np.isfinite(unc_a)) or (tau and unc_a >= tau):
|
| 175 |
+
self.override_count += 1
|
| 176 |
+
self.term_reasons["override"] = self.term_reasons.get("override", 0) + 1
|
| 177 |
+
self._opt_active = None
|
| 178 |
+
return list(self.base.act())
|
| 179 |
+
# ★ 有效期检查 (对**所有**闭环模式生效)
|
| 180 |
+
self._opt_age += 1
|
| 181 |
+
if self._opt_age > self._opt_max_age:
|
| 182 |
+
self.term_reasons["expired"] = self.term_reasons.get("expired", 0) + 1
|
| 183 |
+
self._opt_active = None
|
| 184 |
+
self._opt_age = 0
|
| 185 |
+
return list(self.base.act())
|
| 186 |
+
if self.opt_mode in ("goal_term", "goal_term_override"):
|
| 187 |
+
done, why = o.terminated(
|
| 188 |
+
s_now, unc=unc_a, eps=self._eps_eff(),
|
| 189 |
+
tau_U=(tau if self.opt_mode == "goal_term_override" else None),
|
| 190 |
+
stall=self.stall_tol)
|
| 191 |
+
if done:
|
| 192 |
+
self.term_reasons[why] = self.term_reasons.get(why, 0) + 1
|
| 193 |
+
self._opt_active = None
|
| 194 |
+
return list(self.base.act())
|
| 195 |
+
a = self._goal_action(o)
|
| 196 |
+
if a is None:
|
| 197 |
+
self._opt_active = None
|
| 198 |
+
else:
|
| 199 |
+
self.replan_steps += 1
|
| 200 |
+
self.option_steps += 1
|
| 201 |
+
return [int(a)]
|
| 202 |
+
if self.use_options and self._opt_queue and self.opt_mode == "fixed":
|
| 203 |
+
a = int(self._opt_queue.pop(0))
|
| 204 |
+
self.option_steps += 1
|
| 205 |
+
if not self._opt_queue:
|
| 206 |
+
self._opt_active = None
|
| 207 |
+
return [a]
|
| 208 |
+
|
| 209 |
+
pick = list(self.base.act())
|
| 210 |
+
|
| 211 |
+
# ② ★ 门控**不用于 action selection**。
|
| 212 |
+
# 早先版本在这里把零覆盖动作重定向到 `argmin(visits)` 的已覆盖动作,
|
| 213 |
+
# 结果是灾难性的: 一旦某个动作被覆盖, 门控就把**所有**提议都指向它
|
| 214 |
+
# (实测 `per_action = [0,9,0,0,0,0]`, 9 轮只练了 1 个区间, 探索崩塌)。
|
| 215 |
+
#
|
| 216 |
+
# 语义纠正 (Q8 原文): "目标策略必须在现有经验中得到足够的信息支持" ——
|
| 217 |
+
# 这约束的是**模型驱动的规划决策** (凭预测去选), **不是**阻止 base
|
| 218 |
+
# policy 去探索。往零覆盖区探索恰恰是想要的: 覆盖度只能这样长起来。
|
| 219 |
+
# 所以门控只作用在**模型被咨询**的地方 (option 选择 / rollout),
|
| 220 |
+
# 那两处已由 OptionManager.select(exclude_unc=True) 与
|
| 221 |
+
# ens.rollout(gate=...) 各自保证。
|
| 222 |
+
if self.use_gate and self.know is not None:
|
| 223 |
+
# 只**记录**当前提议是否落在不可信区 (诊断用, 不做干预)
|
| 224 |
+
for a in pick:
|
| 225 |
+
if int(a) < self.n_fine:
|
| 226 |
+
_, unc = self.know.predict(self._prev_state
|
| 227 |
+
if self._prev_state is not None
|
| 228 |
+
else np.zeros(self.dim_state or 1), int(a))
|
| 229 |
+
tau = self.know.uncertainty.tau_U
|
| 230 |
+
if (not np.isfinite(unc)) or (tau and unc >= tau):
|
| 231 |
+
self.untrusted_picks += 1
|
| 232 |
+
|
| 233 |
+
# ③ 起一个新 option (受 opt_frac 限制)
|
| 234 |
+
self._opt_round += 1
|
| 235 |
+
_allow_opt = (self.opt_frac >= 1.0
|
| 236 |
+
or (self.opt_frac > 0 and
|
| 237 |
+
(self._opt_round % max(1, int(round(1.0 / self.opt_frac)))) == 0))
|
| 238 |
+
if (_allow_opt and self.use_options and self.know is not None
|
| 239 |
+
and self.know.options is not None and self._prev_state is not None):
|
| 240 |
+
o = self.know.options.select(self._prev_state, exclude_unc=True)
|
| 241 |
+
if o is not None and len(getattr(o, "actions", [])) > 1:
|
| 242 |
+
self._opt_active = o
|
| 243 |
+
# ★ 返回 option 的**第一个**动作。早先实现假设"起点的那个动作
|
| 244 |
+
# 已经执行过了", 于是只排队 actions[1:] —— 结果 option 的语义
|
| 245 |
+
# 只被用了一半, option_steps 实测只有 1~2 步。
|
| 246 |
+
self._opt_queue = [int(x) for x in o.actions[1:]]
|
| 247 |
+
if self.opt_mode == "fixed":
|
| 248 |
+
self._opt_queue = [int(x) for x in o.actions[1:]]
|
| 249 |
+
self.option_starts += 1
|
| 250 |
+
return [int(o.actions[0])]
|
| 251 |
+
a = self._goal_action(o) # 闭环: 立刻按当前状态算
|
| 252 |
+
if a is None:
|
| 253 |
+
# ★ 计数诚实性: 算不出动作的 option **不算启动**。
|
| 254 |
+
# 早先实现先 `option_starts += 1` 再判断, 于是"启动了 N 次但 0 步执行"
|
| 255 |
+
# 看起来像机制在跑, 实际是 inert -> 一个**静默的空转实验**。
|
| 256 |
+
self._opt_active = None
|
| 257 |
+
self.start_blocked += 1
|
| 258 |
+
return pick
|
| 259 |
+
self.option_starts += 1
|
| 260 |
+
self.replan_steps += 1
|
| 261 |
+
self.option_steps += 1
|
| 262 |
+
return [int(a)]
|
| 263 |
+
return pick
|
| 264 |
+
|
| 265 |
+
def _eps_eff(self):
|
| 266 |
+
"""目标达成阈值 —— **必须与状态空间尺度匹配**。
|
| 267 |
+
|
| 268 |
+
原实现固定 term_eps=0.05 (给"距离"用的量级), 但状态是**准确率向量**,
|
| 269 |
+
单步变化就有 0.1~0.5 -> ‖s−g‖ < 0.05 几乎不可能 -> β_o 永不触发
|
| 270 |
+
-> `goal_term` 与 `goal` 给出**逐位相同**的结果 (实测: term={}).
|
| 271 |
+
"""
|
| 272 |
+
sc = getattr(self, "_state_scale", 0.0)
|
| 273 |
+
return max(0.05, 0.25 * sc) if sc > 0 else 0.05
|
| 274 |
+
|
| 275 |
+
def _goal_action(self, opt):
|
| 276 |
+
"""π_o(s_t) —— **按当前状态重算**动作, 而不是回放固定序列。
|
| 277 |
+
|
| 278 |
+
a = argmax_a [ ‖g−s‖ − ‖g−T(s,a)‖ ]
|
| 279 |
+
即"哪一步动作让我朝子目标前进最多"。
|
| 280 |
+
|
| 281 |
+
★ 用户指出的核心修正: **抽象的是目标, 不是动作**。
|
| 282 |
+
固定序列执行 = "��发现了一段过去有效的脚本, 现在重放它",
|
| 283 |
+
状态变化不再触发动作重算 -> 适应性下降 (D1 实测 r=-0.80)。
|
| 284 |
+
"""
|
| 285 |
+
s_now = self._prev_state
|
| 286 |
+
g = np.asarray(opt.goal_center, dtype=float)
|
| 287 |
+
d_now = float(np.linalg.norm(np.asarray(s_now, dtype=float) - g))
|
| 288 |
+
best_a, best_gain = None, -np.inf
|
| 289 |
+
n_gate_rej = 0
|
| 290 |
+
n_unc_bad = 0
|
| 291 |
+
for a in range(self.n_fine):
|
| 292 |
+
if self.use_gate:
|
| 293 |
+
ok, _ = self.know.uncertainty.allow(s_now, a, self.know.coverage.n)
|
| 294 |
+
if not ok:
|
| 295 |
+
n_gate_rej += 1
|
| 296 |
+
continue
|
| 297 |
+
s_next, unc = self.know.predict(s_now, a)
|
| 298 |
+
if not np.isfinite(unc):
|
| 299 |
+
n_unc_bad += 1
|
| 300 |
+
continue
|
| 301 |
+
gain = d_now - float(np.linalg.norm(np.asarray(s_next, dtype=float) - g))
|
| 302 |
+
if gain > best_gain:
|
| 303 |
+
best_a, best_gain = a, gain
|
| 304 |
+
if best_a is None:
|
| 305 |
+
self.goal_none += 1
|
| 306 |
+
if n_gate_rej == self.n_fine:
|
| 307 |
+
self.gate_blocked += 1
|
| 308 |
+
elif n_unc_bad > 0:
|
| 309 |
+
self.no_finite_unc += 1
|
| 310 |
+
return best_a
|
| 311 |
+
|
| 312 |
+
# ── 奖励 → 参数更新 (+ 知识维护) ────────────────────────────────
|
| 313 |
+
def observe(self, pick, acc, any_time):
|
| 314 |
+
self.base.observe(pick, acc=acc, any_time=any_time)
|
| 315 |
+
|
| 316 |
+
def update(self, pick):
|
| 317 |
+
self.base.update(pick)
|
| 318 |
+
# ★★ 必须在这里把**实际执行的动作**记进 coverage。
|
| 319 |
+
# 实测抓到的 bug(第 4 次同型): `InternalKnowledge.observe_action()` 是
|
| 320 |
+
# coverage 的**唯一写入点**, 但主回路从来没调过它 -> `coverage.n` 永远全零
|
| 321 |
+
# -> `UncertaintyGate.allow()` 对**所有**动作返回 False
|
| 322 |
+
# (因为 `0 <= tau_C=1.0`) -> `_goal_action()` 的每一个候选都被 continue 掉
|
| 323 |
+
# -> `best_a = None` -> 回落到 base -> 轨迹与 E9 **逐位相同**
|
| 324 |
+
# (实测证据: goal/goal_term/override 三档的 alpha 与 e9 完全相同,
|
| 325 |
+
# `option_starts > 0` 但 `option_steps == 0`)。
|
| 326 |
+
if self.know is not None:
|
| 327 |
+
_pick = pick if isinstance(pick, (list, tuple)) else [pick]
|
| 328 |
+
for _a in _pick:
|
| 329 |
+
if int(_a) >= 0:
|
| 330 |
+
self.know.observe_action(int(_a))
|
| 331 |
+
# 预测知识: cumulant = 各区间能力本身 (GVF 学"能力会怎么走")
|
| 332 |
+
if self.know is not None and self._prev_state is not None:
|
| 333 |
+
phi = self._prev_state
|
| 334 |
+
cums = [float(np.mean(phi)),
|
| 335 |
+
float(np.max(phi) - np.min(phi)),
|
| 336 |
+
float(np.std(phi)),
|
| 337 |
+
float(np.mean(np.diff(np.sort(phi)[::-1][:3])) if self.n_fine >= 3 else 0.0)]
|
| 338 |
+
self.know.observe_gvf(phi, cums, phi)
|
| 339 |
+
# 周期性重建 world model + 重新发现 option
|
| 340 |
+
if (self.know is not None and len(self.trans) >= self.min_transitions
|
| 341 |
+
and self.refresh_count < len(self.trans) // max(1, self.refresh_every)):
|
| 342 |
+
self.refresh()
|
| 343 |
+
self.refresh_count += 1
|
| 344 |
+
|
| 345 |
+
def refresh(self):
|
| 346 |
+
"""把转移日志灌进 dynamics ensemble -> 标定门控 -> 重新发现 options。"""
|
| 347 |
+
if self.know is None:
|
| 348 |
+
return
|
| 349 |
+
try:
|
| 350 |
+
X = np.array([np.append(s, a) for s, a, _ in self.trans])
|
| 351 |
+
Y = np.array([sp for _, _, sp in self.trans])
|
| 352 |
+
self.know.fit_dynamics(X, Y)
|
| 353 |
+
from hibs_lnn.option_manager import OptionManager
|
| 354 |
+
om = OptionManager(self.know.dynamics_model, self.know.uncertainty,
|
| 355 |
+
max_len=self.max_opt_len, seed=self.seed)
|
| 356 |
+
states = np.array([s for s, _, _ in self.trans])
|
| 357 |
+
acts = np.array([a for _, a, _ in self.trans])
|
| 358 |
+
# ★ 表格模型下按**真实状态**建图 (不是聚类中心)
|
| 359 |
+
n_uniq = len(np.unique(np.round(states, 6), axis=0))
|
| 360 |
+
nreg = (min(self.n_regions_opt, max(4, n_uniq))
|
| 361 |
+
if self.dyn_model_kind == "tabular"
|
| 362 |
+
else min(self.n_regions, max(2, len(states) // 10)))
|
| 363 |
+
if self.use_subgoals:
|
| 364 |
+
om.discover_subgoals(states, acts, n_regions=nreg)
|
| 365 |
+
else:
|
| 366 |
+
om.discover(states, acts, n_regions=nreg)
|
| 367 |
+
self.know.register_options(om)
|
| 368 |
+
except Exception as e: # 知识重建失败不该拖垮主实验
|
| 369 |
+
print("!! knowledge refresh 失败:", e, flush=True)
|
| 370 |
+
|
| 371 |
+
# ── 诊断 ───────────────────────────────────────────────────────
|
| 372 |
+
def stats(self):
|
| 373 |
+
s = self.base.proposer_stats() if hasattr(self.base, "proposer_stats") else {}
|
| 374 |
+
# ★ 真步长统计: 取自 base 的最近一条 trace (RLProposer 在那里记录 α)
|
| 375 |
+
_tr = getattr(self.base, "trace", None) or []
|
| 376 |
+
if _tr:
|
| 377 |
+
s["alpha_mean"] = _tr[-1].get("alpha_mean")
|
| 378 |
+
s["alpha_std"] = _tr[-1].get("alpha_std")
|
| 379 |
+
s["h_norm"] = _tr[-1].get("h_norm")
|
| 380 |
+
if self.know is None:
|
| 381 |
+
s.update({"n_trans": len(self.trans), "refreshes": 0, "n_options": 0,
|
| 382 |
+
"option_steps": self.option_steps,
|
| 383 |
+
"opt_frac": self.opt_frac,
|
| 384 |
+
"opt_mode": self.opt_mode,
|
| 385 |
+
"dyn_model": self.dyn_model_kind,
|
| 386 |
+
"use_subgoals": self.use_subgoals,
|
| 387 |
+
"replan_steps": self.replan_steps,
|
| 388 |
+
"state_scale": float(getattr(self, "_state_scale", 0.0)),
|
| 389 |
+
"eps_eff": float(self._eps_eff()),
|
| 390 |
+
"override_count": self.override_count,
|
| 391 |
+
"term_reasons": dict(self.term_reasons),
|
| 392 |
+
"option_starts": self.option_starts, "untrusted_picks": self.untrusted_picks,
|
| 393 |
+
"coverage_total": 0, "tau_U": None, "gvf_steps": [], "alpha_std": 0.0})
|
| 394 |
+
return s
|
| 395 |
+
s.update({
|
| 396 |
+
"n_trans": len(self.trans),
|
| 397 |
+
"refreshes": self.refresh_count,
|
| 398 |
+
"n_options": len(self.know.skills()),
|
| 399 |
+
"option_steps": self.option_steps,
|
| 400 |
+
"option_starts": self.option_starts,
|
| 401 |
+
"opt_frac": self.opt_frac,
|
| 402 |
+
"opt_mode": self.opt_mode,
|
| 403 |
+
"dyn_model": self.dyn_model_kind,
|
| 404 |
+
"use_subgoals": self.use_subgoals,
|
| 405 |
+
"replan_steps": self.replan_steps,
|
| 406 |
+
"override_count": self.override_count,
|
| 407 |
+
"gate_blocked": self.gate_blocked, "no_finite_unc": self.no_finite_unc,
|
| 408 |
+
"start_blocked": self.start_blocked,
|
| 409 |
+
"goal_none": self.goal_none,
|
| 410 |
+
"term_reasons": dict(self.term_reasons),
|
| 411 |
+
"untrusted_picks": self.untrusted_picks,
|
| 412 |
+
"coverage_total": int(self.know.coverage.n_total),
|
| 413 |
+
"tau_U": self.know.uncertainty.tau_U,
|
| 414 |
+
"gvf_steps": [g.steps for g in self.know.predictions.gvfs],
|
| 415 |
+
# ★ 不要放在 "alpha_std" 这个键上 —— 它会**覆盖** base.proposer_stats()
|
| 416 |
+
# 里真正的步长统计。早先版本就是这么写的, 结果 JSON 里几个不同算法
|
| 417 |
+
# (idbd / idbd-raw / cidbd) 的 alpha_std 全是同一个**未被更新**的死对象
|
| 418 |
+
# 的值 (2.78e-17), 看起来像"算法不生效"。真正在用的 α 在 base.trace。
|
| 419 |
+
"know_alpha_std": float(self.know.plasticity.alpha.std()),
|
| 420 |
+
})
|
| 421 |
+
return s
|
| 422 |
+
|
| 423 |
+
def stats_extra(self):
|
| 424 |
+
"""主实验落盘用。"""
|
| 425 |
+
if self.know is None:
|
| 426 |
+
return {"knowledge": {"n_options": 0, "coverage": {}, "plasticity": {},
|
| 427 |
+
"tau_U": None, "options": []},
|
| 428 |
+
"oak": {"use_options": self.use_options, "use_gate": self.use_gate,
|
| 429 |
+
"n_trans": len(self.trans), "refreshes": 0,
|
| 430 |
+
"option_steps": self.option_steps,
|
| 431 |
+
"option_starts": self.option_starts,
|
| 432 |
+
"untrusted_picks": self.untrusted_picks}}
|
| 433 |
+
d = self.know.describe()
|
| 434 |
+
return {"knowledge": {
|
| 435 |
+
"n_options": d["Level4_abstraction"]["n_options"],
|
| 436 |
+
"coverage": d["meta_coverage"],
|
| 437 |
+
"plasticity": d["meta_plasticity"],
|
| 438 |
+
"tau_U": d["meta_uncertainty"]["tau_U"],
|
| 439 |
+
"options": d["Level4_abstraction"]["options"],
|
| 440 |
+
}, "oak": {"use_options": self.use_options, "use_gate": self.use_gate,
|
| 441 |
+
"n_trans": len(self.trans), "refreshes": self.refresh_count,
|
| 442 |
+
"option_steps": self.option_steps,
|
| 443 |
+
"option_starts": self.option_starts,
|
| 444 |
+
"untrusted_picks": self.untrusted_picks}}
|
hibs_lnn/option_manager.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""option_manager.py — Options / 时间抽象 (OaK 的 O)。
|
| 2 |
+
|
| 3 |
+
## Option 的三要素 (用户定义)
|
| 4 |
+
|
| 5 |
+
o = (I_o, π_o, β_o)
|
| 6 |
+
|
| 7 |
+
I_o initiation set 什么时候可以启动这个 option
|
| 8 |
+
π_o option policy 启动后怎么行动 (这里是**一串子动作**, 不是单个动作)
|
| 9 |
+
β_o termination 什么时候结束 —— **学习式** P(terminate | s, o)
|
| 10 |
+
|
| 11 |
+
## ★ 不从 reward 定义 option, 而从「可预测的状态转移结构」发现
|
| 12 |
+
|
| 13 |
+
用户: *"Option 不一定首先由 reward 定义, 而可以由可预测的状态转移结构产生。"*
|
| 14 |
+
|
| 15 |
+
我们已有 `TransitionEnsemble` (带分歧 U_T)。发现流程:
|
| 16 |
+
|
| 17 |
+
1. 用过渡模型把能力状态 s 映射到预测状态 ŝ, 聚类成 region
|
| 18 |
+
2. 建 region 转移图: 边 (A --u--> B) 的**可预测性** = 1/(1+U_T)
|
| 19 |
+
3. 在图上找多步可靠路径 A → C (要求每一步 U_T < τ_U)
|
| 20 |
+
4. 每条这样的路径 = 一个 option: π_o = 该动作序列, I_o = A 区域
|
| 21 |
+
5. β_o 用真实观测学: P(到达目标区域 | s, o) —— 学习式终止, 不是固定阈值
|
| 22 |
+
|
| 23 |
+
**为什么这比手工写 "Option1=加磁通 / Option2=稳定" 强**: 手工定义只编码了人类先验;
|
| 24 |
+
从转移结构发现是在编码 **"哪些行为的后果我可以可靠预测"** —— 那才是可利用的时间抽象。
|
| 25 |
+
|
| 26 |
+
## 与 planning 的衔接
|
| 27 |
+
|
| 28 |
+
高层: s_t → 选 option → **rollout transition model** → 选动作序列
|
| 29 |
+
这正是 OaK 的 abstraction → transition model → planning 路线。
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class Option:
|
| 37 |
+
"""o = (I_o, π_o, β_o)。"""
|
| 38 |
+
|
| 39 |
+
def __init__(self, oid, actions, start_center, goal_center, uncs=None,
|
| 40 |
+
regions=None, start_region=None, goal_region=None):
|
| 41 |
+
self.oid = int(oid)
|
| 42 |
+
self.actions = list(actions) # π_o: 动作序列 (时间抽象的核心)
|
| 43 |
+
self.start_center = np.asarray(start_center, dtype=float) # I_o 的中心
|
| 44 |
+
self.goal_center = np.asarray(goal_center, dtype=float) # 目标区域
|
| 45 |
+
self.uncs = list(uncs or []) # 逐步不确定性 (用于可信度)
|
| 46 |
+
self.visits = 0
|
| 47 |
+
self.success = 0
|
| 48 |
+
# β_o 的线性参数: P(terminate | s, o) = sigmoid(w·[s, goal, unc])
|
| 49 |
+
self.w_beta = np.zeros(3 * len(self.goal_center) + 1)
|
| 50 |
+
# ── ★ 区域归属 (无阈值的终止判据) ────────────────────────────
|
| 51 |
+
# 旧判据 `‖s − goal_center‖ < eps` 在**离散状态空间**里会退化:
|
| 52 |
+
# KeyDoor 的 vec() 是 one-hot(pos) ⊕ has_key ⊕ door_open, 两个不同
|
| 53 |
+
# 位置的距离是 √2≈1.414、仅一个 bit 不同的是 1.0, 而
|
| 54 |
+
# `init_radius = max(0.5, median(NN)·0.75) = 0.754` **只接受精确到达**
|
| 55 |
+
# → 实测 β_o 因达成而触发的比例 **0/2737**, 全部靠预算耗尽退出。
|
| 56 |
+
# 正确判据: "s 是否落在该 option 的目标**区域**里" —— 用 k-means 的
|
| 57 |
+
# Voronoi 归属 (argmin ‖s − centers‖) 判定, 无阈值、覆盖全空间。
|
| 58 |
+
self.regions = None if regions is None else np.asarray(regions, dtype=float)
|
| 59 |
+
self.start_region = start_region
|
| 60 |
+
self.goal_region = goal_region
|
| 61 |
+
|
| 62 |
+
# ── ★ 区域归属 ──
|
| 63 |
+
def region_of(self, s):
|
| 64 |
+
"""s 落在哪个区域 (k-means Voronoi 归属)。regions 未设时返回 None。"""
|
| 65 |
+
if self.regions is None or len(self.regions) == 0:
|
| 66 |
+
return None
|
| 67 |
+
d = np.linalg.norm(self.regions - np.asarray(s, dtype=float)[None, :], axis=1)
|
| 68 |
+
return int(np.argmin(d))
|
| 69 |
+
|
| 70 |
+
def in_goal_region(self, s):
|
| 71 |
+
"""β_o 的**主判据**: s 是否已进入目标区域。"""
|
| 72 |
+
if self.goal_region is None:
|
| 73 |
+
return False
|
| 74 |
+
return self.region_of(s) == self.goal_region
|
| 75 |
+
|
| 76 |
+
# ── I_o: 启动条件 (到起点的距离在半径内) ──
|
| 77 |
+
def initiable(self, s, radius=0.5):
|
| 78 |
+
# ★ 优先用区域归属 (与 in_goal_region 对称, 无阈值退化问题);
|
| 79 |
+
# 未设区域时退回距离判据 (保留旧行为)。
|
| 80 |
+
if self.start_region is not None:
|
| 81 |
+
r = self.region_of(s)
|
| 82 |
+
if r is not None and r == self.start_region:
|
| 83 |
+
return True
|
| 84 |
+
return float(np.linalg.norm(np.asarray(s) - self.start_center)) <= radius
|
| 85 |
+
|
| 86 |
+
# ── β_o: **学习式终止概率** ──
|
| 87 |
+
def _beta_feat(self, s, unc=0.0):
|
| 88 |
+
s = np.asarray(s, dtype=float)
|
| 89 |
+
g = self.goal_center
|
| 90 |
+
return np.concatenate([s, g - s, [unc, 1.0]])
|
| 91 |
+
|
| 92 |
+
def beta_prob(self, s, unc=0.0):
|
| 93 |
+
z = float(self.w_beta @ self._beta_feat(s, unc))
|
| 94 |
+
return 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))
|
| 95 |
+
|
| 96 |
+
def learn_termination(self, samples, lr=0.05, epochs=200):
|
| 97 |
+
"""从真实观测学 β_o。samples: [(s, unc, terminated_bool), ...]
|
| 98 |
+
|
| 99 |
+
这才是「学习式 termination」—— 而不是 `if error < threshold: stop()`。
|
| 100 |
+
"""
|
| 101 |
+
if not samples:
|
| 102 |
+
return
|
| 103 |
+
F = np.array([self._beta_feat(s, u) for s, u, _ in samples])
|
| 104 |
+
y = np.array([1.0 if t else 0.0 for _, _, t in samples])
|
| 105 |
+
for _ in range(epochs):
|
| 106 |
+
p = 1.0 / (1.0 + np.exp(-np.clip(F @ self.w_beta, -30, 30)))
|
| 107 |
+
grad = F.T @ (p - y) / len(y)
|
| 108 |
+
self.w_beta -= lr * grad
|
| 109 |
+
return self
|
| 110 |
+
|
| 111 |
+
def should_terminate(self, s, unc=0.0, thresh=0.5):
|
| 112 |
+
return self.beta_prob(s, unc) >= thresh
|
| 113 |
+
|
| 114 |
+
# ── ★ 闭环保守派接口 (用户 2026-09-14 指导) ──────────────────────
|
| 115 |
+
def goal_distance(self, s):
|
| 116 |
+
"""到子目标的距离 —— 这是 β_o 与 π_o 共用的量。"""
|
| 117 |
+
return float(np.linalg.norm(np.asarray(s, dtype=float) - self.goal_center))
|
| 118 |
+
|
| 119 |
+
def terminated(self, s, unc=0.0, eps=0.05, tau_U=None, stall=0.0):
|
| 120 |
+
"""自适应终止 β_o(s): 任一条件命中即退出。
|
| 121 |
+
|
| 122 |
+
β_o(s) = 1 若 ⓪ 进入**目标区域** in_goal_region(s) ← 主判据
|
| 123 |
+
① 目标达成 ||s - g|| < eps
|
| 124 |
+
② 不确定性过高 U_T > tau_U (风险升高)
|
| 125 |
+
③ 停滞 本轮进展 < stall
|
| 126 |
+
β_o(s) = 0 否则
|
| 127 |
+
|
| 128 |
+
★ ⓪ 是本轮新增的主判据。旧判据仅 ①(距离 < eps), 在**离散或高维**
|
| 129 |
+
状态空间里会退化 —— 实测 (KeyDoor, `vec()` 为 one-hot ⊕ 2 bit):
|
| 130 |
+
距离离散化为 {0, 1, 1.732, 2}, 而 eps=0.754 只接受精确到达
|
| 131 |
+
→ 2737 次执行中因「达成」触发 **0 次**, 全部靠预算耗尽退出。
|
| 132 |
+
区域归属 (k-means Voronoi) 无阈值、覆盖全空间, 故设为主判据;
|
| 133 |
+
① 保留为兜底 (regions 未设时行为不变)。
|
| 134 |
+
|
| 135 |
+
★ 固定长度执行是 open-loop 的病根: "即使当前情况已经不适合这个 option,
|
| 136 |
+
它可能还在继续执行"。这里让 option **每步都可以退出**。
|
| 137 |
+
"""
|
| 138 |
+
if self.in_goal_region(s):
|
| 139 |
+
return True, "goal_reached"
|
| 140 |
+
if self.goal_distance(s) < eps:
|
| 141 |
+
return True, "goal_reached"
|
| 142 |
+
if tau_U is not None and np.isfinite(unc) and unc > tau_U:
|
| 143 |
+
return True, "high_uncertainty"
|
| 144 |
+
if stall > 0 and getattr(self, "_last_d", None) is not None:
|
| 145 |
+
if self._last_d - self.goal_distance(s) < stall:
|
| 146 |
+
return True, "stalled"
|
| 147 |
+
return False, ""
|
| 148 |
+
|
| 149 |
+
def progress(self, s_from, s_to):
|
| 150 |
+
"""朝目标前进多少 (正 = 更近)。"""
|
| 151 |
+
return self.goal_distance(s_from) - self.goal_distance(s_to)
|
| 152 |
+
|
| 153 |
+
def stats(self):
|
| 154 |
+
rate = (self.success / self.visits) if self.visits else 0.0
|
| 155 |
+
# ★ 必须暴露 actions: 多步**动作序列**正是 option 的全部意义
|
| 156 |
+
# (时间抽象 = s -> a1..ak)。早先只返回 `len`, 日志里全是 actions=None,
|
| 157 |
+
# 等于把 option 最有信息量的部分丢了。
|
| 158 |
+
return {"oid": self.oid, "len": len(self.actions),
|
| 159 |
+
"actions": [int(x) for x in self.actions],
|
| 160 |
+
"start": np.round(self.start_center, 3).tolist(),
|
| 161 |
+
"goal": np.round(self.goal_center, 3).tolist(),
|
| 162 |
+
"visits": self.visits, "success_rate": round(rate, 3),
|
| 163 |
+
"mean_unc": round(float(np.mean(self.uncs)) if self.uncs else 0.0, 5)}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class OptionManager:
|
| 167 |
+
"""从可预测的状态转移结构中**发现** options。"""
|
| 168 |
+
|
| 169 |
+
def __init__(self, ensemble, gate, max_len=4, min_edges=3, seed=0):
|
| 170 |
+
self.T = ensemble
|
| 171 |
+
self.gate = gate
|
| 172 |
+
self.max_len = int(max_len)
|
| 173 |
+
# 启动集半径: 由 discover 按区域尺度自适应推断
|
| 174 |
+
self.init_radius = 0.75
|
| 175 |
+
self.min_edges = int(min_edges)
|
| 176 |
+
self.options = []
|
| 177 |
+
self.regions = None # region 中心
|
| 178 |
+
self.graph = {} # (region, action) -> (next_region, unc)
|
| 179 |
+
self.rng = np.random.default_rng(seed)
|
| 180 |
+
|
| 181 |
+
# ───────── 发现 ─────────
|
| 182 |
+
def discover(self, states, actions, n_regions=4, tau_U=None):
|
| 183 |
+
"""输入: 观测到的 (状态, 动作) 对。输出: options 列表。
|
| 184 |
+
|
| 185 |
+
states : (N, dim_x) 能力状态
|
| 186 |
+
actions: (N,) 当时训练了哪个域
|
| 187 |
+
"""
|
| 188 |
+
S = np.asarray(states, dtype=float)
|
| 189 |
+
A = np.asarray(actions, dtype=int)
|
| 190 |
+
n_dom = int(A.max()) + 1 if len(A) else 1
|
| 191 |
+
visits = np.zeros(n_dom)
|
| 192 |
+
for a in A:
|
| 193 |
+
visits[a] += 1
|
| 194 |
+
|
| 195 |
+
# 1) 聚类成 region (用最简单可复现的 k-means, 避免引入依赖)
|
| 196 |
+
self.regions, assign = self._kmeans(S, n_regions)
|
| 197 |
+
|
| 198 |
+
# 2) 建转移图: 对每个 (region, action) 预测落点与不确定性
|
| 199 |
+
self.graph = {}
|
| 200 |
+
for r in range(n_regions):
|
| 201 |
+
for u in range(n_dom):
|
| 202 |
+
c = self.regions[r]
|
| 203 |
+
s_next, unc = self.T.predict(c, u, visits) # 已是绝对状态
|
| 204 |
+
nxt = self._nearest_region(s_next)
|
| 205 |
+
self.graph[(r, u)] = (nxt, unc)
|
| 206 |
+
|
| 207 |
+
# 3) 定 τ_U (若未给) —— 用图上所有边的分歧分位
|
| 208 |
+
uncs = [self.graph[k][1] for k in self.graph]
|
| 209 |
+
uncs = [u for u in uncs if np.isfinite(u)]
|
| 210 |
+
if tau_U is None:
|
| 211 |
+
# 用**中位数**而非高分位: 我们要的是「一半的可预测边」, 不是几乎全部。
|
| 212 |
+
tau_U = float(np.median(uncs)) if uncs else float("inf")
|
| 213 |
+
self.tau_U = tau_U
|
| 214 |
+
|
| 215 |
+
# 4) 找可靠路径 (每步 unc < tau_U 且确实前进了) = options
|
| 216 |
+
S = np.asarray(states, dtype=float)
|
| 217 |
+
self.options = []
|
| 218 |
+
oid = 0
|
| 219 |
+
for r0 in range(n_regions):
|
| 220 |
+
for L in range(2, self.max_len + 1):
|
| 221 |
+
for path in self._reliable_paths(r0, L, tau_U):
|
| 222 |
+
acts = path
|
| 223 |
+
goal_r = self._walk(r0, acts)
|
| 224 |
+
if goal_r == r0:
|
| 225 |
+
continue # 没前进, 不是 option
|
| 226 |
+
uncs_p = [self.graph[(self._walk(r0, acts[:i]), acts[i])][1]
|
| 227 |
+
for i in range(len(acts))]
|
| 228 |
+
# ★ 起点/目标吸附到**真实观测状态** (质心可能够不着)
|
| 229 |
+
# + 带上区域归属 (无阈值的 β_o 判据)
|
| 230 |
+
self.options.append(Option(
|
| 231 |
+
oid, acts, self._snap(S, self.regions[r0]),
|
| 232 |
+
self._snap(S, self.regions[goal_r]), uncs_p,
|
| 233 |
+
regions=self.regions, start_region=r0, goal_region=goal_r))
|
| 234 |
+
oid += 1
|
| 235 |
+
# 去重 (同起点同目标保留最短的)
|
| 236 |
+
best = {}
|
| 237 |
+
for o in self.options:
|
| 238 |
+
k = (tuple(np.round(o.start_center, 3)), tuple(np.round(o.goal_center, 3)))
|
| 239 |
+
if k not in best or len(o.actions) < len(best[k].actions):
|
| 240 |
+
best[k] = o
|
| 241 |
+
self.options = list(best.values())
|
| 242 |
+
for i, o in enumerate(self.options):
|
| 243 |
+
o.oid = i
|
| 244 |
+
# 自适应启动集半径: 最近邻区域距离中位数的 0.75 倍 (与状态空间尺度匹配)
|
| 245 |
+
if len(self.regions) > 1:
|
| 246 |
+
_D = np.linalg.norm(self.regions[:, None, :] - self.regions[None, :, :], axis=-1)
|
| 247 |
+
np.fill_diagonal(_D, np.inf)
|
| 248 |
+
self.init_radius = float(max(0.5, np.median(_D.min(axis=1)) * 0.75))
|
| 249 |
+
return self.options
|
| 250 |
+
|
| 251 |
+
def _reliable_paths(self, r0, L, tau_U):
|
| 252 |
+
"""长度 L 的路径, 每步 unc < tau_U。用 BFS 枚举 (空间很小)。"""
|
| 253 |
+
out = []
|
| 254 |
+
n_dom = max(u for _, u in self.graph) + 1 if self.graph else 1
|
| 255 |
+
frontier = [(r0, [])]
|
| 256 |
+
for depth in range(L):
|
| 257 |
+
nxt = []
|
| 258 |
+
for r, seq in frontier:
|
| 259 |
+
for u in range(n_dom):
|
| 260 |
+
e = self.graph.get((r, u))
|
| 261 |
+
if e is None or not np.isfinite(e[1]) or e[1] > tau_U + 1e-12:
|
| 262 |
+
# ★ 用 `>` 而不是 `>=`: 确定性转移 (unc=0) 是**最可靠**的,
|
| 263 |
+
# 在 tau_U 也等于 0 时绝不能被筛掉 —— 否则"完全可预测的边"
|
| 264 |
+
# 全军覆没。实测: 计数式表格模型给出 tau_U=0.0, 旧的 `>=`
|
| 265 |
+
# 使 OptionManager 在结构清晰的 MDP 上发现 0 个 option。
|
| 266 |
+
continue
|
| 267 |
+
if e[0] == r:
|
| 268 |
+
continue # 不前进的边不走
|
| 269 |
+
nxt.append((e[0], seq + [u]))
|
| 270 |
+
if depth == L - 1:
|
| 271 |
+
out.extend(seq for _, seq in nxt)
|
| 272 |
+
frontier = nxt
|
| 273 |
+
if not frontier:
|
| 274 |
+
break
|
| 275 |
+
return out
|
| 276 |
+
|
| 277 |
+
def _walk(self, r0, acts):
|
| 278 |
+
r = r0
|
| 279 |
+
for u in acts:
|
| 280 |
+
e = self.graph.get((r, u))
|
| 281 |
+
if e is None:
|
| 282 |
+
return r
|
| 283 |
+
r = e[0]
|
| 284 |
+
return r
|
| 285 |
+
|
| 286 |
+
def _nearest_region(self, s):
|
| 287 |
+
d = np.linalg.norm(self.regions - np.asarray(s), axis=1)
|
| 288 |
+
return int(np.argmin(d))
|
| 289 |
+
|
| 290 |
+
@staticmethod
|
| 291 |
+
def _kmeans(X, k, iters=50, seed=0):
|
| 292 |
+
rng = np.random.default_rng(seed)
|
| 293 |
+
C = X[rng.choice(len(X), size=min(k, len(X)), replace=False)].copy()
|
| 294 |
+
a = np.zeros(len(X), dtype=int) # 防 iters=0 时未绑定
|
| 295 |
+
for _ in range(iters):
|
| 296 |
+
d = np.linalg.norm(X[:, None, :] - C[None, :, :], axis=2)
|
| 297 |
+
a = np.argmin(d, axis=1)
|
| 298 |
+
for j in range(len(C)):
|
| 299 |
+
if (a == j).any():
|
| 300 |
+
C[j] = X[a == j].mean(0)
|
| 301 |
+
return C, a
|
| 302 |
+
|
| 303 |
+
# ───────── 高层选择 (planning 入口) ─────────
|
| 304 |
+
# ── ★ 子目标驱动的发现 (用户 2026-09-14 指导的 Option 形式) ──────
|
| 305 |
+
def discover_subgoals(self, states, actions, n_regions=8, tgt=0.0,
|
| 306 |
+
min_path=2, max_path=5):
|
| 307 |
+
"""按 `Option=(I_o, g_o, π_o, β_o)` **目标导向地**发现 option。
|
| 308 |
+
|
| 309 |
+
## 为什么需要它 (实测动机)
|
| 310 |
+
|
| 311 |
+
原来的 `discover()` 找的是「**任意**可靠的多步路径」:
|
| 312 |
+
① 没有目标导向 —— 终点是不是"有意义的地方"不管
|
| 313 |
+
② 路径上**每条**边都要 unc < tau_U(中位数) -> 长度 4 的存活率 ~0.5^4=6%
|
| 314 |
+
③ 要求长度**恰好** = L
|
| 315 |
+
结果: 在一个**有明显的"拿钥匙→开门"技能链**的 MDP 上, 即使图里有
|
| 316 |
+
50 条前进边, 也只发现 0~1 个 option。**这是实现的问题, 不是环境的。**
|
| 317 |
+
|
| 318 |
+
## 正确做法: 先找**子目标**, 再找通往它的路
|
| 319 |
+
|
| 320 |
+
1. 建区域转移图 (同 discover)
|
| 321 |
+
2. 算每个区域在图上的**介数中心性** —— 高介数 = 通往别处必须经过的
|
| 322 |
+
咽喉 (bottleneck)。这正是"拿钥匙""开门"这类子目标的特征。
|
| 323 |
+
3. 每个 bottleneck 区域 b 成为一个子目标:
|
| 324 |
+
g_o = centers[b]
|
| 325 |
+
π_o = 从各起点区域到 b 的**最短动作序列** (在图上 BFS)
|
| 326 |
+
I_o = 那些起点区域
|
| 327 |
+
4. 允许多条不同长度的路径 (min_path..max_path), 不再要求恰好 = L
|
| 328 |
+
"""
|
| 329 |
+
self.regions, _ = self._kmeans(np.asarray(states, dtype=float), n_regions)
|
| 330 |
+
n_dom = self.T.n_dom
|
| 331 |
+
visits = np.bincount(np.asarray(actions, dtype=int), minlength=n_dom).astype(float)
|
| 332 |
+
|
| 333 |
+
# 1. 区域转移图
|
| 334 |
+
self.graph = {}
|
| 335 |
+
for r in range(n_regions):
|
| 336 |
+
for u in range(n_dom):
|
| 337 |
+
s_next, unc = self.T.predict(self.regions[r], u, visits)
|
| 338 |
+
self.graph[(r, u)] = (self._nearest_region(s_next), unc)
|
| 339 |
+
|
| 340 |
+
uncs = np.array([v[1] for v in self.graph.values() if np.isfinite(v[1])])
|
| 341 |
+
self.tau_U = float(np.median(uncs)) if len(uncs) else float("inf")
|
| 342 |
+
|
| 343 |
+
# 2. 邻接 (每个区域的出边, 按 unc 从小到大)
|
| 344 |
+
adj = {r: [] for r in range(n_regions)}
|
| 345 |
+
for (r, u), (nxt, unc) in self.graph.items():
|
| 346 |
+
if nxt != r and np.isfinite(unc):
|
| 347 |
+
adj[r].append((nxt, u, unc))
|
| 348 |
+
for r in adj:
|
| 349 |
+
adj[r].sort(key=lambda t: t[2])
|
| 350 |
+
|
| 351 |
+
# 3. ★ 割点/桥找咽喉 —— **不用介数中心性**
|
| 352 |
+
# 实测证据 (选项验证技能 FM-17, 四房间 gridworld): 介数最高的格
|
| 353 |
+
# **不是**走廊格。介数衡量的是"最短路径经过次数", 而走廊的语义是
|
| 354 |
+
# "去掉它图就断开" —— 那是**割点(articulation point)**。
|
| 355 |
+
# 用错了统计量 -> 子目标选在无关处 -> π_o 朝一个无意义的地点走。
|
| 356 |
+
aps = self._articulation_points(adj, n_regions)
|
| 357 |
+
if not aps:
|
| 358 |
+
# 图无割点 (如完全连通): 退回到"通往最多区域"的节点,
|
| 359 |
+
# 这比介数中心性更贴近"咽喉"的意图, 且不退化成"选不到子目标"
|
| 360 |
+
aps = set(np.argsort(-np.array([len(adj[r]) for r in range(n_regions)]))[:2].tolist())
|
| 361 |
+
|
| 362 |
+
# 4. 每个割点区域 = 一个子目标; 用 BFS 找通往它的动作序列
|
| 363 |
+
S = np.asarray(states, dtype=float)
|
| 364 |
+
order_ap = sorted(aps)
|
| 365 |
+
self.options = []
|
| 366 |
+
oid = 0
|
| 367 |
+
for b in order_ap:
|
| 368 |
+
for src in range(n_regions):
|
| 369 |
+
if src == b:
|
| 370 |
+
continue
|
| 371 |
+
seq = self._bfs_action_path(adj, src, b, max_path)
|
| 372 |
+
if seq is None or len(seq) < min_path:
|
| 373 |
+
continue
|
| 374 |
+
uncs_seq = [self.graph[(src, seq[0])][1]]
|
| 375 |
+
# ★ 目标/起点必须是**真实观测到的状态**, 不能是 k-means 质心。
|
| 376 |
+
# 质心是几个状态的软平均, 可能不对应任何真实状态 ->
|
| 377 |
+
# 以它为目标的 π_o 会朝一个"够不着"的点走。
|
| 378 |
+
# 实测: macro 宏执行的成功率只有 14.4% (质心目标下),
|
| 379 |
+
# 即 85.6% 的 option 执行**到不了自己的目标**。
|
| 380 |
+
self.options.append(Option(oid, seq, self._snap(S, self.regions[src]),
|
| 381 |
+
self._snap(S, self.regions[b]), uncs_seq,
|
| 382 |
+
regions=self.regions,
|
| 383 |
+
start_region=src, goal_region=b))
|
| 384 |
+
oid += 1
|
| 385 |
+
if oid >= 40:
|
| 386 |
+
break
|
| 387 |
+
if oid >= 40:
|
| 388 |
+
break
|
| 389 |
+
# 自适应启动集半径: 最近邻区域距离中位数的 0.75 倍 (与状态空间尺度匹配)
|
| 390 |
+
if len(self.regions) > 1:
|
| 391 |
+
_D = np.linalg.norm(self.regions[:, None, :] - self.regions[None, :, :], axis=-1)
|
| 392 |
+
np.fill_diagonal(_D, np.inf)
|
| 393 |
+
self.init_radius = float(max(0.5, np.median(_D.min(axis=1)) * 0.75))
|
| 394 |
+
return self.options
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def _snap(S, center):
|
| 398 |
+
"""把 k-means 质心**吸附到最近的真实观测状态**。
|
| 399 |
+
|
| 400 |
+
★ 为什么必须做: 质心是若干状态的软平均, 可能不对应任何真实状态。
|
| 401 |
+
以质心为目标 -> π_o 朝一个"够不着"的点走 -> β_o 永不因达成而触发。
|
| 402 |
+
实测 (KeyDoor macro 宏执行): 成功率仅 14.4%, 即 85.6% 的 option
|
| 403 |
+
执行到不了自己的目标。
|
| 404 |
+
"""
|
| 405 |
+
if S is None or len(S) == 0:
|
| 406 |
+
return np.asarray(center, dtype=float)
|
| 407 |
+
d = np.linalg.norm(S - np.asarray(center, dtype=float)[None, :], axis=1)
|
| 408 |
+
return S[int(np.argmin(d))].astype(float)
|
| 409 |
+
|
| 410 |
+
@staticmethod
|
| 411 |
+
def _articulation_points(adj, n):
|
| 412 |
+
"""Tarjan 割点检测 (**无向化**后的区域图)。
|
| 413 |
+
|
| 414 |
+
★ 为什么不用介数中心性: 走廊/咽喉的语义是"去掉它图就断开",
|
| 415 |
+
那是割点。介数中心性衡量的是"最短路径经过次数", 二者不等价 ——
|
| 416 |
+
实测 (选项验证技能 FM-17, 四房间 gridworld): 介数最高的格
|
| 417 |
+
**不是**走廊格。
|
| 418 |
+
"""
|
| 419 |
+
und = {r: set() for r in range(n)}
|
| 420 |
+
for r, outs in adj.items():
|
| 421 |
+
for e in outs:
|
| 422 |
+
nxt = e[0]
|
| 423 |
+
und[r].add(nxt)
|
| 424 |
+
und[nxt].add(r)
|
| 425 |
+
|
| 426 |
+
disc, low, parent = {}, {}, {}
|
| 427 |
+
aps = set()
|
| 428 |
+
timer = [0]
|
| 429 |
+
|
| 430 |
+
def dfs(u):
|
| 431 |
+
disc[u] = low[u] = timer[0]
|
| 432 |
+
timer[0] += 1
|
| 433 |
+
children = 0
|
| 434 |
+
for v in und.get(u, ()):
|
| 435 |
+
if v not in disc:
|
| 436 |
+
parent[v] = u
|
| 437 |
+
children += 1
|
| 438 |
+
dfs(v)
|
| 439 |
+
low[u] = min(low[u], low[v])
|
| 440 |
+
if u not in parent and children > 1:
|
| 441 |
+
aps.add(u) # 根: 多棵子树才是割点
|
| 442 |
+
if u in parent and low[v] >= disc[u]:
|
| 443 |
+
aps.add(u)
|
| 444 |
+
elif v != parent.get(u):
|
| 445 |
+
low[u] = min(low[u], disc[v])
|
| 446 |
+
|
| 447 |
+
for s in range(n):
|
| 448 |
+
if s not in disc:
|
| 449 |
+
dfs(s)
|
| 450 |
+
return aps
|
| 451 |
+
|
| 452 |
+
@staticmethod
|
| 453 |
+
def _bfs_action_path(adj, src, dst, max_len):
|
| 454 |
+
"""在区域图上 BFS 出 src->dst 的**动作序列** (优先低不确定性边)。"""
|
| 455 |
+
from collections import deque
|
| 456 |
+
q = deque([(src, [])])
|
| 457 |
+
seen = {src}
|
| 458 |
+
while q:
|
| 459 |
+
cur, path = q.popleft()
|
| 460 |
+
if len(path) > max_len:
|
| 461 |
+
continue
|
| 462 |
+
if cur == dst and path:
|
| 463 |
+
return path
|
| 464 |
+
for (nxt, u, _un) in adj[cur]:
|
| 465 |
+
if nxt in seen:
|
| 466 |
+
continue
|
| 467 |
+
seen.add(nxt)
|
| 468 |
+
q.append((nxt, path + [int(u)]))
|
| 469 |
+
return None
|
| 470 |
+
|
| 471 |
+
def select(self, s, goal=None, exclude_unc=True, radius=None):
|
| 472 |
+
"""选一个 option: 优先「起点匹配 + 目标最接近 goal + 成功率高」。
|
| 473 |
+
|
| 474 |
+
goal=None 时选成功率高且不确定性低的那个。
|
| 475 |
+
"""
|
| 476 |
+
cands = []
|
| 477 |
+
# ★ 半径必须与状态空间尺度匹配。one-hot 状态空间里**两个不同状态**的
|
| 478 |
+
# L2 距离是 √2 ≈ 1.41, 固定 0.75 会让启动集几乎永不满足
|
| 479 |
+
# (实测: 主回路里 option 一次都没被选用, primitive 与 option 臂
|
| 480 |
+
# 给出**逐位相同**的数字)。
|
| 481 |
+
_r = radius if radius is not None else self.init_radius
|
| 482 |
+
for o in self.options:
|
| 483 |
+
if not o.initiable(s, radius=_r):
|
| 484 |
+
continue
|
| 485 |
+
# ★ 同 _reliable_paths 的坑: 用 `>` 而不是 `>=`。
|
| 486 |
+
# 确定性模型下 tau_U=0 且 uncs=[0.0], `>=` 会把**全部** option
|
| 487 |
+
# 过滤掉 -> 主回路里 option 一次都没被选用 -> primitive 与 option 臂
|
| 488 |
+
# 给出逐位相同的数字。
|
| 489 |
+
if exclude_unc and o.uncs and max(o.uncs) > getattr(self, "tau_U", np.inf) + 1e-12:
|
| 490 |
+
continue
|
| 491 |
+
rate = (o.success / o.visits) if o.visits else 0.5 # 先验乐观
|
| 492 |
+
score = rate - (float(np.mean(o.uncs)) if o.uncs else 0.0)
|
| 493 |
+
if goal is not None:
|
| 494 |
+
score -= float(np.linalg.norm(o.goal_center - np.asarray(goal)))
|
| 495 |
+
cands.append((score, o))
|
| 496 |
+
return max(cands, key=lambda t: t[0])[1] if cands else None
|
| 497 |
+
|
| 498 |
+
def stats(self):
|
| 499 |
+
return {"n_options": len(self.options),
|
| 500 |
+
"tau_U": round(float(getattr(self, "tau_U", float("nan"))), 6),
|
| 501 |
+
"n_edges": len(self.graph),
|
| 502 |
+
"options": [o.stats() for o in self.options[:6]]}
|
hibs_lnn/pln_head.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PLN 头 (lm1 V31_PLN 的可移植移植版) + OML 双循环原语。
|
| 2 |
+
|
| 3 |
+
来源: tests/run_v31_meta_learning.py:114 V31_PLN / head_forward / per_feature_step
|
| 4 |
+
tests/run_lm3_bpe.py:193 oml2 双循环协议
|
| 5 |
+
|
| 6 |
+
lm1 原设计(见 run_lm1_production.py 文件头):
|
| 7 |
+
SSM (RLN) + OML 双层 (内循环 PLN 适应 + 外循环合并) + 价值提议器
|
| 8 |
+
|
| 9 |
+
本模块只搬 RLN 之外的两件事:
|
| 10 |
+
1. PLN: 小 MLP 头 + **per-feature 步长** (Meta-SGD / IDBD), 内循环只应用不修改 β
|
| 11 |
+
2. 双循环协议: 内循环克隆头适应 support → 外循环用 query loss 更新全模型
|
| 12 |
+
|
| 13 |
+
与 lm4 的结合点: lm4 原本是 `head = nn.Linear(d_model, n_classes)` 的**单循环**训练。
|
| 14 |
+
把 head 换成 PLN 并改用双循环, 即 lm1 的架构。
|
| 15 |
+
"""
|
| 16 |
+
import math
|
| 17 |
+
from typing import List
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
from hibs_lnn.swifttd_head import SwiftTDHead
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PLNHead(nn.Module):
|
| 27 |
+
"""预测学习网络 (Prediction Learning Network).
|
| 28 |
+
|
| 29 |
+
结构与 V31_PLN 一致: Linear(in, d) → Tanh → Linear(d, out)
|
| 30 |
+
差别: 输入是连续特征聚合 (lm4) 而非离散 hidden, 故 input_dim 可独立指定。
|
| 31 |
+
|
| 32 |
+
step_beta 是 **meta-parameter**: 外循环学它, 内循环只用它做
|
| 33 |
+
`W ← W - exp(beta) * grad`. 这样内循环有 per-feature 自适应步长,
|
| 34 |
+
而不是所有参数共享一个标量 lr。
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, input_dim, d_model, n_classes, inner_lr=0.1):
|
| 38 |
+
super().__init__()
|
| 39 |
+
self.input_dim = input_dim
|
| 40 |
+
self.d_model = d_model
|
| 41 |
+
self.n_classes = n_classes
|
| 42 |
+
self.predictor = nn.Sequential(
|
| 43 |
+
nn.Linear(input_dim, d_model),
|
| 44 |
+
nn.Tanh(),
|
| 45 |
+
nn.Linear(d_model, n_classes),
|
| 46 |
+
)
|
| 47 |
+
# log 空间初始化 → exp(beta) = inner_lr
|
| 48 |
+
self.step_beta = nn.Parameter(
|
| 49 |
+
torch.full((self._n_params(),), math.log(inner_lr)))
|
| 50 |
+
|
| 51 |
+
# ---------- 参数工具 ----------
|
| 52 |
+
def _n_params(self) -> int:
|
| 53 |
+
return sum(p.numel() for p in self.predictor.parameters())
|
| 54 |
+
|
| 55 |
+
def clone_params(self) -> List[torch.Tensor]:
|
| 56 |
+
"""克隆可适应参数 (保留到共享参数的图, 供外循环反传)。"""
|
| 57 |
+
lin0 = self.predictor[0]
|
| 58 |
+
lin2 = self.predictor[2]
|
| 59 |
+
return [lin0.weight.clone(), lin0.bias.clone(),
|
| 60 |
+
lin2.weight.clone(), lin2.bias.clone()]
|
| 61 |
+
|
| 62 |
+
def per_feature_step(self, W: List[torch.Tensor],
|
| 63 |
+
grads: List[torch.Tensor]) -> List[torch.Tensor]:
|
| 64 |
+
"""Meta-SGD 内循环更新: 逐参数自适应步长 (与 V31_PLN.per_feature_step 同式)。"""
|
| 65 |
+
beta = self.step_beta
|
| 66 |
+
new_W, off = [], 0
|
| 67 |
+
for w, g in zip(W, grads):
|
| 68 |
+
n = w.numel()
|
| 69 |
+
b = beta[off:off + n].view_as(w)
|
| 70 |
+
alpha = b.exp().clamp(max=1.0)
|
| 71 |
+
new_W.append(w - alpha * g)
|
| 72 |
+
off += n
|
| 73 |
+
return new_W
|
| 74 |
+
|
| 75 |
+
def load_cloned(self, W: List[torch.Tensor]) -> None:
|
| 76 |
+
"""把克隆参数写回本体 (仅评估/合并用)。"""
|
| 77 |
+
with torch.no_grad():
|
| 78 |
+
self.predictor[0].weight.copy_(W[0])
|
| 79 |
+
self.predictor[0].bias.copy_(W[1])
|
| 80 |
+
self.predictor[2].weight.copy_(W[2])
|
| 81 |
+
self.predictor[2].bias.copy_(W[3])
|
| 82 |
+
|
| 83 |
+
# ---------- 前向 ----------
|
| 84 |
+
@staticmethod
|
| 85 |
+
def fwd_with(W: List[torch.Tensor], h: torch.Tensor) -> torch.Tensor:
|
| 86 |
+
"""用给定的参数集做前向 (functional, 支持内循环克隆参数)。"""
|
| 87 |
+
w1, b1, w2, b2 = W
|
| 88 |
+
x = torch.tanh(h @ w1.t() + b1)
|
| 89 |
+
return x @ w2.t() + b2
|
| 90 |
+
|
| 91 |
+
def forward(self, h: torch.Tensor) -> torch.Tensor:
|
| 92 |
+
return self.predictor(h)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def inner_adapt(head: PLNHead, h_sup: torch.Tensor, y_sup: torch.Tensor,
|
| 96 |
+
K: int = 2, per_feature: bool = True):
|
| 97 |
+
"""内循环: 在 support 上适应**克隆的**头参数, 返回适应后的 W。
|
| 98 |
+
|
| 99 |
+
per_feature=True → 用 head.step_beta (lm1 的 Meta-SGD 步长)
|
| 100 |
+
per_feature=False → 固定标量 lr (lm3 oml2 的做法, lr=0.1)
|
| 101 |
+
|
| 102 |
+
注意: 整个过程对 head.predictor 的参数**保留梯度图** (没有 detach),
|
| 103 |
+
这样外循环的 query loss 反传时, 梯度会经过内循环更新流回 meta-parameters。
|
| 104 |
+
"""
|
| 105 |
+
W = head.clone_params()
|
| 106 |
+
for _ in range(K):
|
| 107 |
+
logits = PLNHead.fwd_with(W, h_sup)
|
| 108 |
+
loss = F.cross_entropy(logits, y_sup)
|
| 109 |
+
grads = torch.autograd.grad(loss, W, create_graph=True)
|
| 110 |
+
if per_feature:
|
| 111 |
+
W = head.per_feature_step(W, grads)
|
| 112 |
+
else:
|
| 113 |
+
W = [w - 0.1 * g for w, g in zip(W, grads)]
|
| 114 |
+
return W
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def oml_step(model, head: PLNHead, enc, h_sup, y_sup, h_qry, y_qry,
|
| 118 |
+
opt_outer, K: int = 2, per_feature: bool = True,
|
| 119 |
+
consolidate: bool = True, reptile_lr: float = 0.0):
|
| 120 |
+
"""一次 OML 双循环步。
|
| 121 |
+
|
| 122 |
+
内循环: 克隆头在 support 上适应 K 步 (慢参数冻结)
|
| 123 |
+
外循环: fast head 在 query 上的 loss → 反传更新 RLN + PLN meta 参数
|
| 124 |
+
合并: 把适应后的 fast head 写回本体 (OML 权重整合)
|
| 125 |
+
Reptile: 再把本体 init 朝适应后权重拉一步 (lm1 meta_step 的 init 平均)
|
| 126 |
+
init ← init + reptile_lr * (W_adapted - init)
|
| 127 |
+
|
| 128 |
+
返回 (query_loss, support_loss) 标量。
|
| 129 |
+
"""
|
| 130 |
+
# --- 内循环 (RLN 冻结, 只适应头; 梯度图保留供外循环用) ---
|
| 131 |
+
W = inner_adapt(head, h_sup, y_sup, K=K, per_feature=per_feature)
|
| 132 |
+
|
| 133 |
+
# --- 外循环: query loss 反传全模型 ---
|
| 134 |
+
opt_outer.zero_grad()
|
| 135 |
+
logits_q = PLNHead.fwd_with(W, h_qry)
|
| 136 |
+
loss_q = F.cross_entropy(logits_q, y_qry)
|
| 137 |
+
loss_q.backward()
|
| 138 |
+
# 二阶 (create_graph) 梯度必须裁剪 —— lm1 meta_step 的 clip_grad_norm_(1.0)。
|
| 139 |
+
# 不裁剪时外循环会把编码器打爆, 表现为"当前域刚学会就整体归零"。
|
| 140 |
+
torch.nn.utils.clip_grad_norm_(
|
| 141 |
+
[p for p in model.parameters() if p.requires_grad], 1.0)
|
| 142 |
+
opt_outer.step()
|
| 143 |
+
|
| 144 |
+
# --- OML 权重整合: 适应后的头合并回本体 ---
|
| 145 |
+
if consolidate:
|
| 146 |
+
with torch.no_grad():
|
| 147 |
+
head.load_cloned([w.detach() for w in W])
|
| 148 |
+
# 合并后这些是叶子参数, 外循环下一步能继续更新
|
| 149 |
+
head.predictor[0].weight.requires_grad_(True)
|
| 150 |
+
head.predictor[0].bias.requires_grad_(True)
|
| 151 |
+
head.predictor[2].weight.requires_grad_(True)
|
| 152 |
+
head.predictor[2].bias.requires_grad_(True)
|
| 153 |
+
|
| 154 |
+
# --- Reptile init 平均 (lm1 V31.2 meta_step 的做法) ---
|
| 155 |
+
if reptile_lr > 0.0:
|
| 156 |
+
with torch.no_grad():
|
| 157 |
+
for p, w in zip(head.predictor.parameters(), W):
|
| 158 |
+
p.add_(reptile_lr * (w.detach() - p))
|
| 159 |
+
|
| 160 |
+
with torch.no_grad():
|
| 161 |
+
loss_s = F.cross_entropy(PLNHead.fwd_with(
|
| 162 |
+
[w.detach() for w in W], h_sup), y_sup).item()
|
| 163 |
+
return loss_q.item(), loss_s
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ============================================================
|
| 167 |
+
# SwiftTD 头 (lm1 hibs_lnn/swifttd_head.py 的适配器)
|
| 168 |
+
# ============================================================
|
| 169 |
+
class SwiftTDAdapter(nn.Module):
|
| 170 |
+
"""把 lm1 的 SwiftTDHead 适配成分类头, 接口与 PLNHead 对齐。
|
| 171 |
+
|
| 172 |
+
SwiftTD 与 PLN 的本质差异:
|
| 173 |
+
PLN — 内循环用 Meta-SGD 梯度, step_beta 是 **meta 参数**, 外循环可学
|
| 174 |
+
SwiftTD — 内循环用**局部规则** (δ·φ), 每参数步长 θ 由 IDBD 在线自适应,
|
| 175 |
+
外循环不需要学它 (θ 靠 bound/decay 自己调节)
|
| 176 |
+
|
| 177 |
+
两者都提供 step_graph (可微版本), 所以都能直接插进 OML 双循环。
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
def __init__(self, input_dim, n_classes, beta_init=0.01, kappa=0.05,
|
| 181 |
+
eta=0.1, eps=0.99, tau_norm=None):
|
| 182 |
+
super().__init__()
|
| 183 |
+
self.input_dim = input_dim
|
| 184 |
+
self.n_classes = n_classes
|
| 185 |
+
self.head = SwiftTDHead(d_model=input_dim, world_vocab=n_classes,
|
| 186 |
+
n_resp=1, input_dim=input_dim,
|
| 187 |
+
beta_init=beta_init, kappa=kappa,
|
| 188 |
+
eta=eta, eps=eps, tau_norm=tau_norm)
|
| 189 |
+
s = 1.0 / math.sqrt(input_dim)
|
| 190 |
+
self.w1 = nn.Parameter(torch.randn(input_dim, input_dim) * s)
|
| 191 |
+
self.b1 = nn.Parameter(torch.zeros(input_dim))
|
| 192 |
+
self.w2 = nn.Parameter(torch.randn(n_classes, input_dim) * s)
|
| 193 |
+
self.b2 = nn.Parameter(torch.zeros(n_classes))
|
| 194 |
+
|
| 195 |
+
def to(self, *args, **kwargs):
|
| 196 |
+
super().to(*args, **kwargs)
|
| 197 |
+
self.head.to(self.w1.device)
|
| 198 |
+
return self
|
| 199 |
+
|
| 200 |
+
def clone_params(self):
|
| 201 |
+
return [self.w1.clone(), self.b1.clone(),
|
| 202 |
+
self.w2.clone(), self.b2.clone()]
|
| 203 |
+
|
| 204 |
+
def load_cloned(self, W):
|
| 205 |
+
with torch.no_grad():
|
| 206 |
+
self.w1.copy_(W[0]); self.b1.copy_(W[1])
|
| 207 |
+
self.w2.copy_(W[2]); self.b2.copy_(W[3])
|
| 208 |
+
|
| 209 |
+
@staticmethod
|
| 210 |
+
def fwd_with(W, h):
|
| 211 |
+
x = torch.tanh(h @ W[0].t() + W[1])
|
| 212 |
+
return x @ W[2].t() + W[3]
|
| 213 |
+
|
| 214 |
+
def forward(self, h):
|
| 215 |
+
return self.fwd_with([self.w1, self.b1, self.w2, self.b2], h)
|
| 216 |
+
|
| 217 |
+
def step_size_stats(self):
|
| 218 |
+
return self.head.step_size_stats()
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def inner_adapt_swifttd(head: SwiftTDAdapter, h_sup, y_sup, K: int = 1):
|
| 222 |
+
"""SwiftTD 内循环: 逐样本应用局部规则 (W 保持计算图, 供外循环 BPTT)。
|
| 223 |
+
|
| 224 |
+
SwiftTD 是**在线单样本**规则 (见 lm1 adapt_graph), 所以内循环按样本串行。
|
| 225 |
+
K 是在同一批 support 上扫的遍数。
|
| 226 |
+
"""
|
| 227 |
+
W = head.clone_params()
|
| 228 |
+
B = h_sup.shape[0]
|
| 229 |
+
for _ in range(K):
|
| 230 |
+
for b in range(B):
|
| 231 |
+
hb = h_sup[b:b + 1]
|
| 232 |
+
x = torch.tanh(hb @ W[0].t() + W[1])
|
| 233 |
+
logits = (x @ W[2].t() + W[3]).view(1, 1, head.n_classes)
|
| 234 |
+
p = F.softmax(logits, dim=-1)
|
| 235 |
+
onehot = F.one_hot(y_sup[b:b + 1], head.n_classes).float()
|
| 236 |
+
errs = (p - onehot).reshape(-1)
|
| 237 |
+
W = head.head.step_graph(W, hb[0], x[0], errs)
|
| 238 |
+
return W
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def oml_step_swifttd(model, head: SwiftTDAdapter, h_sup, y_sup, h_qry, y_qry,
|
| 242 |
+
opt_outer, K: int = 1, consolidate: bool = True,
|
| 243 |
+
reptile_lr: float = 0.0):
|
| 244 |
+
"""SwiftTD 版 OML 双循环 (与 oml_step 同协议, 只换内循环规则)。"""
|
| 245 |
+
W = inner_adapt_swifttd(head, h_sup, y_sup, K=K)
|
| 246 |
+
|
| 247 |
+
opt_outer.zero_grad()
|
| 248 |
+
loss_q = F.cross_entropy(SwiftTDAdapter.fwd_with(W, h_qry), y_qry)
|
| 249 |
+
loss_q.backward()
|
| 250 |
+
torch.nn.utils.clip_grad_norm_(
|
| 251 |
+
[p for p in model.parameters() if p.requires_grad], 1.0)
|
| 252 |
+
opt_outer.step()
|
| 253 |
+
|
| 254 |
+
if consolidate:
|
| 255 |
+
head.load_cloned([w.detach() for w in W])
|
| 256 |
+
if reptile_lr > 0.0:
|
| 257 |
+
with torch.no_grad():
|
| 258 |
+
for p, w in zip(head.parameters(), W):
|
| 259 |
+
p.add_(reptile_lr * (w.detach() - p))
|
| 260 |
+
|
| 261 |
+
with torch.no_grad():
|
| 262 |
+
loss_s = F.cross_entropy(SwiftTDAdapter.fwd_with(
|
| 263 |
+
[w.detach() for w in W], h_sup), y_sup).item()
|
| 264 |
+
return loss_q.item(), loss_s
|
hibs_lnn/rl_proposer.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rl_proposer.py — 真正的 RL 提议器 (取代固定公式的 RegimeProposer)。
|
| 2 |
+
|
| 3 |
+
## 为什么必须有这个文件
|
| 4 |
+
|
| 5 |
+
`RegimeProposer` 是个**启发式排序器**: V(p) 是状态的纯函数, 权重 λ 是超参,
|
| 6 |
+
**从不被 reward 更新, 也从不积累**。leo 连续两问戳中了它:
|
| 7 |
+
「有把 policy 当做一个可以迭代和积累的参数来更新吗, 积累过程是什么?」
|
| 8 |
+
「步长为主导的 policy 积累, redefine 也需要探索 policy 的积累」
|
| 9 |
+
答案都是"没有"。本文件把闭环补成真 RL。
|
| 10 |
+
|
| 11 |
+
## 范式: 动作 → 环境 → 奖励 → 参数更新
|
| 12 |
+
|
| 13 |
+
环境 (Environment) = 训练回路本身: 选一个物理区间去训练 -> 模型变化 -> 能力画像变化
|
| 14 |
+
状态 s_t = 当前能力画像 (每候选的实测 acc / 覆盖次数 / 已学标志 + 全局统计)
|
| 15 |
+
动作 a_t = 选一个候选区间 (后续可扩展到"动作=连续控制量")
|
| 16 |
+
奖励 r_t = **目标函数的增量** Δ(any-time 平均准确率)
|
| 17 |
+
← 必须是真正要的东西, 不是"我这个区间考了多少分"
|
| 18 |
+
参数更新 = θ ← θ + α_i ⊙ (r − V_θ) ∇, α_i 由 IDBD 在线元学
|
| 19 |
+
|
| 20 |
+
π_θ(a|s) = softmax( V_θ(s,a) / τ ), V_θ(s,a) = θ · φ(s,a)
|
| 21 |
+
|
| 22 |
+
## 步长为主导的积累 (OaK 第②条 / Sutton IDBD)
|
| 23 |
+
|
| 24 |
+
关键: **α_i 不是超参, 而是随经验累积的状态**。
|
| 25 |
+
|
| 26 |
+
δ_t = r_t − V_θ(s_t, a_t) (TD/bandit 误差)
|
| 27 |
+
w_i += α_i · δ_t · φ_i (权重)
|
| 28 |
+
h_i = h_i · max(0, 1 − α_i · φ_i²) + α_i · φ_i · δ_t (★ 累积迹)
|
| 29 |
+
α_i = α_i · exp( μ · δ_t · φ_i · h_i ) (★ 步长本身累积)
|
| 30 |
+
|
| 31 |
+
`h_i` 是"信用迹", 把历史 δ 与特征的相关性**累加**起来; `α_i` 按它调整。
|
| 32 |
+
**这两条是把经验持续累积进参数的通路** —— 不是 EMA 式的追踪(那会主动遗忘),
|
| 33 |
+
而是 Sutton 意义上的增量累积。
|
| 34 |
+
|
| 35 |
+
## ★ 实测: 步长分化存在尖锐阈值 (与 V35.2/3 同病, 两处复现)
|
| 36 |
+
|
| 37 |
+
alpha0 = 0.01 -> alpha_std ~ 1e-6 **步长死亡** (仍是超参)
|
| 38 |
+
alpha0 = 0.05 -> alpha_std ~ 1e-4 **死亡**
|
| 39 |
+
alpha0 = 0.20 -> alpha_std ~ 0.3 **分化成立** (max/min ~5x)
|
| 40 |
+
mu=1.0 + alpha0=0.2 -> exp 溢出, ratio 1e5 = 发散 (已加指数截断)
|
| 41 |
+
|
| 42 |
+
根因: IDBD 的 meta-梯度量级 ∝ alpha0 (见 V35.2/3: beta_init=0.001 推不动,
|
| 43 |
+
0.05 才分化)。默认取稳定窗口 mu=0.05 / alpha0=0.2。
|
| 44 |
+
**注意**: 这只证明"步长分化了", 不证明"分化带来收益" —— 后者必须在
|
| 45 |
+
reward 真正依赖动作的回路里测。
|
| 46 |
+
|
| 47 |
+
## 探索 policy 的积累 (redefine 存活)
|
| 48 |
+
|
| 49 |
+
explore[c] = {n_visits, mean_return, last_seen}
|
| 50 |
+
探索加成 u(c) = 1/√(1 + n_visits) (UCB 风格, 递减但不归零)
|
| 51 |
+
|
| 52 |
+
**redefine 时不清零**: 候选池重建后, 每个新候选按**物理描述子最近的旧候选**
|
| 53 |
+
继承 explore / α / θ 对应分量。这样"某个物理区域值不值得探索"这条知识
|
| 54 |
+
**跨 pool 重建存活** —— 这正是 leo 说的"redefine 也需要探索 policy 的积累"。
|
| 55 |
+
|
| 56 |
+
## 与 RegimeProposer 的关系 (共存, 不是替换)
|
| 57 |
+
|
| 58 |
+
RegimeProposer 手工 V + 状态更新 -> 作为**基线**
|
| 59 |
+
RLProposer 可学 V_θ + IDBD 步长 -> 真 RL, 用来回答"学习有没有用"
|
| 60 |
+
"""
|
| 61 |
+
from __future__ import annotations
|
| 62 |
+
|
| 63 |
+
import numpy as np
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class RLProposer:
|
| 67 |
+
"""动作→环境→奖励→参数更新 的提议器; 步长 (IDBD) 是主导的累积参数。
|
| 68 |
+
|
| 69 |
+
参数
|
| 70 |
+
----
|
| 71 |
+
desc : (N, D) 每个候选的物理描述子 (如 [log10 密度, log10 偶极 L, sin 磁纬])
|
| 72 |
+
lam : 兼容旧接口留空
|
| 73 |
+
tau : softmax 温度
|
| 74 |
+
k : 每次提议几个
|
| 75 |
+
mu : IDBD 元步长 (步长自身的更新率), 默认 0.01
|
| 76 |
+
alpha0 : 每个权重的初始步长
|
| 77 |
+
gamma : 折扣; 1.0 = bandit (无 bootstrap), <1 = TD
|
| 78 |
+
explore_w : 探索加成权重 (UCB 风格)
|
| 79 |
+
seed : 随机种子
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, desc, tau=0.5, k=2, mu=0.05, alpha0=0.2,
|
| 83 |
+
gamma=1.0, explore_w=0.5, seed=0, algo="cidbd",
|
| 84 |
+
auto_scope="global", n_know=0):
|
| 85 |
+
desc = np.asarray(desc, dtype=np.float64)
|
| 86 |
+
self.n, self.d = desc.shape
|
| 87 |
+
mu_d = desc.mean(0)
|
| 88 |
+
sd_d = desc.std(0)
|
| 89 |
+
sd_d[sd_d < 1e-9] = 1.0
|
| 90 |
+
self.desc = desc
|
| 91 |
+
self.z = (desc - mu_d) / sd_d # 归一化描述子 (用于 redefine 继承)
|
| 92 |
+
self.tau, self.k = float(tau), int(k)
|
| 93 |
+
self.mu, self.gamma, self.explore_w = float(mu), float(gamma), float(explore_w)
|
| 94 |
+
self.rng = np.random.default_rng(seed)
|
| 95 |
+
|
| 96 |
+
# ── 可学参数 (policy 本身) ──
|
| 97 |
+
# 特征 φ(s,a) = [1, z(a), 全局统计×z(a), 覆盖, 探索加成]
|
| 98 |
+
# ★ 「丰富的内部知识」(Horde/GVF 思路): 智能体**自己的预测**进入状态。
|
| 99 |
+
# 手写先验 φ = [1, z(a), z(a)*any_time, cov, unc] 只有 9 维, 且每块都是
|
| 100 |
+
# 人给定的结构 (见 docs/oak_alignment.md §"人为规定最少的结构")。
|
| 101 |
+
# n_know > 0 时, 评测方每步可通过 set_knowledge() 注入一段**外部知识向量**
|
| 102 |
+
# (例如: 当前能力画像 acc_matrix 的一行 + 转移模型对每个候选的预测增量),
|
| 103 |
+
# 它被线性映射进 φ。知识维度越高, 可分化步长的方向就越多。
|
| 104 |
+
self.n_know = int(n_know)
|
| 105 |
+
self.fdim = 1 + self.d + self.d + 2 + self.n_know
|
| 106 |
+
self.theta = np.zeros(self.fdim) # 价值权重 (policy 参数)
|
| 107 |
+
# ★ 每个权重一个步长 + 一个信用迹 —— 这就是"步长为主导的积累"
|
| 108 |
+
self.alpha = np.full(self.fdim, float(alpha0))
|
| 109 |
+
self.beta = np.log(np.maximum(self.alpha, 1e-12))
|
| 110 |
+
self.h = np.zeros(self.fdim) # IDBD 信用迹 (累积)
|
| 111 |
+
self.baseline = 0.0
|
| 112 |
+
# ★ δ 的 RMS (误差归一化)。标准 IDBD 假设 δ 是 O(1) 量级, 但
|
| 113 |
+
# "目标函数的增量" δ≈0.006 -> mu*δ*phi*h ≈ 1e-6 -> 步长几乎不动
|
| 114 |
+
# (实测 alpha_std=0.0, 与 V35.2/3 记录的 beta_std=1.6e-05 同病)。
|
| 115 |
+
# 归一化后 mu 的含义与任务量级解耦。
|
| 116 |
+
self.drms = 1.0
|
| 117 |
+
# ★ Autostep (Mahmood/Sutton/Degris/Pilarski 2012) 的两个归一化器。
|
| 118 |
+
# 动机 (论文的单位分析): IDBD 的指数项 delta*x*h 量纲是 y^2, 所以
|
| 119 |
+
# 最优 mu 的量纲是 1/y^2 —— **目标方差一变, 最优 mu 就跨数量级漂移**
|
| 120 |
+
# (论文实测跨 5 个数量级)。我们的 reward = Δ(any-time) ~ 0.02, y^2 极小,
|
| 121 |
+
# 所以实测必须 alpha0>=0.2 才活、mu=1.0 就炸 —— 与论文所述完全一致。
|
| 122 |
+
# Autostep 的两步归一化让 mu 免调参、且"overshoot 不可能发生"。
|
| 123 |
+
self.v = np.zeros(self.fdim) # (4) |delta*x*h| 的 running max
|
| 124 |
+
self.v_g = 0.0 # global 作用域时的标量归一化因子
|
| 125 |
+
# ★ IDBD 量纲标准化器: 指数 delta*x*h 的量纲是 y^2 (论文 §3), 故须除以其
|
| 126 |
+
# 自身量级才能让 mu 与任务无关。**注意这不同于 Autostep**: Autostep 除以
|
| 127 |
+
# running **max** (把指数卡死在 mu, 杀掉积累); 这里除以 running **RMS**
|
| 128 |
+
# (慢变标量, 保留跨分量比例与累积性)。
|
| 129 |
+
self.grms = 1e-8
|
| 130 |
+
self.beta_lo = float(np.log(1e-6)) # log(alpha) 的下界 (限制累积总量)
|
| 131 |
+
self.beta_hi = float(np.log(10.0)) # 上界
|
| 132 |
+
self._warm = 0 # idbd-acc 的 warmup 计数
|
| 133 |
+
# ── Continual-IDBD (按用户给的 §9/§10 设计) ──
|
| 134 |
+
# s_i : 逐分量 |g_i| 的 EMA 尺度估计 (既归一化又保留跨分量比例)
|
| 135 |
+
# alpha_min/max : **clip 在 alpha 上**, beta 不 clip -> beta 可继续
|
| 136 |
+
# 累积, 也就能在恢复时立刻回到可塑区间
|
| 137 |
+
# E_base / kappa : 误差基线 + 变化检测阈值 (环境突变 -> 提可塑性)
|
| 138 |
+
self.s = np.ones(self.fdim) * 1e-8
|
| 139 |
+
self.rho = 0.01 # s 的 EMA 速率
|
| 140 |
+
self.alpha_min = 1e-5
|
| 141 |
+
self.alpha_max = 10.0
|
| 142 |
+
self.E_base = None
|
| 143 |
+
self.kappa = 3.0 # 误差 > kappa*基线 = 环境变了
|
| 144 |
+
self.alpha_recovery = 0.05 # 恢复时的可塑性下限
|
| 145 |
+
self.n_recovery = 0
|
| 146 |
+
self.flip_rate = [] # weight-flipping rate 的历史
|
| 147 |
+
self.know = np.zeros(self.n_know) # 内部知识向量 (评测方每步注入)
|
| 148 |
+
self.Wk = np.zeros((self.n_know, self.n_know)) # 知识->特征 的线性映射
|
| 149 |
+
if self.n_know:
|
| 150 |
+
# 初始化为确定性正交映射 (无需训练, 保留信息不塌缩)
|
| 151 |
+
self.Wk = np.eye(self.n_know) * (1.0 / max(1.0, np.sqrt(self.n_know)))
|
| 152 |
+
self.tau_a = 1e4 # (4) 归一化器的跟踪速率
|
| 153 |
+
self.algo = algo
|
| 154 |
+
# ★ Autostep 归一化因子的作用域。
|
| 155 |
+
# 论文 Table 1 用**逐分量** running max: v_i = max|delta*x_i*h_i|。
|
| 156 |
+
# 实测 (tests/diag_stepsize.py): 逐分量归一化把指数**卡死在 mu**
|
| 157 |
+
# (|e|max 恰 = mu), 于是跨分量的量级信息被抹掉 -> alpha max/min
|
| 158 |
+
# 只有 1.10/1.31, 而 h 自身的 max/min 高达 72/55 (特征明明可分)。
|
| 159 |
+
# 论文 §3 的单位分析给出正确作用域: delta*x*h 的量纲是 y^2, 故
|
| 160 |
+
# 归一化因子应是一个**带 y^2 量纲的标量**, 而非逐分量向量。
|
| 161 |
+
# 'global' = 用全局 running max (保留跨分量比例, 默认);
|
| 162 |
+
# 'per_component' = 论文原版 (保留以作对照)。
|
| 163 |
+
self.auto_scope = auto_scope
|
| 164 |
+
|
| 165 |
+
# ── 环境状态 ──
|
| 166 |
+
self.visits = np.zeros(self.n, dtype=np.float64)
|
| 167 |
+
self.acc = np.full(self.n, np.nan)
|
| 168 |
+
self.learned = np.zeros(self.n, dtype=bool)
|
| 169 |
+
self.global_acc = 0.0 # 上一轮的全域平均 (算 reward 用)
|
| 170 |
+
self.any_time = 0.0
|
| 171 |
+
|
| 172 |
+
# ── 探索记录 (redefine 存���) ──
|
| 173 |
+
self.explore = np.zeros(self.n, dtype=np.float64) # 每候选的累积收益
|
| 174 |
+
self.explore_n = np.zeros(self.n, dtype=np.float64)
|
| 175 |
+
self.redefine_count = 0
|
| 176 |
+
self.trace = [] # 每步记录, 供分析
|
| 177 |
+
|
| 178 |
+
# ─────────────── 特征 / 价值 / 策略 ───────────────
|
| 179 |
+
def set_knowledge(self, vec):
|
| 180 |
+
"""注入内部知识向量 (Horde/GVF: 让智能体自己的预测成为状态)。
|
| 181 |
+
|
| 182 |
+
调用方每步喂入, 例如:
|
| 183 |
+
[acc_matrix[t] 的一行 (6 域能力画像), 之前几步的 any-time, 遗忘信号]
|
| 184 |
+
或 转移模型对每个候选的预测增量。
|
| 185 |
+
形状不匹配则忽略 (保证向后兼容)。
|
| 186 |
+
"""
|
| 187 |
+
v = np.asarray(vec, dtype=float).ravel()
|
| 188 |
+
if v.size != self.n_know:
|
| 189 |
+
return
|
| 190 |
+
self.know = np.nan_to_num(v, nan=0.0, posinf=0.0, neginf=0.0)
|
| 191 |
+
|
| 192 |
+
def phi(self, i):
|
| 193 |
+
"""φ(s,a): 描述子 + 与全局状态的调制 + 覆盖/探索加成 + **内部知识**。"""
|
| 194 |
+
cov = 1.0 / (1.0 + self.visits[i])
|
| 195 |
+
unc = 1.0 / np.sqrt(1.0 + self.explore_n[i]) # UCB 风格探索加成
|
| 196 |
+
zz = self.z[i]
|
| 197 |
+
base = np.concatenate([[1.0], zz, zz * self.any_time, [cov, unc]])
|
| 198 |
+
if self.n_know:
|
| 199 |
+
# 知识以 zz 为门控投影进来: 候选特定的知识 vs 全局知识
|
| 200 |
+
k = self.Wk @ self.know
|
| 201 |
+
return np.concatenate([base, k * (1.0 + 0.5 * zz[0])])
|
| 202 |
+
return base
|
| 203 |
+
|
| 204 |
+
def value(self, i):
|
| 205 |
+
return float(self.theta @ self.phi(i))
|
| 206 |
+
|
| 207 |
+
def score(self, i):
|
| 208 |
+
return self.value(i) + self.explore_w / np.sqrt(1.0 + self.explore_n[i])
|
| 209 |
+
|
| 210 |
+
def probs(self):
|
| 211 |
+
# ★ 必须 clip: theta 学起来后分数差会拉大, 直接 exp 会让远端候选
|
| 212 |
+
# **下溢成恰好 0**, 于是 rng.choice(replace=False) 报
|
| 213 |
+
# "Fewer non-zero entries in p than size" 而崩。
|
| 214 |
+
# clip 到 [-30, 0] 保证每个候选都有非零概率 (exp(-30) ~ 9e-14)。
|
| 215 |
+
s = np.array([self.score(i) for i in range(self.n)])
|
| 216 |
+
s = np.clip((s - s.max()) / max(1e-9, self.tau), -30.0, 0.0)
|
| 217 |
+
e = np.exp(s)
|
| 218 |
+
tot = e.sum()
|
| 219 |
+
if tot <= 0 or not np.isfinite(tot):
|
| 220 |
+
return np.full(self.n, 1.0 / self.n) # 兜底: 退化成均匀
|
| 221 |
+
return e / tot
|
| 222 |
+
|
| 223 |
+
# ─────────────── 动作 ───────────────
|
| 224 |
+
def act(self, k=None, greedy=False):
|
| 225 |
+
"""选动作 (提议 k 个候选)。greedy=True 用 argmax (评估用)。"""
|
| 226 |
+
k = k or self.k
|
| 227 |
+
if greedy:
|
| 228 |
+
order = np.argsort([self.score(i) for i in range(self.n)])[::-1]
|
| 229 |
+
idx = list(map(int, order[:k]))
|
| 230 |
+
else:
|
| 231 |
+
p = self.probs()
|
| 232 |
+
idx = list(map(int, self.rng.choice(self.n, size=min(k, self.n),
|
| 233 |
+
replace=False, p=p)))
|
| 234 |
+
for i in idx:
|
| 235 |
+
self.visits[i] += 1
|
| 236 |
+
return idx
|
| 237 |
+
|
| 238 |
+
# ─────────────── 奖励 → 参数更新 ───────────────
|
| 239 |
+
def observe(self, idx, acc, any_time=None):
|
| 240 |
+
"""环境反馈: 记录实测 acc + 全域 any-time 准确率。
|
| 241 |
+
|
| 242 |
+
reward 由 update() 从**目标函数的增量**算出, 不是"该区间考了多少分"。
|
| 243 |
+
"""
|
| 244 |
+
for i in idx:
|
| 245 |
+
self.acc[i] = float(acc)
|
| 246 |
+
self.learned[i] = True
|
| 247 |
+
if any_time is not None:
|
| 248 |
+
self.any_time = float(any_time)
|
| 249 |
+
|
| 250 |
+
def update(self, idx, r=None, gamma=None):
|
| 251 |
+
"""★ 参数更新: 用 reward 更新 policy。返回本步的 δ。
|
| 252 |
+
|
| 253 |
+
r 为空时用 Δ(any-time) 作为 reward —— 这才是**真正的目标**。
|
| 254 |
+
"""
|
| 255 |
+
gamma = self.gamma if gamma is None else gamma
|
| 256 |
+
if r is None:
|
| 257 |
+
r = self.any_time - self.global_acc # 目标函数增量
|
| 258 |
+
self.global_acc = self.any_time
|
| 259 |
+
|
| 260 |
+
deltas = []
|
| 261 |
+
for i in idx:
|
| 262 |
+
phi = self.phi(i)
|
| 263 |
+
v = float(self.theta @ phi)
|
| 264 |
+
delta = float(r - v) # δ = r − V
|
| 265 |
+
# ★ 误差归一化: 把 δ 缩放到 ~O(1), 否则 IDBD 的 meta-梯度消失
|
| 266 |
+
self.drms = 0.99 * self.drms + 0.01 * delta ** 2
|
| 267 |
+
delta_n = delta / max(1e-6, np.sqrt(self.drms))
|
| 268 |
+
if self.algo == "autostep":
|
| 269 |
+
# ── Autostep (2012 Table 1) ──
|
| 270 |
+
g = np.abs(delta_n * phi * self.h)
|
| 271 |
+
if self.auto_scope == "global":
|
| 272 |
+
# ★ 标量归一化 (单位分析推导的修正): 保留跨分量比例
|
| 273 |
+
gmax = float(g.max()) if g.size else 0.0
|
| 274 |
+
self.v_g = max(gmax, self.v_g + (1.0 / self.tau_a)
|
| 275 |
+
* float(np.sum(self.alpha * phi ** 2))
|
| 276 |
+
* (gmax - self.v_g))
|
| 277 |
+
denom = self.v_g if self.v_g > 0 else 1.0
|
| 278 |
+
self.alpha *= np.exp(
|
| 279 |
+
np.clip(self.mu * delta_n * phi * self.h / denom, -2.0, 2.0))
|
| 280 |
+
else:
|
| 281 |
+
# 论文原版: 逐分量 running max (会把 |e| 卡死在 mu)
|
| 282 |
+
self.v = np.maximum(g, self.v + (1.0 / self.tau_a)
|
| 283 |
+
* self.alpha * phi ** 2 * (g - self.v))
|
| 284 |
+
nz = self.v > 0
|
| 285 |
+
self.alpha[nz] *= np.exp(
|
| 286 |
+
np.clip(self.mu * delta_n * phi[nz] * self.h[nz]
|
| 287 |
+
/ self.v[nz], -2.0, 2.0))
|
| 288 |
+
# (6)(7) 有效步长上界: sum(alpha*x^2) <= 1 => 过冲不可能
|
| 289 |
+
M = max(float(np.sum(self.alpha * phi ** 2)), 1.0)
|
| 290 |
+
self.alpha = self.alpha / M
|
| 291 |
+
# 权重 + 信用迹 (与 IDBD 同)
|
| 292 |
+
self.theta += self.alpha * delta_n * phi
|
| 293 |
+
self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
|
| 294 |
+
+ self.alpha * phi * delta_n
|
| 295 |
+
else:
|
| 296 |
+
# ── IDBD (Sutton 1992, 官方式 (3)) —— 累积型 + 三项安全化 ──
|
| 297 |
+
# 官方: alpha *= exp(mu * delta * x * h) (指数项**带 x**)
|
| 298 |
+
#
|
| 299 |
+
# 三处安全化, 每处都刻意**保住累积性** (与 Autostep 的取舍相反):
|
| 300 |
+
# ① 量纲标准化: 指数除以 |delta*x*h| 的 running **RMS**。
|
| 301 |
+
# Autostep 用 running max -> 指数恒被卡在 mu, n 从 36 到 1000
|
| 302 |
+
# ratio 恒为 1.01 (实测) = 完全不累积。RMS 是慢变标量,
|
| 303 |
+
# 跨分量比例与时间累积都保留。
|
| 304 |
+
# ② 累积量限幅: 限制的**不是每步指数**, 而是累积后的 log(alpha)
|
| 305 |
+
# 本身 (beta in [log 1e-6, log 10])。Autostep 限制每步指数,
|
| 306 |
+
# 于是总位移被 exp(mu*n) 封顶; 这里允许单步大步, 只防跑飞。
|
| 307 |
+
# ③ 地板可恢复: 官方 IDBD 会把握信噪比差的分量打到下限 (实测出现
|
| 308 |
+
# 1e-5 且再也回不来)。这里对撞到地板的分量给一个微小的复活推进。
|
| 309 |
+
self.theta += self.alpha * delta_n * phi
|
| 310 |
+
self.h = self.h * np.maximum(0.0, 1.0 - self.alpha * phi ** 2) \
|
| 311 |
+
+ self.alpha * phi * delta_n
|
| 312 |
+
_raw = delta_n * phi * self.h
|
| 313 |
+
if self.algo == "cidbd":
|
| 314 |
+
# ── Continual-IDBD (用户给的 §9 设计) ──
|
| 315 |
+
# g_i = delta*x_i*h_i (元梯度)
|
| 316 |
+
# s_i <- (1-rho)s_i + rho*|g_i| (逐分量 EMA 尺度)
|
| 317 |
+
# beta_i <- beta_i + mu * g_i/(s_i+eps) (归一化后累积)
|
| 318 |
+
# alpha_i = clip(exp(beta_i), a_min, a_max) (**clip 在 alpha 上**,
|
| 319 |
+
# beta 不 clip)
|
| 320 |
+
# h_i <- h_i[1-alpha_i x_i^2]^+ + alpha_i delta x_i
|
| 321 |
+
# 与 Autostep 的关键差别: 用 EMA 而非 running max。
|
| 322 |
+
# max 会把指数**卡死在 mu**(实测 |e|max ≡ mu, n 从 36 到 1000
|
| 323 |
+
# 完全不累积); EMA 是平滑估计, 保留跨分量比例与时间累积。
|
| 324 |
+
self.s = (1.0 - self.rho) * self.s + self.rho * np.abs(_raw)
|
| 325 |
+
_e = self.mu * _raw / (self.s + 1e-12)
|
| 326 |
+
_e = np.clip(_e, -2.0, 2.0)
|
| 327 |
+
self.beta = np.log(np.maximum(self.alpha, 1e-12)) + _e
|
| 328 |
+
self.alpha = np.clip(np.exp(np.clip(self.beta, -30, 30)),
|
| 329 |
+
self.alpha_min, self.alpha_max)
|
| 330 |
+
self.flip_rate.append(float(np.mean(
|
| 331 |
+
np.sign(_e) != np.sign(getattr(self, "_e_prev", _e)))))
|
| 332 |
+
self._e_prev = _e
|
| 333 |
+
elif self.algo == "idbd-raw":
|
| 334 |
+
# ★ 官方 IDBD, **不做任何 running 归一化**。
|
| 335 |
+
# 实测 (真实回路): raw 版 alpha_std=0.2357 / ratio=3.92;
|
| 336 |
+
# 而加 RMS 归一化后掉到 0.0006 / 1.01 —— 因为 delta 已被
|
| 337 |
+
# delta_rms 归一化过一次, 再除 grad_rms 等于**双重归一化**,
|
| 338 |
+
# 指数塌到 ~0, alpha 几乎不动。分化能力被自己的"安全化"杀死。
|
| 339 |
+
# 代价: 指数无界 -> 靠下面的软壁垒与地板兜底。
|
| 340 |
+
_e = np.clip(self.mu * _raw, -2.0, 2.0)
|
| 341 |
+
elif self.algo == "idbd-acc":
|
| 342 |
+
# ★ 真累积: 归一化因子在**前 warmup 步测一次就冻结**。
|
| 343 |
+
# 任何 running 归一化 (Autostep 的 max / 上面的 RMS) 都会把
|
| 344 |
+
# 指数变成平稳量 -> n 从 36 到 1000 完全不累积 (实测 ratio
|
| 345 |
+
# 恒在 ~40x 不增长)。要在时间上累积, 归一化因子必须是常数。
|
| 346 |
+
# 代价: 该常数与任务量级���合 -> 用 warmup 自动标定一次。
|
| 347 |
+
if self._warm < 50:
|
| 348 |
+
self._warm += 1
|
| 349 |
+
self.grms = max(self.grms, float(np.mean(_raw ** 2)))
|
| 350 |
+
_den = max(1e-8, np.sqrt(self.grms))
|
| 351 |
+
else:
|
| 352 |
+
_den = max(1e-8, np.sqrt(self.grms)) # 冻结, 不再更新
|
| 353 |
+
_e = np.clip(self.mu * _raw / _den, -2.0, 2.0)
|
| 354 |
+
else:
|
| 355 |
+
self.grms = 0.99 * self.grms + 0.01 * float(np.mean(_raw ** 2))
|
| 356 |
+
_e = np.clip(self.mu * _raw / max(1e-8, np.sqrt(self.grms)),
|
| 357 |
+
-2.0, 2.0)
|
| 358 |
+
# ★ 软壁垒, 不用硬 clip。
|
| 359 |
+
# 硬 clip 实测的失败: 所有分量的 log(alpha) 都顶到 beta_hi
|
| 360 |
+
# -> 挤在天花板上 -> alpha max/min 塌回 1.0 (分化全丢)。
|
| 361 |
+
# 软壁垒: 接近上界时只阻尼**正向**更新、接近下界时只阻尼
|
| 362 |
+
# **负向**更新。边界因此是「吸引但非吸收」的, 跨分量差异得以保留,
|
| 363 |
+
# 同时仍然防跑飞。
|
| 364 |
+
_la = np.log(self.alpha) + _e
|
| 365 |
+
_span = self.beta_hi - self.beta_lo
|
| 366 |
+
_t = np.clip((_la - self.beta_lo) / _span, 0.0, 1.0) # 0=下界 1=上界
|
| 367 |
+
_la = np.log(self.alpha) + _e * np.where(_e > 0, 1.0 - _t, _t)
|
| 368 |
+
_la = np.clip(_la, self.beta_lo, self.beta_hi) # 极值兜底
|
| 369 |
+
self.alpha = np.exp(_la)
|
| 370 |
+
# ③ 地板复活: 撞到下限的分量获得一个朝向可学区间的微小推进
|
| 371 |
+
_dead = self.alpha <= 1e-6 * 1.01
|
| 372 |
+
if _dead.any():
|
| 373 |
+
self.alpha[_dead] = np.minimum(
|
| 374 |
+
10.0, self.alpha[_dead] * np.exp(0.05 * np.sign(_e[_dead])))
|
| 375 |
+
# (4) 探索记录累积 (redefine 存活)
|
| 376 |
+
self.explore[i] += r
|
| 377 |
+
self.explore_n[i] += 1.0
|
| 378 |
+
deltas.append(delta)
|
| 379 |
+
|
| 380 |
+
# ★ §10 reset/recovery: 误差超阈值 = 环境变了 -> **保留 w**, 只提可塑性。
|
| 381 |
+
# 这是「真正的持续学习」: 不是重训丢知识, 而是提高 plasticity 重新适应。
|
| 382 |
+
if self.algo == "cidbd":
|
| 383 |
+
_E = float(np.mean([d ** 2 for d in deltas])) if deltas else 0.0
|
| 384 |
+
if self.E_base is None:
|
| 385 |
+
self.E_base = max(_E, 1e-9)
|
| 386 |
+
elif _E > self.kappa * self.E_base:
|
| 387 |
+
# 只抬高 alpha (beta 同步), 权重 theta 完全不动
|
| 388 |
+
_new = np.maximum(self.alpha, self.alpha_recovery)
|
| 389 |
+
self.alpha = _new
|
| 390 |
+
self.beta = np.log(np.maximum(_new, 1e-12))
|
| 391 |
+
self.n_recovery += 1
|
| 392 |
+
else:
|
| 393 |
+
self.E_base = 0.999 * self.E_base + 0.001 * _E
|
| 394 |
+
self.baseline += 0.05 * (r - self.baseline) # 简单基线
|
| 395 |
+
self.trace.append({
|
| 396 |
+
"idx": list(map(int, idx)), "reward": round(float(r), 5),
|
| 397 |
+
"delta": round(float(np.mean(deltas)) if deltas else 0.0, 5),
|
| 398 |
+
"alpha_mean": round(float(self.alpha.mean()), 6),
|
| 399 |
+
"alpha_std": round(float(self.alpha.std()), 6),
|
| 400 |
+
"h_norm": round(float(np.linalg.norm(self.h)), 5),
|
| 401 |
+
})
|
| 402 |
+
return float(np.mean(deltas)) if deltas else 0.0
|
| 403 |
+
|
| 404 |
+
# ─────────────── redefine: 探索积累跨重建存活 ───────────────
|
| 405 |
+
def redefine(self, new_desc):
|
| 406 |
+
"""重建候选池, 但**继承**探索记录 / 步长 / 价值参数。
|
| 407 |
+
|
| 408 |
+
leo: "redefine 也需要探索 policy 的积累" —— 若重建就清零,
|
| 409 |
+
则每次 redefine 都从零开始学"哪里值得探索", 探索知识无法累积。
|
| 410 |
+
继承方式: 每个新候选按**物理描述子最近**的旧候选继承。
|
| 411 |
+
"""
|
| 412 |
+
new_desc = np.asarray(new_desc, dtype=np.float64)
|
| 413 |
+
# 归一化用新池自己的统计 (与构造时一致)
|
| 414 |
+
mu_d, sd_d = new_desc.mean(0), new_desc.std(0)
|
| 415 |
+
sd_d[sd_d < 1e-9] = 1.0
|
| 416 |
+
zn = (new_desc - mu_d) / sd_d
|
| 417 |
+
|
| 418 |
+
keep = {"explore": [], "explore_n": [], "acc": [], "learned": []}
|
| 419 |
+
for a in zn:
|
| 420 |
+
j = int(np.argmin(np.linalg.norm(self.z - a, axis=1))) # 最近旧候选
|
| 421 |
+
keep["explore"].append(self.explore[j])
|
| 422 |
+
keep["explore_n"].append(self.explore_n[j])
|
| 423 |
+
keep["acc"].append(self.acc[j])
|
| 424 |
+
keep["learned"].append(bool(self.learned[j]))
|
| 425 |
+
|
| 426 |
+
self.n = len(new_desc)
|
| 427 |
+
self.desc, self.z = new_desc, zn
|
| 428 |
+
self.explore = np.array(keep["explore"])
|
| 429 |
+
self.explore_n = np.array(keep["explore_n"])
|
| 430 |
+
self.acc = np.array(keep["acc"])
|
| 431 |
+
self.learned = np.array(keep["learned"])
|
| 432 |
+
self.visits = np.maximum(1.0, self.explore_n) # 已探过的别当全新的
|
| 433 |
+
self.redefine_count += 1
|
| 434 |
+
# θ / α / h 是全局参数, 天然跨 redefine 存活 —— 这正是"积累"
|
| 435 |
+
|
| 436 |
+
# ─────────────── 诊断 ───────────────
|
| 437 |
+
def stats(self):
|
| 438 |
+
return {
|
| 439 |
+
"algo": self.algo,
|
| 440 |
+
"n_candidates": int(self.n),
|
| 441 |
+
"n_learned": int(self.learned.sum()),
|
| 442 |
+
"redefine_count": int(self.redefine_count),
|
| 443 |
+
# ★ 步长是核心累积量: 它的分化程度 = 是否真的在学"谁该快谁该慢"
|
| 444 |
+
"alpha": {
|
| 445 |
+
"min": round(float(self.alpha.min()), 6),
|
| 446 |
+
"max": round(float(self.alpha.max()), 6),
|
| 447 |
+
"std": round(float(self.alpha.std()), 6),
|
| 448 |
+
"mean": round(float(self.alpha.mean()), 6),
|
| 449 |
+
"ratio_max_min": round(float(self.alpha.max()
|
| 450 |
+
/ max(1e-9, self.alpha.min())), 2),
|
| 451 |
+
},
|
| 452 |
+
"h_norm": round(float(np.linalg.norm(self.h)), 5),
|
| 453 |
+
"theta_norm": round(float(np.linalg.norm(self.theta)), 5),
|
| 454 |
+
"baseline": round(float(self.baseline), 5),
|
| 455 |
+
"explore": {"nonzero": int((self.explore_n > 0).sum()),
|
| 456 |
+
"mean_n": round(float(self.explore_n.mean()), 3)},
|
| 457 |
+
"any_time": round(float(self.any_time), 4),
|
| 458 |
+
"delta_rms": round(float(np.sqrt(max(0.0, self.drms))), 6),
|
| 459 |
+
"grad_rms": round(float(np.sqrt(max(0.0, self.grms))), 6),
|
| 460 |
+
"alpha_dead": int((self.alpha <= 1e-5).sum()),
|
| 461 |
+
"n_recovery": int(getattr(self, "n_recovery", 0)),
|
| 462 |
+
"flip_rate_mean": round(float(np.mean(self.flip_rate[-50:]))
|
| 463 |
+
if getattr(self, "flip_rate", None) else 0.0, 4),
|
| 464 |
+
}
|
hibs_lnn/rr_agent.py
ADDED
|
@@ -0,0 +1,1108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""`RRSkillAgent` —— OaK 第一步(SubTask → Option)在 KeyDoor 上的 agent 侧实现。
|
| 3 |
+
|
| 4 |
+
**为什么是子类**:`SkillAgent.run_episode` 是所有实验臂的公共路径。把 rr 逻辑
|
| 5 |
+
写进父类会改动正在跑的管线;写成子类则:
|
| 6 |
+
|
| 7 |
+
* `mode != "rr"` 时**逐字委托** `super().run_episode()` → primitive /
|
| 8 |
+
rediscover / macro 三个对照臂与原来**逐位相同**(对照干净);
|
| 9 |
+
* 部署只是**新增一个文件**, 不修改任何现有文件。
|
| 10 |
+
|
| 11 |
+
## 与 OpenLoop 的区别(§1, Pitfall 35)
|
| 12 |
+
|
| 13 |
+
a_t = π_o(s_t) ← **每一步都按当前状态重算**(本文件)
|
| 14 |
+
a_{t+i} = a_i^o ← 固定动作序列replay(不是时间抽象)
|
| 15 |
+
|
| 16 |
+
`π_o` 来自子任务的价值迭代解,存成表;执行时**每个状态查一次表**, 所以
|
| 17 |
+
`recompute-count == 执行步数`。
|
| 18 |
+
|
| 19 |
+
## 四条终止判据(§6, 全部必需)
|
| 20 |
+
|
| 21 |
+
① goal_reached 特征达成(x_i(s)=1) ← 子任务的**目的**
|
| 22 |
+
② beta_stop 表中 β_o(s)=True(停车最优)
|
| 23 |
+
③ expired **硬到期**(由 option 自身的名义时长导出, 不是拍脑袋常数)
|
| 24 |
+
④ stall 连续 stall_patience 步状态不变(撞墙空转)
|
| 25 |
+
|
| 26 |
+
另有 ⑤ devalued: 主任务价值追上后增益 ≤ 0 → 主动放弃。
|
| 27 |
+
|
| 28 |
+
到期是**独立的第四条**而不是前三条的兜底 —— 前三条各自会因自己的原因不触发
|
| 29 |
+
(容差尺度错 / 台账空 / 阈值没到), 到期把损害封顶。
|
| 30 |
+
"""
|
| 31 |
+
import numpy as np
|
| 32 |
+
import math
|
| 33 |
+
|
| 34 |
+
from hibs_lnn.rr_options import (
|
| 35 |
+
KeyDoorTabular, build_bottleneck_library, build_random_goal_library,
|
| 36 |
+
build_rr_library, initiation_ok, main_value, option_gain,
|
| 37 |
+
)
|
| 38 |
+
from hibs_lnn.skill_agent import SkillAgent
|
| 39 |
+
from hibs_lnn.subtask_options import STOP
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RRSkillAgent(SkillAgent):
|
| 43 |
+
"""在 `SkillAgent` 上增加 `mode="rr"`(reward-respecting subtask options)。
|
| 44 |
+
|
| 45 |
+
消融旋钮(每个对应矩阵里的一臂):
|
| 46 |
+
rr_v_main "exact" | "learned" 价值函数来源(精确 VI / agent 自己的 Q)
|
| 47 |
+
rr_z_mode "rr" | "zeroV" | "nobonus"
|
| 48 |
+
rr_cumulant "reward" | "zero"
|
| 49 |
+
rr_feats ("key","door") | ("key",) ...
|
| 50 |
+
rr_bonus w̄(浮点, 扫录用)
|
| 51 |
+
rr_lib "rr" | "bottleneck" | "random" 换整库(负对照)
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(self, mdp, mode="rr", seed=0, rr_v_main="exact",
|
| 55 |
+
rr_z_mode="rr", rr_cumulant="reward", rr_feats=("key", "door"),
|
| 56 |
+
rr_bonus=1.0, rr_lib="rr", rr_max_duration=None,
|
| 57 |
+
stall_patience=4, rr_select_rule="uniform",
|
| 58 |
+
rr_min_launches=6, rr_ucb_c=1.0, rr_calib_min_rate=0.5,
|
| 59 |
+
rr_persist=True, rr_forget_hl=float("inf"),
|
| 60 |
+
rr_score="rate", rr_pot="learned",
|
| 61 |
+
rr_calib_min_adv=0.0, rr_retire_rule="internal",
|
| 62 |
+
rr_calib_min_advq=0.0, rr_forgive_p=0.0,
|
| 63 |
+
rr_lr_v=0.2, rr_starve_steps=None, rr_unk_p=0.5,
|
| 64 |
+
rr_three_state=False, rr_dyn="none", rr_dyn_c=1.0,
|
| 65 |
+
rr_evidence=False, rr_ev_n_min=10, rr_ev_z_bad=2.0,
|
| 66 |
+
rr_ev_stale_hl=200, **kw):
|
| 67 |
+
super().__init__(mdp, mode=mode, seed=seed, **kw)
|
| 68 |
+
self.rr_v_main = rr_v_main
|
| 69 |
+
self.rr_z_mode = rr_z_mode
|
| 70 |
+
self.rr_cumulant = rr_cumulant
|
| 71 |
+
self.rr_feats = tuple(rr_feats)
|
| 72 |
+
self.rr_bonus = float(rr_bonus)
|
| 73 |
+
self.rr_lib = rr_lib
|
| 74 |
+
# ★ 选择规则**所有臂共用同一个** —— 这样臂间的差异只能来自"子任务内容",
|
| 75 |
+
# 而不是来自某个选择启发式。规则本身是**另一条消融轴**。
|
| 76 |
+
# "uniform" 在可启动的 option 里均匀抽(每个库都有同等曝光)
|
| 77 |
+
# "gain" argmax(子任务价值 − V_main), 只在 > 0 时启动
|
| 78 |
+
# §3 的反面教训: 用 `V_main` 做增益基线时, `c=−1` 的瓶颈 option
|
| 79 |
+
# 价值为负 -> 永不启动 -> 负对照臂变成**空臂**(不是"更差")。
|
| 80 |
+
self.rr_select_rule = rr_select_rule
|
| 81 |
+
self.rr_max_duration = None if rr_max_duration is None else int(rr_max_duration)
|
| 82 |
+
self.stall_patience = int(stall_patience)
|
| 83 |
+
|
| 84 |
+
self.rr_lib_obj = None
|
| 85 |
+
self.rr_mdl = None
|
| 86 |
+
self._rr_regime = None
|
| 87 |
+
self._rr_cap = {} # option 名 -> 硬到期上限
|
| 88 |
+
# ── 计数器(§3: 模式的机制差异必须在**跑了之后**可被证伪) ──
|
| 89 |
+
self.rr_stats = {
|
| 90 |
+
"recomputes": 0, # π_o 重算次数(闭环执行 ≈ 执行步数)
|
| 91 |
+
"selections": 0, # 启动次数
|
| 92 |
+
"exec_steps": 0, # 在 option 内的总步数
|
| 93 |
+
"terms": {}, # 终止原因台账(**必须非空**)
|
| 94 |
+
"durations": [], # 每次执行的时长(检 lock-in)
|
| 95 |
+
"stall_baseline": [], # 停滞统计的基线样本
|
| 96 |
+
"rebuilds": 0,
|
| 97 |
+
}
|
| 98 |
+
# ══ 自我校准闭环(L2 缺的那一格)══════════════════════════════
|
| 99 |
+
# 这个 dict 是**按 option 归属**的台账。在此之前整个仓库唯一的
|
| 100 |
+
# 更新点是 skill_agent.py:240(旧 macro 路径)的全局计数器 ——
|
| 101 |
+
# `_rr_settle(self, st, steps, reason)` 收下 `st` 然后**完全忽略它**,
|
| 102 |
+
# 所以库级台账对 rr 路径不存在, "这个 option 从不达成目标" 这个
|
| 103 |
+
# 事实无处安放(L2 的 57 启动 / 56 expired / 1 reached 就是这样
|
| 104 |
+
# 被埋掉的: 没有任何东西因为该事实而改变)。
|
| 105 |
+
# 信号取 **option 自己的达成率**(β_at_feature), 不取主奖励 ——
|
| 106 |
+
# KeyDoor 的奖励是稀疏的(只在终点 +1), 拿主奖励做 option 级信号
|
| 107 |
+
# 会退化成"所有 option 都得 0"。
|
| 108 |
+
self.rr_outcome = {} # option 名 -> {"n", "reached", "steps"}
|
| 109 |
+
self.rr_min_launches = int(rr_min_launches) # 判"坏了"所需的最少启动数
|
| 110 |
+
self.rr_ucb_c = float(rr_ucb_c) # 校准选择的探索常数
|
| 111 |
+
self.rr_calib_min_rate = float(rr_calib_min_rate) # 达成率低于此 = 机制坏了
|
| 112 |
+
# ══ 持续性的两个旋钮(「持续自我校准」的"持续")══════════════════
|
| 113 |
+
# rr_persist 台账是否跨 regime 存活。
|
| 114 |
+
# False -> 每次库重建就清空(只在单 regime 内校准)
|
| 115 |
+
# True -> 账随 agent 走(校准成为**跨任务**的知识)
|
| 116 |
+
# rr_forget_hl **忘记半衰期**(以启动次数计)。inf = 不忘记 = 终身平均。
|
| 117 |
+
#
|
| 118 |
+
# ★ 为什么持久化必须配遗忘, 否则持久化是**有害**的:
|
| 119 |
+
# 无遗忘时 n 会跨 regime 累积到几百, 于是
|
| 120 |
+
# (a) 老的成功把新 regime 的失败稀释掉 —— 一个在第 2 个 regime
|
| 121 |
+
# 已经坏掉的 option, 终身达成率仍可能是 0.9, 检测不出来;
|
| 122 |
+
# (b) UCB 探索项 c·sqrt(ln N/(n+1)) 被大 n 压死 —— agent 不再
|
| 123 |
+
# 去重新检验它, 陈旧的判断被永久锁住。
|
| 124 |
+
# 「持续校准」必须跟踪的是**当前胜任度**, 不是历史平均。
|
| 125 |
+
# 实现: 有效计数按 decay = 0.5^(1/hl) 指数衰减。
|
| 126 |
+
# hl=inf -> decay=1.0 -> n_eff ≡ n, 逐位复现旧行为(向后兼容)。
|
| 127 |
+
self.rr_persist = bool(rr_persist)
|
| 128 |
+
self.rr_forget_hl = float(rr_forget_hl)
|
| 129 |
+
# rr_score 选择分数用什么信号
|
| 130 |
+
# "rate" 达成率(option 自己的成功) —— 机制内部视角
|
| 131 |
+
# "eff" 达成次数 / 消耗步数 —— **把主任务的成本接进来**
|
| 132 |
+
# 奖励稀疏时主奖励做不了 option 级信号, 但**时间是通用货币**:
|
| 133 |
+
# 视野有限, 白走的路严格是损失。所以"每步产出多少达成"是
|
| 134 |
+
# 一个不需要 oracle、又对主任务有意义的信号。
|
| 135 |
+
self.rr_score = rr_score
|
| 136 |
+
if rr_score not in ("rate", "eff", "adv", "advq"):
|
| 137 |
+
raise ValueError(f"unknown rr_score: {rr_score}")
|
| 138 |
+
# ══ 主任务信号(§8 明写的那个缺口, 现在被实测证据逼出来了)══════
|
| 139 |
+
# 为什么必须有: 只用 **option 自己的判据**(达成率)做校准是**错的**。
|
| 140 |
+
# 实测: 把一个 option 的上限压到 1 步 -> 它"达不成自己的子目标"
|
| 141 |
+
# (达成率 0.2), 但**它执行的那一步仍然是 π_o 的最优步**, 对主任务
|
| 142 |
+
# 有益。按内部判据把它淘汰 -> 白损失 π_o 的好步 -> 步数反而差
|
| 143 |
+
# 1.0 步(3 seed 一致)。**校准到错误的信号上, 会淘汰有用的东西。**
|
| 144 |
+
#
|
| 145 |
+
# 主任务信号取 **势函数差分 / 步数**:
|
| 146 |
+
# progress = Φ(终止状态) − Φ(起始状态), adv = Σprogress / Σsteps
|
| 147 |
+
# 这是 SMDP 视角下"这个 option 每消耗一步, 把主任务推进了多少"。
|
| 148 |
+
# Φ 取 **agent 自己的 max_a Q[s,a]** —— 在线、不需要 oracle。
|
| 149 |
+
# (早期 Q 是乐观常数 -> adv≈0 -> 判定"无信息", 这是诚实的行为;
|
| 150 |
+
# 它随学习变得有信息。`rr_pot="exact"` 用模型 VI 的 V_main 作对照,
|
| 151 |
+
# 用来分离"信号质量"与"机制结构"两个因素。)
|
| 152 |
+
self.rr_pot = rr_pot
|
| 153 |
+
if rr_pot not in ("learned", "exact", "gvf"):
|
| 154 |
+
raise ValueError(f"unknown rr_pot: {rr_pot}")
|
| 155 |
+
self.rr_calib_min_adv = float(rr_calib_min_adv)
|
| 156 |
+
# rr_retire_rule 用哪条判据决定淘汰
|
| 157 |
+
# "internal" 只看 option 自己的达成率 (旧行为)
|
| 158 |
+
# "adv" 只看主任务推进量
|
| 159 |
+
# "both" 两条都判坏才淘汰 (保守)
|
| 160 |
+
# "either" 任一条判坏就淘汰 (激进)
|
| 161 |
+
self.rr_retire_rule = rr_retire_rule
|
| 162 |
+
if rr_retire_rule not in ("none", "internal", "adv", "advq", "both",
|
| 163 |
+
"either", "all"):
|
| 164 |
+
raise ValueError(f"unknown rr_retire_rule: {rr_retire_rule}")
|
| 165 |
+
self.rr_regime_idx = -1 # 已见过的 regime 数(诊断用)
|
| 166 |
+
self.rr_calib_hist = [] # [{'regime': i, 'broken': [...], 'usage': {...}}]
|
| 167 |
+
# ══ option 的**学习价值**(SMDP)══════════════════════════════════
|
| 168 |
+
# ★ 这是对"在线可用势函数"问题的正确解 —— 不是找一个更好的 Φ,
|
| 169 |
+
# 而是**取消 Φ 这个中间量**,直接学 option 自己的价值。
|
| 170 |
+
#
|
| 171 |
+
# 为什么 Φ 这条路走不通:
|
| 172 |
+
# `adv = [Φ(s_end) − Φ(s_start)] / steps` 把两件事混在一起 ——
|
| 173 |
+
# (a) option 把我移动了
|
| 174 |
+
# (b) agent 在学习, 所以 Φ 自己变了
|
| 175 |
+
# 而且乐观初始化下 Φ(s_start) = Φ(s_end) = 常数 -> 恒等于 0。
|
| 176 |
+
#
|
| 177 |
+
# 正确的量是 **advantage**:
|
| 178 |
+
# A(s, o) = Q_opt(s, o) − V_base(s), V_base(s) = max_a Q[s, a]
|
| 179 |
+
# 它直接回答"从 s 执行这个 option, 比贪心走基元动作好多少" ——
|
| 180 |
+
# 这正是"要不要留它"该问的问题。阈值 0 在**同一奖励尺度**上有意义,
|
| 181 |
+
# 而且两边**从同一个初值出发**(都 q_init), 所以早期 A ≡ 0 =
|
| 182 |
+
# "还没有信息"(正确行为, 不是 bug)。
|
| 183 |
+
#
|
| 184 |
+
# SMDP 更新(每次 option 结算时):
|
| 185 |
+
# Q_opt[s0, o] += lr · [r_opt + γ^k · V(s1) − Q_opt[s0, o]]
|
| 186 |
+
# r_opt = option 执行期间累计的**主任务**奖励, k = 执行步数。
|
| 187 |
+
# 部分执行(到期/停滞/被中断)按截断处理, 仍是合法 bootstrap。
|
| 188 |
+
#
|
| 189 |
+
# ★ 判别器为什么**从数学里自己掉出来**(不是手设计的阈值):
|
| 190 |
+
# inert(上限压到1步, 但那1步是好的): Q_opt ≈ γ·V(s') 且 V(s') ≈ V(s)/γ
|
| 191 |
+
# => A ≈ 0 -> 不淘汰 ✓
|
| 192 |
+
# harm (反转 π_o, 朝反方向走): Q_opt ≈ γ·V(s_back), V(s_back) ≪ V(s)
|
| 193 |
+
# => A ≪ 0 -> 淘汰 ✓
|
| 194 |
+
self.q_opt = None # (n_states, n_opts) —— 惰性建表
|
| 195 |
+
self._rr_oidx = {} # option 名 -> 列号(跨 regime 稳定的身份)
|
| 196 |
+
self.rr_calib_min_advq = float(rr_calib_min_advq)
|
| 197 |
+
# ★ GVF 势函数: **中性初始化**(全 0)的状态价值表。
|
| 198 |
+
# 为什么不能借用 `self.q.Q` 的 max: 那张表是乐观初始化,
|
| 199 |
+
# 未试过的动作会把 max 顶在初值上 —— 实测 V(s0) 逐位 1.0000。
|
| 200 |
+
self.rr_V = np.zeros(self.mdp.n_states, dtype=float)
|
| 201 |
+
self.rr_lr_v = float(rr_lr_v)
|
| 202 |
+
# ★ "犯错是可以原谅的" = **退休可逆**。被判坏的 option 以 p 的概率被重新检验;
|
| 203 |
+
# 若其统计量恢复(它用的是同一批阈值判据), 下次 `rr_broken` 自然不再点名它。
|
| 204 |
+
self.rr_forgive_p = float(rr_forgive_p)
|
| 205 |
+
# ══ 三态 epistemic state(好 / 坏 / 未知)══════════════════════════════
|
| 206 |
+
# ★ 为什么必须有: 实测发现, `advq == 0` 在两种完全不同的情况下出现 ——
|
| 207 |
+
# (a) 证据充分且真的没有显著差异 -> 好
|
| 208 |
+
# (b) **奖励流为空, Φ 学不到东西**(harm 故障下整臂全灭时
|
| 209 |
+
# `Q_adv ≡ 0.0000` 精确零, 因为账压根没记) -> **未知**
|
| 210 |
+
# 把 (b) 读成"不坏", 就是**自欺**。系统的最大缺口不是精度, 是它
|
| 211 |
+
# 分不清这两种零。所以"未知"必须是一等状态, 不能被 0 顶上。
|
| 212 |
+
#
|
| 213 |
+
# "奖励流为空"的在线判据: 连续多少步没有拿到任何正奖励。
|
| 214 |
+
# 默认 3×horizon = "连着三个回合一分没拿" -> 势函数不可能在学东西。
|
| 215 |
+
self.rr_starve_steps = (int(rr_starve_steps) if rr_starve_steps
|
| 216 |
+
else 3 * int(getattr(mdp, "horizon", 30)))
|
| 217 |
+
self.rr_since_rew = 0 # 距上次拿到正奖励的步数
|
| 218 |
+
self.rr_starve_ever = 0 # 曾经进入"奖励流为空"的次数(诊断)
|
| 219 |
+
self.rr_unk_p = float(rr_unk_p) # "未知"状态的探索概率(有限度探索)
|
| 220 |
+
# ★ 三态开关。默认 **False** = 旧行为逐位不变(向后兼容 + 它本身是
|
| 221 |
+
# 一条消融轴: "把未知提升为一等状态, 到底改变了什么?")。
|
| 222 |
+
# True 时启用三件事: 缓存为空 -> 退回基元层; 未知 -> 有限度探索;
|
| 223 |
+
# 未知 -> 永不算坏。
|
| 224 |
+
self.rr_three_state = bool(rr_three_state)
|
| 225 |
+
# ── 阶段五: 证据库(evidence bank) ──────────────────────────────
|
| 226 |
+
# ★ 默认关闭 ⇒ 旧行为**逐位不变**(与 rr_three_state 同一条纪律)。
|
| 227 |
+
# ★ 语义: 证据**只降级(好->坏), 不升级** —— 与 A 类的反熟悉度约束同一条。
|
| 228 |
+
# 理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
|
| 229 |
+
# 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
|
| 230 |
+
self.rr_evidence = bool(rr_evidence)
|
| 231 |
+
self.rr_ev_n_min = int(rr_ev_n_min)
|
| 232 |
+
self.rr_ev_z_bad = float(rr_ev_z_bad)
|
| 233 |
+
self.rr_ev_stale_hl = int(rr_ev_stale_hl)
|
| 234 |
+
self.rr_bank = None
|
| 235 |
+
self.rr_ev_step = 0
|
| 236 |
+
self.rr_ev_last = None # 最近一次的 support() 引用清单
|
| 237 |
+
self.rr_ev_bad_n = 0 # 证据库投了几次 bad(诊断用)
|
| 238 |
+
if self.rr_evidence:
|
| 239 |
+
from hibs_lnn.evidence_bank import EvidenceBank
|
| 240 |
+
self.rr_bank = EvidenceBank(
|
| 241 |
+
dim=len(self.mdp.vec()), seed=seed,
|
| 242 |
+
n_min=self.rr_ev_n_min, stale_hl=self.rr_ev_stale_hl,
|
| 243 |
+
z_bad=self.rr_ev_z_bad)
|
| 244 |
+
# ── A 类动力学证据(阶段四)─────────────────────────────────
|
| 245 |
+
# `rr_cumulant`(上面那个)是 **option 构造期**参数 —— 它是 OaK 的
|
| 246 |
+
# `(I_o, g_o, π_o, β_o)` 里的 `g_o`。A 类是**每步的动力学读数**,
|
| 247 |
+
# 是另一回事, 所以单开一个通道。
|
| 248 |
+
#
|
| 249 |
+
# ★ 方向约定: 三个 cumulant 都**统一成"压力"(越大越差)** ——
|
| 250 |
+
# A1 转移不确定性(归一化熵) / A2 转移预测误差(1−p(s2|s,a)) /
|
| 251 |
+
# A3 离开已知稳定动力学区域(1−熟悉度)。
|
| 252 |
+
# 这样它们**只能当风险证据**, 不可能被读成"值"。
|
| 253 |
+
self.rr_dyn = str(rr_dyn)
|
| 254 |
+
if self.rr_dyn not in ("none", "N", "A1", "A2", "A3"):
|
| 255 |
+
# "N" = 新颖度**控制量** —— 与 A1/A2/A3 走同一套管道,
|
| 256 |
+
# 用来回答"A 类到底是动力学探测器, 还是只是新颖度探测器"。
|
| 257 |
+
raise ValueError(f"unknown rr_dyn: {rr_dyn}")
|
| 258 |
+
self.rr_dyn_c = float(rr_dyn_c)
|
| 259 |
+
self._rr_T = {} # (s,a) -> {s2: n} 在线转移计数(只用真实转移)
|
| 260 |
+
self._rr_vis = {} # s -> n 访问次数
|
| 261 |
+
self._rr_dense = {} # option -> [n, Σ, Σ²] 动力学证据台账
|
| 262 |
+
self._rr_dseg = [0.0, 0.0, 0.0] # 全段基线 [n, Σ, Σ²] —— "显著更差"的参照
|
| 263 |
+
self._rr_dn = 0 # A 类喂了多少个转移
|
| 264 |
+
self._rr_prev_state = {} # option -> 上次判定(用来数退休/恢复次数)
|
| 265 |
+
|
| 266 |
+
# ── 技能库(每个 regime 重建一次 —— 目标随 regime 移动) ──────────
|
| 267 |
+
def _rr_ensure(self):
|
| 268 |
+
reg = tuple(self.mdp.regime)
|
| 269 |
+
if self._rr_regime == reg and self.rr_lib_obj is not None:
|
| 270 |
+
return
|
| 271 |
+
# ── regime 变了: 台账怎么处理 = 「持续」这条轴 ──────────────────
|
| 272 |
+
if self._rr_regime is not None: # 不是第一次
|
| 273 |
+
self.rr_regime_idx += 1
|
| 274 |
+
self.rr_calib_hist.append({
|
| 275 |
+
"regime": self.rr_regime_idx,
|
| 276 |
+
"broken": sorted(self.rr_broken()),
|
| 277 |
+
"usage": self._rr_usage(),
|
| 278 |
+
})
|
| 279 |
+
if not self.rr_persist:
|
| 280 |
+
# 只在本 regime 内校准 —— 每换一次世界, 经验清零
|
| 281 |
+
self.rr_outcome = {}
|
| 282 |
+
mdl = KeyDoorTabular(self.mdp, gamma=self.q.gamma)
|
| 283 |
+
self.rr_mdl = mdl
|
| 284 |
+
if self.rr_v_main == "exact":
|
| 285 |
+
V_main = main_value(mdl)
|
| 286 |
+
elif self.rr_v_main == "learned":
|
| 287 |
+
# agent 自己的 Q 表 —— 在线版, 不需要 oracle
|
| 288 |
+
V_main = np.max(self.q.Q, axis=1).copy()
|
| 289 |
+
else:
|
| 290 |
+
raise ValueError(f"unknown rr_v_main: {self.rr_v_main}")
|
| 291 |
+
self._rr_v_main_arr = V_main
|
| 292 |
+
|
| 293 |
+
if self.rr_lib == "rr":
|
| 294 |
+
lib = build_rr_library(mdl, V_main, bonus_weight=self.rr_bonus,
|
| 295 |
+
feats=self.rr_feats,
|
| 296 |
+
cumulant=self.rr_cumulant,
|
| 297 |
+
z_mode=self.rr_z_mode)
|
| 298 |
+
elif self.rr_lib == "bottleneck":
|
| 299 |
+
lib = build_bottleneck_library(mdl)
|
| 300 |
+
elif self.rr_lib == "random":
|
| 301 |
+
lib = build_random_goal_library(mdl, V_main, seed=self.rng.randint(1 << 30))
|
| 302 |
+
else:
|
| 303 |
+
raise ValueError(f"unknown rr_lib: {self.rr_lib}")
|
| 304 |
+
|
| 305 |
+
self.rr_lib_obj = lib
|
| 306 |
+
self._rr_regime = reg
|
| 307 |
+
self.rr_stats["rebuilds"] += 1
|
| 308 |
+
# 每个 option 的硬到期上限: **由它自己的名义时长导出**。
|
| 309 |
+
# 名义时长 = 从起始状态出发、按 π_o 走、直到终止判据命中所需步数
|
| 310 |
+
# (模型确定性, 直接模拟)。
|
| 311 |
+
self._rr_cap = {}
|
| 312 |
+
for st in lib:
|
| 313 |
+
nom = self._rr_nominal(st)
|
| 314 |
+
cap = int(self.rr_max_duration) if self.rr_max_duration else max(3, 2 * nom)
|
| 315 |
+
self._rr_cap[st.name] = cap
|
| 316 |
+
|
| 317 |
+
def _rr_nominal(self, st):
|
| 318 |
+
"""模拟 π_o 从**回合起始状态 0** 出发到自己终止要多少步。
|
| 319 |
+
|
| 320 |
+
模型确定性, 直接前向模拟。这个数��� `expired` 上限的依据 ——
|
| 321 |
+
§6 要求上限由 option 自身的名义时长导出, 不是拍脑袋常数。
|
| 322 |
+
"""
|
| 323 |
+
s = 0
|
| 324 |
+
for k in range(1, self.mdp.horizon + 1):
|
| 325 |
+
if st.policy[s] == STOP:
|
| 326 |
+
return k
|
| 327 |
+
nxt = self.rr_mdl.T[s][st.policy[s]][0][1]
|
| 328 |
+
if self.rr_mdl.is_goal[nxt] or float(getattr(st, "feature")[nxt]) > 0:
|
| 329 |
+
return k
|
| 330 |
+
if st.beta[nxt]:
|
| 331 |
+
return k
|
| 332 |
+
if nxt == s:
|
| 333 |
+
return k
|
| 334 |
+
s = nxt
|
| 335 |
+
return self.mdp.horizon
|
| 336 |
+
|
| 337 |
+
# ── 选择 ────────────────────────────────────────────────────────
|
| 338 |
+
def _rr_select(self, s):
|
| 339 |
+
"""选一个 option。规则由 `rr_select_rule` 决定(所有臂共用)。
|
| 340 |
+
|
| 341 |
+
"uniform" —— 在**可启动**(I_o 成立)的 option 里均匀抽。
|
| 342 |
+
每个库都有同等曝光, 所以臂间差异只能来自子任务内容。
|
| 343 |
+
"gain" —— `argmax`(子任务价值 − V_main), 且增益必须 **> 0**。
|
| 344 |
+
这是"哪个子任务现在最值得追"的贪心读法, 本身是一条
|
| 345 |
+
消融轴(更强的先验 = 更多 oracle 信息)。
|
| 346 |
+
"calibrated" —— **自我校准**: 只用**自己的实测达成率**排序,
|
| 347 |
+
不用任何假设的价值。UCB1 形式:
|
| 348 |
+
score = reached/n + c·sqrt(ln(N+1)/(n+1))
|
| 349 |
+
并且**排除被判"坏了"的 option**(见 `rr_broken`)。
|
| 350 |
+
这是"犯错 -> 发现 -> 改正"的闭环本身, 与其它两条
|
| 351 |
+
规则的关键区别是: 它**不引入任何外部先验**,
|
| 352 |
+
"哪个 option 好"完全由 agent 自己的经验决定。
|
| 353 |
+
"""
|
| 354 |
+
cand = [st for st in self.rr_lib_obj if initiation_ok(st, s)]
|
| 355 |
+
# 选择**尝试**总数 —— fallback 占比的分母必须是它, 不能是
|
| 356 |
+
# `selections`(那只在真的选中时加一, 比值会 > 1, 实测 6.827)。
|
| 357 |
+
self.rr_stats["sel_attempts"] = self.rr_stats.get("sel_attempts", 0) + 1
|
| 358 |
+
if not cand:
|
| 359 |
+
return None
|
| 360 |
+
rule = self.rr_select_rule
|
| 361 |
+
if rule == "gain":
|
| 362 |
+
best, best_g = None, 0.0
|
| 363 |
+
for st in cand:
|
| 364 |
+
g = option_gain(st, s, self._rr_v_main_arr)
|
| 365 |
+
if g > best_g:
|
| 366 |
+
best, best_g = st, g
|
| 367 |
+
return best
|
| 368 |
+
if rule == "calibrated":
|
| 369 |
+
# ★ 奖励流为空 -> **退回安全基元策略**。这不是放弃, 是承认"现在
|
| 370 |
+
# 什么都判不了": Φ 学不到东西, 任何判断都是自欺。等主任务拿到
|
| 371 |
+
# 奖励、Φ 重新有内容, 再回来判。
|
| 372 |
+
if self.rr_three_state and self.rr_starved():
|
| 373 |
+
self.rr_stats["starved_sel"] = self.rr_stats.get("starved_sel", 0) + 1
|
| 374 |
+
return None
|
| 375 |
+
broken = self.rr_broken()
|
| 376 |
+
live = [st for st in cand if getattr(st, "name", None) not in broken]
|
| 377 |
+
# ★★ **原谅**(`rr_forgive_p`)—— "犯错是可以原谅的"的机制含义:
|
| 378 |
+
# 被判坏的 option **不是被处决**, 而是以 p 的概率被**重新检验**。
|
| 379 |
+
#
|
| 380 |
+
# 没有这条路就有一个**死锁**: 早期 V 还没学到东西(bootstrap 暂态),
|
| 381 |
+
# option 的价值天然偏低 -> 判坏 -> 不再使用 -> 统计量永不更新 ->
|
| 382 |
+
# 永远坏。实测就是这么崩的: advq 恒 -0.004, fallback 0.999。
|
| 383 |
+
# **退休必须可逆, 否则一次误判 = 永久损失。**
|
| 384 |
+
dea = [st for st in cand if getattr(st, "name", None) in broken]
|
| 385 |
+
if dea and self.rr_forgive_p > 0 and self.rng.rand() < self.rr_forgive_p:
|
| 386 |
+
self.rr_stats["forgiven"] = self.rr_stats.get("forgiven", 0) + 1
|
| 387 |
+
return dea[int(self.rng.randint(len(dea)))]
|
| 388 |
+
if not live: # 全坏了 -> 退回基元层(诚实行为)
|
| 389 |
+
return None
|
| 390 |
+
# ★ 三态驱动的选择(leo 契约: "未知应触发有限度探索"):
|
| 391 |
+
# 未知 -> 以 `rr_unk_p` 的概率**优先挑它** —— 不确定的 option
|
| 392 |
+
# 得先攒到证据, 才可能从"未知"变成"好/坏"。没有这条,
|
| 393 |
+
# 它永远停在未知(不被选 -> 没证据 -> 不被选)。
|
| 394 |
+
# 好 -> 正常 UCB 参与选择。
|
| 395 |
+
# 坏 -> 只在原谅概率下被重新检验。
|
| 396 |
+
unk = [st for st in live
|
| 397 |
+
if self.rr_epistemic(self.rr_outcome.get(
|
| 398 |
+
getattr(st, "name", None), {})) == "unknown"]
|
| 399 |
+
if unk and self.rr_three_state and self.rng.rand() < self.rr_unk_p:
|
| 400 |
+
self.rr_stats["unk_probe"] = self.rr_stats.get("unk_probe", 0) + 1
|
| 401 |
+
return unk[int(self.rng.randint(len(unk)))]
|
| 402 |
+
N = sum(o.get("n_eff", 0.0) for o in self.rr_outcome.values())
|
| 403 |
+
best, best_sc = None, -np.inf # adv 分数可为负 -> 不能用 -1.0 做初值
|
| 404 |
+
for st in live:
|
| 405 |
+
o = self.rr_outcome.get(getattr(st, "name", None))
|
| 406 |
+
if o is None: # 没试过的 option: 无限乐观, 先试它
|
| 407 |
+
return st
|
| 408 |
+
n = o.get("n_eff", 0.0)
|
| 409 |
+
sc = self._rr_score_of(o) + self.rr_ucb_c * float(
|
| 410 |
+
np.sqrt(np.log(N + 1.0) / (n + 1.0)))
|
| 411 |
+
if sc > best_sc:
|
| 412 |
+
best, best_sc = st, sc
|
| 413 |
+
return best
|
| 414 |
+
return cand[int(self.rng.randint(len(cand)))]
|
| 415 |
+
|
| 416 |
+
# ── 一个回合(rr 路径; 其他模式逐字委托父类) ──────────────────
|
| 417 |
+
def run_episode(self):
|
| 418 |
+
if self.mode != "rr":
|
| 419 |
+
return super().run_episode() # 对照臂路径**逐字不变**
|
| 420 |
+
self._rr_ensure()
|
| 421 |
+
s = self.mdp.reset()
|
| 422 |
+
done, n = False, 0
|
| 423 |
+
active, o_steps, prev_s, stuck, o_start = None, 0, None, 0, None
|
| 424 |
+
o_rew = 0.0 # option 执行期间的**主任务**累计奖励(SMDP 更新用)
|
| 425 |
+
while not done and n < self.mdp.horizon:
|
| 426 |
+
v0 = self.mdp.vec() # ★ **步前**状态 —— 与父类 run_episode 同约定。
|
| 427 |
+
a = None
|
| 428 |
+
if self.rng.rand() < self.opt_prob or active is not None:
|
| 429 |
+
# 启动
|
| 430 |
+
if active is None:
|
| 431 |
+
active = self._rr_select(s)
|
| 432 |
+
o_steps, prev_s, stuck, o_start, o_rew = 0, s, 0, s, 0.0
|
| 433 |
+
if active is not None:
|
| 434 |
+
self.rr_stats["selections"] += 1
|
| 435 |
+
# 出动作: **每步按当前状态重算 π_o**(闭环)
|
| 436 |
+
if active is not None:
|
| 437 |
+
if int(active.policy[s]) != STOP:
|
| 438 |
+
a = int(active.policy[s])
|
| 439 |
+
self.rr_stats["recomputes"] += 1
|
| 440 |
+
else:
|
| 441 |
+
self._rr_settle(active, o_steps, "beta_stop", o_start, s,
|
| 442 |
+
r_opt=o_rew)
|
| 443 |
+
active, o_steps, o_start = None, 0, None
|
| 444 |
+
if a is None:
|
| 445 |
+
if active is not None:
|
| 446 |
+
self._rr_settle(active, o_steps, "devalued", o_start, s,
|
| 447 |
+
r_opt=o_rew)
|
| 448 |
+
active, o_steps, o_start = None, 0, None
|
| 449 |
+
a = self.q.act(s)
|
| 450 |
+
s2, r, done = self.mdp.step(a)
|
| 451 |
+
self.q.update(s, a, r, s2, done)
|
| 452 |
+
# ★ GVF 势函数: **每个转移都喂**, 不论那一步是 option 出的还是基元出的。
|
| 453 |
+
# 这就是 option 绕过 Q 也不影响它的原因 —— 它学的是"走到哪值多少",
|
| 454 |
+
# 不是"哪个动作好"。
|
| 455 |
+
self._rr_vstep(s, r, s2, done)
|
| 456 |
+
# ★ A 类动力学证据: **先读数(用更新前的表)再更新** —— 这是
|
| 457 |
+
# "先预测、再观测"的正确在线语义。**失败时这行照样执行**:
|
| 458 |
+
# 到不了终点也有真实转移, 这正是 A 类作为候选的全部理由。
|
| 459 |
+
if self.rr_dyn != "none":
|
| 460 |
+
_dv = self._rr_dval(s, a, s2)
|
| 461 |
+
self._rr_dstep(s, a, s2)
|
| 462 |
+
self._rr_dacc(active.name if active is not None
|
| 463 |
+
else "__primitive__", _dv)
|
| 464 |
+
n += 1
|
| 465 |
+
# ★ 奖励流监控: "连续多少步一分没拿"。Φ 从奖励流学, 奖励流为空
|
| 466 |
+
# 时 Φ 恒为初值 —— 此时任何"不坏"的结论都是自欺。
|
| 467 |
+
self.rr_since_rew = 0 if float(r) > 0 else (self.rr_since_rew + 1)
|
| 468 |
+
# ── 阶段五: 喂证据库 ────────────────────────────────────────
|
| 469 |
+
# ★ `phi=v0, phi_next=self.mdp.vec()` —— **不能两个都传 `self.mdp.vec()`**。
|
| 470 |
+
# `self.mdp` 在 `step()` 里已经前进, 两个都传当前 vec 会让 `v_next == v`,
|
| 471 |
+
# TD 的 `gamma*v_next` 项与 `v` 抵消 ⇒ 学的是"瞬时误差"不是值。
|
| 472 |
+
# (`hibs_lnn/oak_proposer.py:338` 的 `observe_gvf(phi, cums, phi)` 正是
|
| 473 |
+
# 这个形态; 本模块刻意避开, 见 docs/stage5_evidence_bank_spec.md §0。)
|
| 474 |
+
# 四个 cumulant = 这一步**四个不同的可观测事实**:
|
| 475 |
+
# prediction → "有 option 正在执行" reward → 即时奖励
|
| 476 |
+
# transition → "状态没变(自环)" regime → "本回合结束"
|
| 477 |
+
if self.rr_bank is not None:
|
| 478 |
+
_cums = (1.0 if active is not None else 0.0,
|
| 479 |
+
float(r),
|
| 480 |
+
1.0 if s2 == s else 0.0,
|
| 481 |
+
1.0 if done else 0.0)
|
| 482 |
+
self.rr_ev_step += 1
|
| 483 |
+
self.rr_bank.observe(v0, _cums, self.mdp.vec(),
|
| 484 |
+
step=self.rr_ev_step,
|
| 485 |
+
regime=self.rr_regime_idx)
|
| 486 |
+
self.trans.append((v0, a, self.mdp.vec())) # (步前, 动作, 步后)
|
| 487 |
+
# ── 结算判据: 全部在**新状态**上评估(正确语义) ──────────
|
| 488 |
+
if active is not None:
|
| 489 |
+
o_steps += 1
|
| 490 |
+
o_rew += float(r) # 主任务奖励记到**当前这个 option** 头上
|
| 491 |
+
self.rr_stats["exec_steps"] += 1
|
| 492 |
+
reason = None
|
| 493 |
+
# ★ 主判据 = **option 自己的 β_o**(由 stopping value z 导出)。
|
| 494 |
+
# 这是机制本体 —— 之前把"特征达成"排在 β 之前, 导致所有
|
| 495 |
+
# 变体在特征亮起的同一瞬间就停, z 永远轮不到起作用,
|
| 496 |
+
# 5 个 rr 变体的台账**逐位相同**(Pitfall-26 tell)。
|
| 497 |
+
# "恰好停在特征上"现在作为 β_o 的一个**子理由**计数,
|
| 498 |
+
# 这样既能证明机制在跑, 又能看出它停在哪。
|
| 499 |
+
if bool(active.beta[s2]):
|
| 500 |
+
reason = ("beta_at_feature" if float(active.feature[s2]) > 0
|
| 501 |
+
else "beta_elsewhere")
|
| 502 |
+
elif done:
|
| 503 |
+
reason = "horizon_end"
|
| 504 |
+
elif o_steps >= self._rr_cap.get(active.name, self.mdp.horizon):
|
| 505 |
+
reason = "expired" # ③ 硬到期
|
| 506 |
+
elif s2 == prev_s:
|
| 507 |
+
stuck += 1
|
| 508 |
+
self.rr_stats["stall_baseline"].append(stuck)
|
| 509 |
+
if stuck >= self.stall_patience:
|
| 510 |
+
reason = "stall" # ④ 撞墙空转
|
| 511 |
+
else:
|
| 512 |
+
stuck = 0
|
| 513 |
+
if reason:
|
| 514 |
+
self._rr_settle(active, o_steps, reason, o_start, s2,
|
| 515 |
+
r_opt=o_rew, done=bool(done))
|
| 516 |
+
active, o_steps, o_start = None, 0, None
|
| 517 |
+
prev_s = s2
|
| 518 |
+
s = s2
|
| 519 |
+
if active is not None: # 回合结束仍在执行
|
| 520 |
+
self._rr_settle(active, o_steps, "horizon_end", o_start, s,
|
| 521 |
+
r_opt=o_rew, done=True)
|
| 522 |
+
self.episodes += 1
|
| 523 |
+
self._rr_sync_states() # 三态判定 + 退休/恢复计数(每回合一次)
|
| 524 |
+
return bool(done and self.mdp._reached()), n
|
| 525 |
+
|
| 526 |
+
# ── 有效计数(遗忘)──────────────────────────────────────────────
|
| 527 |
+
def _rr_decay(self):
|
| 528 |
+
"""每次启动后, 旧统计量保留的比例。`hl=inf` -> 1.0(不遗忘)。"""
|
| 529 |
+
hl = self.rr_forget_hl
|
| 530 |
+
if hl is None or not np.isfinite(hl) or hl <= 0:
|
| 531 |
+
return 1.0
|
| 532 |
+
return float(0.5 ** (1.0 / hl))
|
| 533 |
+
|
| 534 |
+
@staticmethod
|
| 535 |
+
def _rr_rate(o):
|
| 536 |
+
"""当前胜任度 = reached_eff / n_eff。不遗忘时逐位等于终身平均。"""
|
| 537 |
+
ne = o.get("n_eff", float(o.get("n", 0)))
|
| 538 |
+
return (o.get("reached_eff", float(o.get("reached", 0))) / ne) if ne > 0 else 0.0
|
| 539 |
+
|
| 540 |
+
@staticmethod
|
| 541 |
+
def _rr_eff(o):
|
| 542 |
+
"""**效率** = 达成次数 / 消耗步数(每步产出多少达成)。
|
| 543 |
+
|
| 544 |
+
这是不依赖 oracle 的"主任务成本"信号: 视野有限, 白走的路严格是损失。
|
| 545 |
+
与 `rate` 的区别: 一个"总能达成但每次都绕远路"的 option,
|
| 546 |
+
`rate` 高而 `eff` 低 —— 后者才是对主任务真正有意义的量。
|
| 547 |
+
"""
|
| 548 |
+
return o.get("reached_eff", 0.0) / max(1.0, o.get("steps_eff", 1.0))
|
| 549 |
+
|
| 550 |
+
def _rr_score_of(self, o):
|
| 551 |
+
s = self.rr_score
|
| 552 |
+
if s == "rate":
|
| 553 |
+
return self._rr_rate(o)
|
| 554 |
+
if s == "eff":
|
| 555 |
+
return self._rr_eff(o)
|
| 556 |
+
if s == "advq":
|
| 557 |
+
return self._rr_advq_of(o)
|
| 558 |
+
return self._rr_adv(o)
|
| 559 |
+
|
| 560 |
+
@staticmethod
|
| 561 |
+
def _rr_adv(o):
|
| 562 |
+
"""主任务推进量 = Σ势函数差分 / Σ步数(每步把主任务推进多少)。"""
|
| 563 |
+
return o.get("adv_sum", 0.0) / max(1.0, o.get("adv_steps", 1.0))
|
| 564 |
+
|
| 565 |
+
def _rr_pot(self, s):
|
| 566 |
+
"""势函数 Φ(s)。**必须是一个独立学习、中性初始化的状态价值预测**(GVF)。
|
| 567 |
+
|
| 568 |
+
三个来源, 依次说明为什么只有第三个在线可用:
|
| 569 |
+
|
| 570 |
+
`learned` —— `max_a Q[s,a]`, agent 的动作价值表取最大。
|
| 571 |
+
实测**不可用**: 该表是**乐观初始化**(q_init=+1.0), 只要某个动作
|
| 572 |
+
还没试过, `max_a` 就还是 1.0。实测 option 的启动状态上
|
| 573 |
+
`V(s0)` **逐位等于 1.0000**(初值), 而 `γ^k·V(s1) = 0.717` ->
|
| 574 |
+
advantage ≡ −0.275 —— **量出来的是初值 artifact, 不是 option 好坏**。
|
| 575 |
+
(32 个状态里只有 14 个有任一 Q 离开初值, 但"最大值"几乎都还在初值上。)
|
| 576 |
+
|
| 577 |
+
`exact` —— 模型 VI 的 `V_main`。干净, 但**是 oracle**(用了真转移模型),
|
| 578 |
+
只能当"信号质量"的对照, 不能当在线方案。
|
| 579 |
+
|
| 580 |
+
`gvf` —— ★ **对"在线可用势函数"的答案**: 一张中性初始化(全 0)的
|
| 581 |
+
状态价值表, 用 TD(0) 从 agent **自己走的每一步**学, 不管那一步是
|
| 582 |
+
基元动作还是 option 内部的动作:
|
| 583 |
+
|
| 584 |
+
V[s] += lr_v · [r + γ·V[s'] − V[s]]
|
| 585 |
+
|
| 586 |
+
为什么这样就成了:
|
| 587 |
+
· **中性初值** -> 没有"未试过的动作把 max 顶在初值"这个病;
|
| 588 |
+
· 奖励稀疏(+1 只在终点) -> `V(s)` 自然收敛成 γ^(到目标的步数),
|
| 589 |
+
这**正是**我们要的"离目标多远"的势函数;
|
| 590 |
+
· **每个状态都被喂** -> option 绕过 Q 也没关系, 因为喂 V 的是
|
| 591 |
+
每一步的真实转移, 不是动作选择;
|
| 592 |
+
· 不用转移模型、不用目标位置的坐标 -> **无 oracle**。
|
| 593 |
+
"""
|
| 594 |
+
if s is None:
|
| 595 |
+
return 0.0
|
| 596 |
+
if self.rr_pot == "exact":
|
| 597 |
+
return float(self._rr_v_main_arr[s])
|
| 598 |
+
if self.rr_pot == "gvf":
|
| 599 |
+
return float(self.rr_V[s])
|
| 600 |
+
return float(np.max(self.q.Q[s]))
|
| 601 |
+
|
| 602 |
+
def _rr_vstep(self, s, r, s2, done):
|
| 603 |
+
"""GVF 势函数的一步 TD —— 每个转移都喂, 无论那一步是谁出的动作。"""
|
| 604 |
+
tgt = float(r) if done else float(r) + float(self.q.gamma) * self.rr_V[s2]
|
| 605 |
+
self.rr_V[s] += self.rr_lr_v * (tgt - self.rr_V[s])
|
| 606 |
+
|
| 607 |
+
def _rr_has_base(self, s):
|
| 608 |
+
"""**势函数在该状态上是否有证据**(离开中性初值)。
|
| 609 |
+
|
| 610 |
+
为什么必须有这个门: 在没有任何经验的区域上比较"option 比基线好不好",
|
| 611 |
+
比出来的是**初值的形状**, 不是经验。这不是调阈值, 是判断的前提条件 ——
|
| 612 |
+
"要判断这里用 option 划不划算, 前提是在这里有过经验"。
|
| 613 |
+
"""
|
| 614 |
+
if s is None:
|
| 615 |
+
return False
|
| 616 |
+
if self.rr_pot == "gvf":
|
| 617 |
+
return bool(abs(float(self.rr_V[s])) > 1e-12)
|
| 618 |
+
return bool(np.any(self.q.Q[s] != self.q.q_init))
|
| 619 |
+
|
| 620 |
+
# ── option 的学习价值(SMDP)──────────────────────────────────────
|
| 621 |
+
def _rr_oindex(self, name):
|
| 622 |
+
"""option 名 -> `q_opt` 的列号。名跨 regime 稳定, 所以身份稳定。"""
|
| 623 |
+
if name not in self._rr_oidx:
|
| 624 |
+
self._rr_oidx[name] = len(self._rr_oidx)
|
| 625 |
+
n_col = len(self._rr_oidx)
|
| 626 |
+
if self.q_opt is None:
|
| 627 |
+
# 与基元 Q **同一初值** —— 这样早期 advantage ≡ 0 = "还没有信息",
|
| 628 |
+
# 而不是"所有 option 都很差"。
|
| 629 |
+
self.q_opt = np.full((self.mdp.n_states, max(n_col, 1)),
|
| 630 |
+
float(self.q.q_init))
|
| 631 |
+
elif self.q_opt.shape[1] < n_col:
|
| 632 |
+
pad = np.full((self.mdp.n_states, n_col - self.q_opt.shape[1]),
|
| 633 |
+
float(self.q.q_init))
|
| 634 |
+
self.q_opt = np.hstack([self.q_opt, pad])
|
| 635 |
+
return self._rr_oidx[name]
|
| 636 |
+
|
| 637 |
+
def _rr_vbase(self, s):
|
| 638 |
+
"""V_base(s) = max_a Q[s, a] —— **只用基元动作**的价值。
|
| 639 |
+
|
| 640 |
+
这是 advantage 的比较基线: "不用这个 option, 我靠基元动作能拿到多少"。
|
| 641 |
+
它必须是**基元专属**的, 不能把 option 的价值混进来, 否则
|
| 642 |
+
`Q_opt − V` 恒 <= 0(自己不可能超过包含自己的最大值)。
|
| 643 |
+
"""
|
| 644 |
+
return float(np.max(self.q.Q[s]))
|
| 645 |
+
|
| 646 |
+
def _rr_vall(self, s):
|
| 647 |
+
"""V_all(s) = max(基元, option) —— agent **实际行为**(含 option)的价值。
|
| 648 |
+
|
| 649 |
+
★ SMDP 的 bootstrap 必须用它, 不能用 `_rr_vbase`: option 之间是可以
|
| 650 |
+
串联的(先拿钥匙再开门), 如果 option 从"基元专属"价值 bootstrap,
|
| 651 |
+
option 链上的价值就传不下去 —— 每次传播只能走一层。
|
| 652 |
+
"""
|
| 653 |
+
v = float(np.max(self.q.Q[s]))
|
| 654 |
+
if self.q_opt is not None and self.q_opt.shape[1]:
|
| 655 |
+
v = max(v, float(np.max(self.q_opt[s])))
|
| 656 |
+
return v
|
| 657 |
+
|
| 658 |
+
@staticmethod
|
| 659 |
+
def _rr_advq_of(o):
|
| 660 |
+
"""advantage = Q_opt − Φ(s0) 的(遗忘加权)均值。正 = 比基线好。"""
|
| 661 |
+
return o.get("advq_sum", 0.0) / max(1.0, o.get("advq_n", 1.0))
|
| 662 |
+
|
| 663 |
+
@staticmethod
|
| 664 |
+
def _rr_advq_se(o):
|
| 665 |
+
"""上面那个均值的**标准误**(遗忘加权)。没有它就不能说"显著更差"。"""
|
| 666 |
+
n = max(1.0, o.get("advq_n", 0.0))
|
| 667 |
+
m = o.get("advq_sum", 0.0) / n
|
| 668 |
+
v = max(0.0, o.get("advq_sq", 0.0) / n - m * m)
|
| 669 |
+
return float(np.sqrt(v / n))
|
| 670 |
+
|
| 671 |
+
# ── 三态 epistemic state ────────────────────────────────────────
|
| 672 |
+
# ── A 类动力学 cumulant(阶段四)────────────────────────────────
|
| 673 |
+
def _rr_dval(self, s, a, s2):
|
| 674 |
+
"""A 类 cumulant 的**行前读数**(用更新前的表 —— 先预测再观测)。
|
| 675 |
+
|
| 676 |
+
三条共同契约(leo 的四条要求里最硬的两条):
|
| 677 |
+
· 每一步都能在线获得 —— 只看刚发生的真实转移;
|
| 678 |
+
· **不用目标坐标、不用真实模型、不用未来标签** —— 只有
|
| 679 |
+
`(s, a, s2)` 三元组的计数, agent 自己走出来的;
|
| 680 |
+
· 统一方向 = "压力", 越大越差, ⇒ 天然不可能被读成"值"。
|
| 681 |
+
"""
|
| 682 |
+
if self.rr_dyn == "none":
|
| 683 |
+
return 0.0
|
| 684 |
+
d = self._rr_T.get((int(s), int(a)))
|
| 685 |
+
n = sum(d.values()) if d else 0
|
| 686 |
+
if self.rr_dyn == "N":
|
| 687 |
+
# ★ **控制量: 新颖度**。`1/(1+n(s,a))`, 用**更新前**的计数。
|
| 688 |
+
# 它必须与 A1/A2/A3 **走同一套管道**(同一个 _rr_dval/_rr_dacc/
|
| 689 |
+
# 基线与判据), 否则"A 与 N 有没有分开"比的是实现差异而不是语义。
|
| 690 |
+
# 方向同样统一成"压力": 越新 -> 越大。
|
| 691 |
+
return float(1.0 / (1.0 + n))
|
| 692 |
+
if self.rr_dyn == "A1":
|
| 693 |
+
# ★ A1 = **转移预测误差**(按 leo 的 A′ 规格)。
|
| 694 |
+
# 语义: 用**更新前**的模型预测这一步会落在哪, 再看实际落在哪。
|
| 695 |
+
# ⇒ "意外度" = 1 − p̂_prev(实际后继 | s,a)。
|
| 696 |
+
# ★ 这是**可预测性**, 不是任务价值 —— 一个稳定把你带向
|
| 697 |
+
# 错误方向的动作照样很容易预测(leo 点名的那条)。
|
| 698 |
+
# ★ 与 A2 的分工: A1 对**分布翻转**敏感(A2 不敏感),
|
| 699 |
+
# A2 对**支撑集扩张**敏感(A1 也敏感) —— 两者在翻转环境上
|
| 700 |
+
# **必然分歧**, 这正是检验"它们是不是同一个量"的机会。
|
| 701 |
+
#
|
| 702 |
+
# 注意: 本分支与 A2 早先**写反了**(旧 A1 = 熵, 旧 A2 = 1−p̂)。
|
| 703 |
+
# 在 A′ 里按 leo 规格对齐: A1 = 预测误差, A2 = 不确定性。
|
| 704 |
+
if n < 2:
|
| 705 |
+
return 1.0 # 无先验 ⇒ 没有"预测"可供做错, 报未知
|
| 706 |
+
return float(1.0 - d.get(int(s2), 0) / n)
|
| 707 |
+
if self.rr_dyn == "A2":
|
| 708 |
+
# ★ A2 = **转移不确定性** = 观测到的后继分布归一化熵。
|
| 709 |
+
# ★ 关键区别: 熵对 **{0.9,0.1} 与 {0.1,0.9} 给出同一个数**,
|
| 710 |
+
# 所以分布翻转时 A2 **不跳** —— 这是 A1 ≠ A2 的结构性证据。
|
| 711 |
+
# ★ 但 A2 对"支撑集扩张"(0.9/0.1 -> 0.5/0.5) 敏感。
|
| 712 |
+
if n < 2:
|
| 713 |
+
return 1.0 # 见过的样本不足 ⇒ 报最大不确定
|
| 714 |
+
p = np.array(list(d.values()), float)
|
| 715 |
+
p /= p.sum()
|
| 716 |
+
return float(-(p * np.log(p + 1e-12)).sum()
|
| 717 |
+
/ np.log(max(2, getattr(self.mdp, "n_states", 32))))
|
| 718 |
+
# A3 离开已知稳定动力学区域: 落点的**出发边平均熵**(归一化)。
|
| 719 |
+
# ★ 方向必须与 A1/A2 一致 = "压力"(越大越差)。第一版写成了
|
| 720 |
+
# `1 - 熵`, 返回的是**熟悉度** —— 与通道约定相反, 于是读数恒
|
| 721 |
+
# 1.000(确定性 MDP 里熵恒 0)、永不触发。这是"通道方向写反"
|
| 722 |
+
# 的典型样子: 指标看起来在动, 但基线与判据读的是相反的量。
|
| 723 |
+
ns = int(s2)
|
| 724 |
+
if self._rr_vis.get(ns, 0) == 0:
|
| 725 |
+
return 1.0 # 全新状态 -> 最大压力
|
| 726 |
+
ents = []
|
| 727 |
+
# 用**观测到的**出边动作, 不硬编码 (0,1) —— 第一版漏掉了 GRAB/OPEN,
|
| 728 |
+
# 而 KeyDoor 的动作是 LEFT/RIGHT/GRAB/OPEN 四个。
|
| 729 |
+
acts = {a2 for (ns2, a2) in self._rr_T if ns2 == ns}
|
| 730 |
+
for a2 in (sorted(acts) if acts else (0, 1)):
|
| 731 |
+
dd = self._rr_T.get((ns, int(a2)))
|
| 732 |
+
nn = sum(dd.values()) if dd else 0
|
| 733 |
+
if nn >= 2:
|
| 734 |
+
pp = np.array(list(dd.values()), float)
|
| 735 |
+
pp /= pp.sum()
|
| 736 |
+
ents.append(float(-(pp * np.log(pp + 1e-12)).sum()))
|
| 737 |
+
if not ents:
|
| 738 |
+
return 1.0 # 出边一条都没摸过
|
| 739 |
+
return float(min(1.0, np.mean(ents)
|
| 740 |
+
/ np.log(max(2, getattr(self.mdp, "n_states", 32)))))
|
| 741 |
+
|
| 742 |
+
def _rr_dstep(self, s, a, s2):
|
| 743 |
+
"""把这一步的真实转移记进在线转移表 —— **失败时这行照样执行**。"""
|
| 744 |
+
k = (int(s), int(a))
|
| 745 |
+
d = self._rr_T.get(k)
|
| 746 |
+
if d is None:
|
| 747 |
+
d = self._rr_T[k] = {}
|
| 748 |
+
d[int(s2)] = d.get(int(s2), 0) + 1
|
| 749 |
+
self._rr_vis[int(s2)] = self._rr_vis.get(int(s2), 0) + 1
|
| 750 |
+
self._rr_dn += 1
|
| 751 |
+
|
| 752 |
+
def _rr_dacc(self, name, d):
|
| 753 |
+
"""把读数记到 **具体 option** 的台账 + 全段基线。"""
|
| 754 |
+
L = self._rr_dense.get(name)
|
| 755 |
+
if L is None:
|
| 756 |
+
L = self._rr_dense[name] = [0.0, 0.0, 0.0]
|
| 757 |
+
L[0] += 1.0
|
| 758 |
+
L[1] += d
|
| 759 |
+
L[2] += d * d
|
| 760 |
+
B = self._rr_dseg
|
| 761 |
+
B[0] += 1.0
|
| 762 |
+
B[1] += d
|
| 763 |
+
B[2] += d * d
|
| 764 |
+
|
| 765 |
+
def _rr_dyn_bad(self, name):
|
| 766 |
+
"""A 类风险判据: 该 option 的动力学压力是否**显著高于本段基线**。
|
| 767 |
+
|
| 768 |
+
尺度**���全由数据自己的方差给出**(两样本 SE), 不引入手工 ε ——
|
| 769 |
+
与 leo 在 inert 上要求的"'接近零'必须使用健康段自身的方差定义"同一条。
|
| 770 |
+
"""
|
| 771 |
+
if self.rr_dyn == "none":
|
| 772 |
+
return False
|
| 773 |
+
L = self._rr_dense.get(name)
|
| 774 |
+
if L is None or L[0] < self.rr_min_launches:
|
| 775 |
+
return False
|
| 776 |
+
Bn, Bs, Bs2 = self._rr_dseg
|
| 777 |
+
if Bn < 20 or Bn <= L[0]:
|
| 778 |
+
return False
|
| 779 |
+
m = L[1] / L[0]
|
| 780 |
+
v = max(0.0, L[2] / L[0] - m * m)
|
| 781 |
+
bm = Bs / Bn
|
| 782 |
+
bv = max(0.0, Bs2 / Bn - bm * bm)
|
| 783 |
+
se = math.sqrt(v / L[0] + bv / Bn)
|
| 784 |
+
return (m - bm) > self.rr_dyn_c * se
|
| 785 |
+
|
| 786 |
+
def rr_dyn_stats(self):
|
| 787 |
+
"""A 类通道的报告: 证据量 / 均值 / 是否触发风险。"""
|
| 788 |
+
Bn, Bs, Bs2 = self._rr_dseg
|
| 789 |
+
bm = Bs / Bn if Bn else 0.0
|
| 790 |
+
per = {}
|
| 791 |
+
for k in sorted(self._rr_dense):
|
| 792 |
+
L = self._rr_dense[k]
|
| 793 |
+
m = L[1] / L[0] if L[0] else 0.0
|
| 794 |
+
per[k] = {"n": int(L[0]), "mean": round(m, 4),
|
| 795 |
+
"risk": bool(self._rr_dyn_bad(k))}
|
| 796 |
+
return {"kind": self.rr_dyn, "transitions": int(self._rr_dn),
|
| 797 |
+
"base_mean": round(bm, 4), "base_n": int(Bn), "per_option": per}
|
| 798 |
+
|
| 799 |
+
def rr_starved(self):
|
| 800 |
+
"""奖励流是否为空 —— 势函数不可能在学东西。
|
| 801 |
+
|
| 802 |
+
★ 这是本轮最重要的那个门。实测: harm 故障下整臂全灭时
|
| 803 |
+
`Q_adv ≡ 0.0000` **精确为零** —— 不是"没有显著差异", 是"根本没记账"
|
| 804 |
+
(到不了终点 -> 无奖励 -> Φ 恒为 0 -> 证据门 `_rr_has_base` 挡掉一切)。
|
| 805 |
+
不加这个门, 系统会把"我不知道"读成"没问题" —— **自欺**。
|
| 806 |
+
"""
|
| 807 |
+
return self.rr_since_rew >= self.rr_starve_steps
|
| 808 |
+
|
| 809 |
+
def rr_epistemic(self, o):
|
| 810 |
+
"""**三态判定**: `"good"` / `"bad"` / `"unknown"`。
|
| 811 |
+
|
| 812 |
+
与旧 `rr_broken` 的关键区别: **"未知"不是"好"**。
|
| 813 |
+
旧代码 `advq = 0 -> 不 < 0 -> 不淘汰 -> 当作健康`, 而在奖励流为空时
|
| 814 |
+
`advq` 恰好是精确的 0 —— 于是"没有证据"被系统性地读成"没问题"。
|
| 815 |
+
|
| 816 |
+
判据(与 leo 定的契约一致):
|
| 817 |
+
未知 —— 奖励流为空 / 势函数未离开初值 / 证据量不足(< min_launches)
|
| 818 |
+
坏 —— 证据充分, 且**上置信界 < 0**(显著更差)
|
| 819 |
+
好 —— 证据充分, 且没有显著负向信号
|
| 820 |
+
"""
|
| 821 |
+
if self.rr_starved():
|
| 822 |
+
return "unknown" # Φ 学不到 -> 无有效预测证据
|
| 823 |
+
n = o.get("advq_n", 0.0)
|
| 824 |
+
if n < self.rr_min_launches:
|
| 825 |
+
return "unknown" # 证据不足
|
| 826 |
+
m = self._rr_advq_of(o)
|
| 827 |
+
if m + self.rr_ucb_c * self._rr_advq_se(o) < 0.0:
|
| 828 |
+
return "bad"
|
| 829 |
+
# ★★ **反熟悉度保证**: A 类动力学证据**只能降级(好 -> 坏)**,
|
| 830 |
+
# **永远不能升级(未知 -> 好)**。也就是说"可预测"买不到"好" ——
|
| 831 |
+
# 否则一个稳定把你带向错误方向的 option 会因为好预测而被判好。
|
| 832 |
+
# 这条不是调出来的, 是设计约束: A 类是**风险通道**, 不是值信号。
|
| 833 |
+
# (它到底能不能当值信号, 由"稳定但错误"否证实验来判。)
|
| 834 |
+
nm = o.get("name")
|
| 835 |
+
if nm is not None and self._rr_dyn_bad(nm):
|
| 836 |
+
return "bad"
|
| 837 |
+
# ── 阶段五: 证据库(★ **只降级, 不升级**)──────────────────────
|
| 838 |
+
# 与 A 类同一条不对称约束: 证据可以投 `bad`, 但**永远不能**把状态升成
|
| 839 |
+
# `good`。理由: 预测误差低只说明"我预测得准", 不说明"这件事有价值";
|
| 840 |
+
# 而**新颖度会拉高误差** ⇒ 允许升级就等于允许新颖度伪造"好"。
|
| 841 |
+
# ⇒ 证据缺席不改变判定, 证据示警才降级。
|
| 842 |
+
if self.rr_bank is not None:
|
| 843 |
+
self.rr_ev_last = self.rr_bank.support(step=self.rr_ev_step,
|
| 844 |
+
regime=self.rr_regime_idx)
|
| 845 |
+
if self.rr_ev_last.get("verdict") == "bad":
|
| 846 |
+
self.rr_ev_bad_n += 1
|
| 847 |
+
return "bad"
|
| 848 |
+
return "good"
|
| 849 |
+
|
| 850 |
+
def rr_ev_support(self):
|
| 851 |
+
"""★ 最近一次证据判定背后的**引用清单** —— 哪条支持 bad / 哪条缺席。
|
| 852 |
+
|
| 853 |
+
这是"证据可追溯"的直接接口: 调用方拿到的不是分数, 是**来源**。
|
| 854 |
+
"""
|
| 855 |
+
if self.rr_bank is None:
|
| 856 |
+
return {"enabled": False}
|
| 857 |
+
s = self.rr_bank.support(step=self.rr_ev_step, regime=self.rr_regime_idx)
|
| 858 |
+
s["enabled"] = True
|
| 859 |
+
s["n_bad_ever"] = int(self.rr_ev_bad_n)
|
| 860 |
+
return s
|
| 861 |
+
|
| 862 |
+
def rr_complementarity(self, label="reward"):
|
| 863 |
+
"""★ 这组 GVF 到底有没有产生**互补证据**。
|
| 864 |
+
|
| 865 |
+
`label`: 用哪个 cumulant 的二值化当"变化/未变化"标签。
|
| 866 |
+
默认 `"reward"` ⇒ 问的是「这组证据在**奖励到达**这件事上互不互补」。
|
| 867 |
+
|
| 868 |
+
⚠ 口径限制(必须写明): 标签本身就是四个 cumulant 之一,
|
| 869 |
+
所以对 `reward` 那条 GVF 天然有利。读 `complementarity` 时要同时看
|
| 870 |
+
`auc_i`(**单条**的 AUC)与 `corr_err`(冗余度), 不能只看差值。
|
| 871 |
+
|
| 872 |
+
**允许结论是"冗余"** —— 那就照实报冗余。
|
| 873 |
+
"""
|
| 874 |
+
if self.rr_bank is None:
|
| 875 |
+
return {"enabled": False}
|
| 876 |
+
from hibs_lnn.evidence_bank import complementarity
|
| 877 |
+
h = self.rr_bank.z_history()
|
| 878 |
+
if label not in h["cumulant"]:
|
| 879 |
+
return {"enabled": True, "error": f"unknown label {label}",
|
| 880 |
+
"labels": list(h["cumulant"])}
|
| 881 |
+
lab = (h["cumulant"][label] > 0).astype(int)
|
| 882 |
+
if lab.min() == lab.max():
|
| 883 |
+
return {"enabled": True, "error": "标签全为同一值, 无法算 AUC",
|
| 884 |
+
"label": label, "n": int(lab.size)}
|
| 885 |
+
r = complementarity(h["z"], lab, z_bad=self.rr_ev_z_bad)
|
| 886 |
+
r.update({"enabled": True, "label": label, "n": int(lab.size),
|
| 887 |
+
"pos_rate": float(lab.mean())})
|
| 888 |
+
return r
|
| 889 |
+
|
| 890 |
+
def rr_epistemic_all(self, names=None):
|
| 891 |
+
return {k: self.rr_epistemic(o) for k, o in self.rr_outcome.items()
|
| 892 |
+
if names is None or k in names}
|
| 893 |
+
|
| 894 |
+
def _rr_sync_states(self):
|
| 895 |
+
"""把三态判定同步进台账, 并**计数退休/恢复**。
|
| 896 |
+
|
| 897 |
+
为什么必须分开记: leo 的契约里 `当前状态` 用于**决策**,
|
| 898 |
+
`历史状态` 用于**审计** —— 一个 option 被判坏过几次、又恢复过几次,
|
| 899 |
+
是"持续学习是否真的在发生"的直接证据, 不能被当前状态覆盖掉。
|
| 900 |
+
"""
|
| 901 |
+
for k, o in self.rr_outcome.items():
|
| 902 |
+
st = self.rr_epistemic(o)
|
| 903 |
+
old = self._rr_prev_state.get(k)
|
| 904 |
+
if old is not None and st != old:
|
| 905 |
+
if st == "bad":
|
| 906 |
+
o["retired_n"] = o.get("retired_n", 0) + 1
|
| 907 |
+
elif old == "bad":
|
| 908 |
+
o["recovered_n"] = o.get("recovered_n", 0) + 1
|
| 909 |
+
self._rr_prev_state[k] = st
|
| 910 |
+
o["state"] = st
|
| 911 |
+
if self.rr_starved():
|
| 912 |
+
self.rr_starve_ever += 1
|
| 913 |
+
|
| 914 |
+
def _rr_settle(self, st, steps, reason, s0=None, s1=None,
|
| 915 |
+
r_opt=0.0, done=False):
|
| 916 |
+
self.rr_stats["terms"][reason] = self.rr_stats["terms"].get(reason, 0) + 1
|
| 917 |
+
self.rr_stats["durations"].append(int(steps))
|
| 918 |
+
# ★ 归属到**这个 option**(此前 `st` 收下即弃 —— 库级台账不存在)
|
| 919 |
+
o = self.rr_outcome.setdefault(getattr(st, "name", id(st)),
|
| 920 |
+
{"n": 0, "reached": 0, "steps": 0,
|
| 921 |
+
"name": getattr(st, "name", None),
|
| 922 |
+
"n_eff": 0.0, "reached_eff": 0.0,
|
| 923 |
+
"steps_eff": 0.0,
|
| 924 |
+
"adv_sum": 0.0, "adv_steps": 0.0,
|
| 925 |
+
"advq_sum": 0.0, "advq_n": 0.0,
|
| 926 |
+
"advq_sq": 0.0})
|
| 927 |
+
dec = self._rr_decay()
|
| 928 |
+
hit = 1.0 if reason == "beta_at_feature" else 0.0
|
| 929 |
+
# 终身计数(永不衰减, 供报告与判据门限用)
|
| 930 |
+
o["n"] += 1
|
| 931 |
+
o["steps"] += int(steps)
|
| 932 |
+
o["reached"] += int(hit)
|
| 933 |
+
# 有效计数(按 rr_forget_hl 指数遗忘; dec=1.0 时逐位等于终身计数)
|
| 934 |
+
o["n_eff"] = dec * o["n_eff"] + 1.0
|
| 935 |
+
o["reached_eff"] = dec * o["reached_eff"] + hit
|
| 936 |
+
o["steps_eff"] = dec * o["steps_eff"] + int(steps)
|
| 937 |
+
# 势函数差分(旧方案; 保留作对照)
|
| 938 |
+
prog = self._rr_pot(s1) - self._rr_pot(s0)
|
| 939 |
+
o["adv_sum"] = dec * o["adv_sum"] + prog
|
| 940 |
+
o["adv_steps"] = dec * o["adv_steps"] + max(1, int(steps))
|
| 941 |
+
# ★ SMDP 更新 + advantage 记账(新方案; 在线、无 oracle)
|
| 942 |
+
if s0 is not None and s1 is not None:
|
| 943 |
+
name = getattr(st, "name", None)
|
| 944 |
+
if name is not None:
|
| 945 |
+
j = self._rr_oindex(name)
|
| 946 |
+
qo = self.q_opt
|
| 947 |
+
assert qo is not None # _rr_oindex 已建表
|
| 948 |
+
g, k = float(self.q.gamma), max(1, int(steps))
|
| 949 |
+
# ★ bootstrap 用**势函数** Φ: 它才是"agent 现在认为走到某状态值多少"
|
| 950 |
+
# 的在线估计。`gvf` 时它是中性初始化独立学的状态价值,
|
| 951 |
+
# 没有"未试过的动作把 max 顶在初值"这个病。
|
| 952 |
+
boot = 0.0 if done else (g ** k) * self._rr_pot(s1)
|
| 953 |
+
tgt = float(r_opt) + boot
|
| 954 |
+
qo[s0, j] += self.q.lr * (tgt - qo[s0, j])
|
| 955 |
+
# ★ 只在**势函数对该状态有证据**时记账 —— 否则比的是初值的形状
|
| 956 |
+
if self._rr_has_base(s0):
|
| 957 |
+
advq = float(qo[s0, j]) - self._rr_pot(s0)
|
| 958 |
+
o["advq_sum"] = dec * o["advq_sum"] + advq
|
| 959 |
+
o["advq_sq"] = dec * o.get("advq_sq", 0.0) + advq * advq
|
| 960 |
+
o["advq_n"] = dec * o["advq_n"] + 1.0
|
| 961 |
+
else:
|
| 962 |
+
o["no_base"] = o.get("no_base", 0) + 1
|
| 963 |
+
|
| 964 |
+
# ── 自我校准: 检测 + 淘汰 ───────────────────────────────────────
|
| 965 |
+
def rr_broken(self, names=None):
|
| 966 |
+
"""哪些 option 的**终止机制坏了**(而不是"暂时运气差")。
|
| 967 |
+
|
| 968 |
+
判据(事前定义, 只看 option 自己的达成率, 不看主任务表现):
|
| 969 |
+
启动 >= `rr_min_launches` 次 且 达成率 < `rr_calib_min_rate`
|
| 970 |
+
|
| 971 |
+
## 为什么不是 `reached == 0`
|
| 972 |
+
|
| 973 |
+
最初的写法是"从没达成过"(绝对零)。这是错的 —— **连 L2 的真案都
|
| 974 |
+
抓不到**: L2 是 57 启动 / 1 次 goal_reached, 达成率 0.018, 不是 0。
|
| 975 |
+
绝对零判据会放过它。
|
| 976 |
+
|
| 977 |
+
## 0.5 这个线的依据(来自实测的两个簇, 不是拍脑袋)
|
| 978 |
+
|
| 979 |
+
坏的: L2 真案 0.018 (56/57 expired) | 本轮制造器 0.250
|
| 980 |
+
好的: rr[key] 1.000 (确定性 MDP + 模型导出的 π_o 应当几乎必成)
|
| 981 |
+
rr-zeroV 0.595 (健康但偏早停的臂)
|
| 982 |
+
|
| 983 |
+
两个簇之间有一大段空档, 0.5 落在空档里。**确定性 MDP 上模型导出的
|
| 984 |
+
option 本该接近 1.0**, 明显低于 1.0 就意味着终止机制或 π_o 有问题。
|
| 985 |
+
"""
|
| 986 |
+
out = set()
|
| 987 |
+
for k, o in self.rr_outcome.items():
|
| 988 |
+
if names is not None and k not in names:
|
| 989 |
+
continue
|
| 990 |
+
if o.get("n_eff", float(o.get("n", 0))) < self.rr_min_launches:
|
| 991 |
+
continue
|
| 992 |
+
bad_int = self._rr_rate(o) < self.rr_calib_min_rate
|
| 993 |
+
bad_adv = self._rr_adv(o) < self.rr_calib_min_adv
|
| 994 |
+
# advq 额外要求**基线证据够** + **显著更差**:
|
| 995 |
+
# ★ 判据必须是 "上置信界 < 0"(显著更差), 不是 "均值 < 0"。
|
| 996 |
+
# 实测量到 Q_adv = −0.0002 —— 那是**零的噪声范围之内**,
|
| 997 |
+
# 严格 `< 0` 会把每一个 option 都判坏(fallback 0.994)。
|
| 998 |
+
# 证据不足时不许处决 —— 这正是"犯错是可以原谅的"的统计版本,
|
| 999 |
+
# 而且它不需要任何手工 epsilon: 尺度由数据自己的方差给出。
|
| 1000 |
+
advq_mean = self._rr_advq_of(o)
|
| 1001 |
+
bad_q = (o.get("advq_n", 0.0) >= self.rr_min_launches
|
| 1002 |
+
and advq_mean + self.rr_ucb_c * self._rr_advq_se(o) < 0.0)
|
| 1003 |
+
# ★ 三态: "未知"**永远不算坏** —— 证据不足 / 奖励流为空都不处决。
|
| 1004 |
+
# 这是"退休必须保守"的原则: 宁可留着不确定的, 不可错杀。
|
| 1005 |
+
if self.rr_three_state and self.rr_epistemic(o) == "unknown":
|
| 1006 |
+
bad_q = False
|
| 1007 |
+
rule = self.rr_retire_rule
|
| 1008 |
+
if rule == "none": # 诊断臂: 只报数, 不淘汰
|
| 1009 |
+
hit = False
|
| 1010 |
+
elif rule == "internal":
|
| 1011 |
+
hit = bad_int
|
| 1012 |
+
elif rule == "adv":
|
| 1013 |
+
hit = bad_adv
|
| 1014 |
+
elif rule == "advq": # ★ 在线可用: 只需 agent 自己的 Q
|
| 1015 |
+
hit = bad_q
|
| 1016 |
+
elif rule == "both":
|
| 1017 |
+
hit = bad_int and bad_q
|
| 1018 |
+
elif rule == "all":
|
| 1019 |
+
hit = bad_int and bad_adv and bad_q
|
| 1020 |
+
else:
|
| 1021 |
+
hit = bad_int or bad_q
|
| 1022 |
+
if hit:
|
| 1023 |
+
out.add(k)
|
| 1024 |
+
return out
|
| 1025 |
+
|
| 1026 |
+
def rr_calib_stats(self):
|
| 1027 |
+
"""校准台账: 每个 option 的启动数 / **当前胜任度** / 效率 + 谁被判坏了。
|
| 1028 |
+
|
| 1029 |
+
注意 `rate` 报的是**有效达成率**(按 `rr_forget_hl` 遗忘后的当前胜任度),
|
| 1030 |
+
`rate_life` 才是终身平均 —— 持久化的意义就在这两者的差别上。
|
| 1031 |
+
"""
|
| 1032 |
+
per = {}
|
| 1033 |
+
for k, o in sorted(self.rr_outcome.items()):
|
| 1034 |
+
ne = o.get("n_eff", float(o.get("n", 0)))
|
| 1035 |
+
m = self._rr_advq_of(o)
|
| 1036 |
+
se = self._rr_advq_se(o)
|
| 1037 |
+
per[k] = {
|
| 1038 |
+
# ── 当前判定(用于决策)──────────────────────────────
|
| 1039 |
+
"state": self.rr_epistemic(o),
|
| 1040 |
+
# ── 证据量 ──────────────────────────────────────────
|
| 1041 |
+
"n": o["n"],
|
| 1042 |
+
"n_eff": round(ne, 2),
|
| 1043 |
+
"advq_n": round(o.get("advq_n", 0.0), 2),
|
| 1044 |
+
"no_base": o.get("no_base", 0), # 有启动但没进比较的次数
|
| 1045 |
+
# ── 近期状态(遗忘加权)─────────────────────────────
|
| 1046 |
+
"rate": round(self._rr_rate(o), 4),
|
| 1047 |
+
"advq": round(m, 4),
|
| 1048 |
+
"advq_se": round(se, 4),
|
| 1049 |
+
"advq_lo": round(m - self.rr_ucb_c * se, 4), # 下置信界
|
| 1050 |
+
"advq_hi": round(m + self.rr_ucb_c * se, 4), # 上置信界
|
| 1051 |
+
"eff": round(self._rr_eff(o), 4),
|
| 1052 |
+
"adv": round(self._rr_adv(o), 4),
|
| 1053 |
+
# ── 历史状态(用于审计; 与当前状态分开)────────────
|
| 1054 |
+
"reached": o["reached"],
|
| 1055 |
+
"rate_life": round(o["reached"] / o["n"], 4) if o["n"] else 0.0,
|
| 1056 |
+
"retired_n": o.get("retired_n", 0), # 被判坏过几次
|
| 1057 |
+
"recovered_n": o.get("recovered_n", 0), # 从坏恢复过几次
|
| 1058 |
+
"mean_steps": round(o["steps"] / o["n"], 2) if o["n"] else 0.0,
|
| 1059 |
+
}
|
| 1060 |
+
broke = self.rr_broken()
|
| 1061 |
+
states = [v["state"] for v in per.values()]
|
| 1062 |
+
return {
|
| 1063 |
+
"per_option": per, "broken": sorted(broke), "n_broken": len(broke),
|
| 1064 |
+
# ★ 三态汇总 + 自欺门的状态 —— 报告层必须能一眼看出
|
| 1065 |
+
# "这次是真的没问题" 还是 "这次根本没数据"。
|
| 1066 |
+
"states": {"good": states.count("good"),
|
| 1067 |
+
"bad": states.count("bad"),
|
| 1068 |
+
"unknown": states.count("unknown")},
|
| 1069 |
+
"starved": bool(self.rr_starved()),
|
| 1070 |
+
# ★ 阶段五: 证据库 —— 报的是**引用清单**不是分数
|
| 1071 |
+
"evidence": (self.rr_ev_support() if self.rr_evidence else {"enabled": False}),
|
| 1072 |
+
"since_rew": int(self.rr_since_rew),
|
| 1073 |
+
"starve_steps": int(self.rr_starve_steps),
|
| 1074 |
+
"starve_ever": int(self.rr_starve_ever),
|
| 1075 |
+
"sel_starved": self.rr_stats.get("starved_sel", 0),
|
| 1076 |
+
"sel_unk_probe": self.rr_stats.get("unk_probe", 0),
|
| 1077 |
+
"sel_forgiven": self.rr_stats.get("forgiven", 0),
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
def _rr_usage(self):
|
| 1081 |
+
"""各 option 的**使用占比**(校准的直接观测量)。"""
|
| 1082 |
+
tot = sum(o["n"] for o in self.rr_outcome.values())
|
| 1083 |
+
if not tot:
|
| 1084 |
+
return {}
|
| 1085 |
+
return {k: round(o["n"] / tot, 4) for k, o in sorted(self.rr_outcome.items())}
|
| 1086 |
+
|
| 1087 |
+
# ── 台账 ────────────────────────────────────────────────────────
|
| 1088 |
+
def rr_bookkeeping(self):
|
| 1089 |
+
d = list(self.rr_stats["durations"])
|
| 1090 |
+
base = self.rr_stats["stall_baseline"]
|
| 1091 |
+
return {
|
| 1092 |
+
"recomputes": self.rr_stats["recomputes"],
|
| 1093 |
+
"selections": self.rr_stats["selections"],
|
| 1094 |
+
"exec_steps": self.rr_stats["exec_steps"],
|
| 1095 |
+
"terms": dict(self.rr_stats["terms"]),
|
| 1096 |
+
"rebuilds": self.rr_stats["rebuilds"],
|
| 1097 |
+
"n_options": 0 if self.rr_lib_obj is None else len(self.rr_lib_obj),
|
| 1098 |
+
"mean_duration": float(np.mean(d)) if d else 0.0,
|
| 1099 |
+
"max_duration": int(np.max(d)) if d else 0,
|
| 1100 |
+
"horizon": int(self.mdp.horizon),
|
| 1101 |
+
# ★ lock-in 检: 平均执行时长接近整回合 = 不是抽象而是锁死。
|
| 1102 |
+
# 阈值取自 option 自身名义时长, 所以也一并报出来。
|
| 1103 |
+
"lockin_ratio": (float(np.mean(d)) / max(1, self.mdp.horizon)) if d else 0.0,
|
| 1104 |
+
"max_cap": (int(max(self._rr_cap.values())) if self._rr_cap else 0),
|
| 1105 |
+
"stall_trigger_frac": (float(np.sum([1 for b in base if b >= self.stall_patience])) / max(1, len(base))) if base else 0.0,
|
| 1106 |
+
"beta_nonempty": 0 if self.rr_lib_obj is None else
|
| 1107 |
+
int(sum(int(np.sum(o.beta)) for o in self.rr_lib_obj)),
|
| 1108 |
+
}
|
hibs_lnn/rr_options.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Reward-respecting subtask options on `KeyDoorMDP`(OaK 的第一步)。
|
| 3 |
+
|
| 4 |
+
论文:Sutton et al. 2022, *Reward-Respecting Subtasks for Model-Based
|
| 5 |
+
Reinforcement Learning*(arXiv:2202.03466)。
|
| 6 |
+
|
| 7 |
+
## 层序(不可颠倒, 见 `docs/oak_diagnosis_from_papers.md`)
|
| 8 |
+
|
| 9 |
+
SubTask ──> Option ──> Option model ──> Planning
|
| 10 |
+
(c, z) (π_o,β_o) SMDP model planner
|
| 11 |
+
|
| 12 |
+
本项目此前一直从**转移图**出发(bottleneck / 介数中心性 / 最短路),那正是论文
|
| 13 |
+
点名的**最差类别**:"option models from ... shortest path options based on
|
| 14 |
+
bottleneck states ... are much less likely to be useful in planning"。
|
| 15 |
+
这个模块把驱动层换回 **SubTask**。
|
| 16 |
+
|
| 17 |
+
## 子任务的定义(论文式(4))
|
| 18 |
+
|
| 19 |
+
c(s,a) = R(s,a) ← **与主任务完全相同的奖励**
|
| 20 |
+
z_i(s) = V_main(s) + (w̄_i − w_i)·x_i(s) ← 只在 x_i(s)=1 处允许停
|
| 21 |
+
└────────── stopping bonus ──────────┘
|
| 22 |
+
|
| 23 |
+
`w_i` 是**特征 i 在主任务价值函数里的权重**(一热特征下 = 该特征为 1 诸状态上
|
| 24 |
+
`V_main` 的均值),不是常数 1。硬写 `w_i = 1` 会让 bonus 恒 0 → `z ≡ V_main`
|
| 25 |
+
→ `β_o` 处处为空 → option 永不停止 → 与 shortest-path 给出逐位相同的曲线。
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
from hibs_lnn.skill_mdp import N_ACT
|
| 31 |
+
from hibs_lnn.subtask_options import (
|
| 32 |
+
STOP,
|
| 33 |
+
Subtask,
|
| 34 |
+
reward_respecting_subtask,
|
| 35 |
+
shortest_path_subtask,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 40 |
+
# 1. KeyDoor 的表格视图 —— 给 Subtask.solve 提供 (T, R, is_goal)
|
| 41 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 42 |
+
|
| 43 |
+
class KeyDoorTabular:
|
| 44 |
+
"""`KeyDoorMDP` 的**表格**表示。
|
| 45 |
+
|
| 46 |
+
KeyDoor 的动力学完全确定性, 所以 `T[s][a] = [(1.0, s')]`;唯一随 regime
|
| 47 |
+
改变的是 `is_goal`(目标判定含 `goal_pos < door_pos` 的免钥匙情形)与 `R`
|
| 48 |
+
(进目标得 +1)。**因此本对象必须按 regime 重建**, 不能跨 regime 复用。
|
| 49 |
+
|
| 50 |
+
接口刻意与 `subtask_options.py` 里 gridworld 的环境对象**逐字一致**
|
| 51 |
+
(`n_states` / `T` / `R` / `is_goal` / `gamma`), 这样 `Subtask.solve`
|
| 52 |
+
与 `reward_respecting_subtask` 可以原样复用, 不必为 KeyDoor 另写一份。
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, mdp, gamma=0.95):
|
| 56 |
+
self.gamma = float(gamma)
|
| 57 |
+
self.regime = tuple(mdp.regime)
|
| 58 |
+
self.n_pos = int(mdp.n_pos)
|
| 59 |
+
self.n_states = int(mdp.n_states)
|
| 60 |
+
key_pos, door_pos, goal_pos = self.regime
|
| 61 |
+
|
| 62 |
+
# 目标状态: 与 KeyDoorMDP._reached() 同一判据
|
| 63 |
+
def is_term(s):
|
| 64 |
+
pos, _hk, do = s // 4, (s // 2) % 2, s % 2
|
| 65 |
+
return pos == goal_pos and (do or goal_pos < door_pos)
|
| 66 |
+
|
| 67 |
+
self.is_goal = np.array([is_term(s) for s in range(self.n_states)],
|
| 68 |
+
dtype=bool)
|
| 69 |
+
|
| 70 |
+
self.T = [[None] * N_ACT for _ in range(self.n_states)]
|
| 71 |
+
self.R = np.zeros((self.n_states, N_ACT), dtype=float)
|
| 72 |
+
for s in range(self.n_states):
|
| 73 |
+
pos, hk, do = s // 4, (s // 2) % 2, s % 2
|
| 74 |
+
for a in range(N_ACT):
|
| 75 |
+
np_, nhk, ndo = pos, hk, do
|
| 76 |
+
if a == 0: # LEFT
|
| 77 |
+
np_ = max(0, pos - 1)
|
| 78 |
+
elif a == 1: # RIGHT
|
| 79 |
+
np_ = min(self.n_pos - 1, pos + 1)
|
| 80 |
+
elif a == 2: # GRAB
|
| 81 |
+
if pos == key_pos and not hk:
|
| 82 |
+
nhk = 1
|
| 83 |
+
elif a == 3: # OPEN
|
| 84 |
+
if pos == door_pos and hk and not do:
|
| 85 |
+
ndo = 1
|
| 86 |
+
ns = np_ * 4 + nhk * 2 + ndo
|
| 87 |
+
self.T[s][a] = [(1.0, ns)]
|
| 88 |
+
self.R[s][a] = 1.0 if self.is_goal[ns] else 0.0
|
| 89 |
+
self.regime_key = self.regime
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 93 |
+
# 2. 特征向量与主任务价值
|
| 94 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 95 |
+
|
| 96 |
+
def feature_vectors(mdl: KeyDoorTabular):
|
| 97 |
+
"""KeyDoor 的**子成就特征**: 「拿到钥匙」与「打开门」。
|
| 98 |
+
|
| 99 |
+
这两个是任务链上真正的中间成果(key → door → goal), 对应论文里
|
| 100 |
+
"the features are the sub-achievements of the task" 的取法。
|
| 101 |
+
"""
|
| 102 |
+
n = mdl.n_states
|
| 103 |
+
x_key = np.array([(s // 2) % 2 for s in range(n)], dtype=float)
|
| 104 |
+
x_door = np.array([s % 2 for s in range(n)], dtype=float)
|
| 105 |
+
return {"key": x_key, "door": x_door}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def main_value(mdl: KeyDoorTabular, max_sweeps=5000, tol=1e-12):
|
| 109 |
+
"""主任务的最优价值 `V_main`(精确价值迭代)。
|
| 110 |
+
|
| 111 |
+
★ 与 `Subtask.solve` 用**同一套约定**: 目标状态价值为 0, 其余
|
| 112 |
+
`V(s) = max_a [ R(s,a) + γ V(s') ]`。两处约定一致是硬要求 ——
|
| 113 |
+
否则 `z_i(s) = V_main(s) + bonus` 里的 `V_main` 与子任务自身
|
| 114 |
+
的价值不在同一把尺子上, `β_o` 会在错误的地方触发。
|
| 115 |
+
"""
|
| 116 |
+
n = mdl.n_states
|
| 117 |
+
V = np.zeros(n)
|
| 118 |
+
for _ in range(max_sweeps):
|
| 119 |
+
delta = 0.0
|
| 120 |
+
Vn = V.copy()
|
| 121 |
+
for s in range(n):
|
| 122 |
+
if mdl.is_goal[s]:
|
| 123 |
+
Vn[s] = 0.0
|
| 124 |
+
continue
|
| 125 |
+
best = -np.inf
|
| 126 |
+
for a in range(N_ACT):
|
| 127 |
+
if mdl.T[s][a] is None:
|
| 128 |
+
continue
|
| 129 |
+
val = mdl.R[s][a] + mdl.gamma * V[mdl.T[s][a][0][1]]
|
| 130 |
+
if val > best:
|
| 131 |
+
best = val
|
| 132 |
+
Vn[s] = best
|
| 133 |
+
delta = max(delta, abs(Vn[s] - V[s]))
|
| 134 |
+
V = Vn
|
| 135 |
+
if delta < tol:
|
| 136 |
+
break
|
| 137 |
+
return V
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def optimal_policy(mdl: KeyDoorTabular):
|
| 141 |
+
"""主任务最优策略(诊断用: 给 T_adapt 阈值与 sanity check)。"""
|
| 142 |
+
V = main_value(mdl)
|
| 143 |
+
n = mdl.n_states
|
| 144 |
+
pol = np.full(n, STOP, dtype=int)
|
| 145 |
+
for s in range(n):
|
| 146 |
+
if mdl.is_goal[s]:
|
| 147 |
+
continue
|
| 148 |
+
best_a, best_v = STOP, -np.inf
|
| 149 |
+
for a in range(N_ACT):
|
| 150 |
+
val = mdl.R[s][a] + mdl.gamma * V[mdl.T[s][a][0][1]]
|
| 151 |
+
if val > best_v:
|
| 152 |
+
best_a, best_v = a, val
|
| 153 |
+
pol[s] = best_a
|
| 154 |
+
return V, pol
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 158 |
+
# 3. 子任务库的构造
|
| 159 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 160 |
+
|
| 161 |
+
def build_rr_library(mdl: KeyDoorTabular, V_main, bonus_weight=1.0,
|
| 162 |
+
feats=("key", "door"), cumulant="reward",
|
| 163 |
+
z_mode="rr"):
|
| 164 |
+
"""构造 reward-respecting 子任务库并**解出** option。
|
| 165 |
+
|
| 166 |
+
参数就是消融的旋钮 —— 每个旋钮对应矩阵里的一个臂:
|
| 167 |
+
|
| 168 |
+
`z_mode` "rr" → `z = V_main + (w̄ − w_i)x_i` (机制本体)
|
| 169 |
+
"zeroV" → `z = 0 + (w̄ − 0 )x_i` (削掉价值函数)
|
| 170 |
+
"nobonus" → `w̄ = w_i` → bonus ≡ 0 (削掉停止奖励)
|
| 171 |
+
`cumulant` "reward" → `c = R`(reward-respecting)
|
| 172 |
+
"zero" → `c = 0`(纯"到达特征", 丢掉沿途目标)
|
| 173 |
+
"""
|
| 174 |
+
feats_all = feature_vectors(mdl)
|
| 175 |
+
lib = []
|
| 176 |
+
for name in feats:
|
| 177 |
+
x = feats_all[name]
|
| 178 |
+
if z_mode == "rr":
|
| 179 |
+
st = reward_respecting_subtask(mdl, x, V_main,
|
| 180 |
+
bonus_weight=bonus_weight)
|
| 181 |
+
elif z_mode == "zeroV":
|
| 182 |
+
# 削掉价值函数: V_main ≡ 0。此时 z 只剩 bonus 项,
|
| 183 |
+
# "停"与"继续"不再由主任务价值定价 —— 这正是用户猜测的
|
| 184 |
+
# "可能是价值函数的原因" 的**直接检验臂**。
|
| 185 |
+
z0 = np.zeros_like(np.asarray(V_main, dtype=float))
|
| 186 |
+
st = reward_respecting_subtask(mdl, x, z0,
|
| 187 |
+
bonus_weight=bonus_weight)
|
| 188 |
+
elif z_mode == "nobonus":
|
| 189 |
+
# w̄ = w_i → bonus ≡ 0 → z ≡ V_main → 论文警告的退化情形。
|
| 190 |
+
# 这一臂**预期**表现为 option 永不提前停(β_o 空), 是 harness
|
| 191 |
+
# 的"已知负向"校准点(Pitfall 35 / §9.3 第三条)。
|
| 192 |
+
on = np.asarray(x, dtype=float) > 0
|
| 193 |
+
wi = float(np.mean(np.asarray(V_main, dtype=float)[on])) if on.any() else 0.0
|
| 194 |
+
st = reward_respecting_subtask(mdl, x, V_main, bonus_weight=wi)
|
| 195 |
+
else:
|
| 196 |
+
raise ValueError(f"unknown z_mode: {z_mode}")
|
| 197 |
+
|
| 198 |
+
if cumulant == "zero":
|
| 199 |
+
st.c_fn = (lambda s, a: 0.0) # 丢掉沿途主任务奖励
|
| 200 |
+
elif cumulant != "reward":
|
| 201 |
+
raise ValueError(f"unknown cumulant: {cumulant}")
|
| 202 |
+
|
| 203 |
+
st.name = f"rr[{name}]"
|
| 204 |
+
st.feature = x
|
| 205 |
+
st.solve(mdl)
|
| 206 |
+
lib.append(st)
|
| 207 |
+
return lib
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def build_bottleneck_library(mdl: KeyDoorTabular, subgoal_state=None):
|
| 211 |
+
"""**已知弱基线(负对照)**: 从瓶颈/子目标状态出发的 shortest-path subtask。
|
| 212 |
+
|
| 213 |
+
`c = −1`, `z = 0` 只在子目标处 —— 它优化的是"**到达**",完全无视任务目标。
|
| 214 |
+
它在这里的**正确行为就是不比什么都不做好**, 所以它同时是 harness 的
|
| 215 |
+
负向校准点(§9:一个已知负向的臂必须复现为负)。
|
| 216 |
+
"""
|
| 217 |
+
if subgoal_state is None:
|
| 218 |
+
# 瓶颈 = 门所在的格(拿钥匙后必须经过) —— 或用割点思想选
|
| 219 |
+
door_pos = mdl.regime[1]
|
| 220 |
+
subgoal_state = min(mdl.n_states - 1, door_pos * 4 + 2) # (door, has_key, closed)
|
| 221 |
+
st = shortest_path_subtask(mdl, subgoal_state)
|
| 222 |
+
st.name = f"bottleneck[{subgoal_state}]"
|
| 223 |
+
st.feature = np.zeros(mdl.n_states)
|
| 224 |
+
st.solve(mdl)
|
| 225 |
+
return [st]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def build_random_goal_library(mdl: KeyDoorTabular, V_main, seed=0,
|
| 229 |
+
n_goals=2, bonus_weight=1.0):
|
| 230 |
+
"""**随机目标(负对照)**: 目标状态随机取, 其余同 reward-respecting。
|
| 231 |
+
|
| 232 |
+
论文 Fig.1 的第三条曲线。用来检验"收益是不是来自目标**选得好**,
|
| 233 |
+
而不是来自'有一个可停止的子任务'这件事本身"。
|
| 234 |
+
"""
|
| 235 |
+
rng = np.random.RandomState(seed)
|
| 236 |
+
lib = []
|
| 237 |
+
cand = [s for s in range(mdl.n_states) if not mdl.is_goal[s]]
|
| 238 |
+
for i in range(n_goals):
|
| 239 |
+
g = int(cand[rng.randint(len(cand))])
|
| 240 |
+
x = np.zeros(mdl.n_states)
|
| 241 |
+
x[g] = 1.0
|
| 242 |
+
st = reward_respecting_subtask(mdl, x, V_main,
|
| 243 |
+
bonus_weight=bonus_weight)
|
| 244 |
+
st.name = f"random[{g}]"
|
| 245 |
+
st.feature = x
|
| 246 |
+
st.solve(mdl)
|
| 247 |
+
lib.append(st)
|
| 248 |
+
return lib
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 252 |
+
# 4. 启动集 I_o 与选择
|
| 253 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 254 |
+
|
| 255 |
+
def initiation_ok(st: Subtask, s: int) -> bool:
|
| 256 |
+
"""`I_o(s)`: 只有当**特征尚未达成**且**在 s 停止不是最优**时才允许启动。
|
| 257 |
+
|
| 258 |
+
"特征已达成"= `x_i(s) > 0`(如 has_key=1)。已经拿到钥匙还去执行
|
| 259 |
+
"拿钥匙"的 option 是空转;论文的 initiation set 正是排除这类状态。
|
| 260 |
+
"""
|
| 261 |
+
x = getattr(st, "feature", None)
|
| 262 |
+
if x is None:
|
| 263 |
+
return True
|
| 264 |
+
return float(x[s]) <= 0.0
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def option_gain(st: Subtask, s: int, V_main) -> float:
|
| 268 |
+
"""选择用的**增益**: 该 option 承诺的价值超出主任务价值多少。
|
| 269 |
+
|
| 270 |
+
只在可启动处有意义;不可启动返回 `-inf`。选择 = `argmax` 这个量,
|
| 271 |
+
这就是"哪个子任务现在最值得追"。
|
| 272 |
+
"""
|
| 273 |
+
if st.value is None or not initiation_ok(st, s):
|
| 274 |
+
return -np.inf
|
| 275 |
+
v = float(st.value[s])
|
| 276 |
+
if not np.isfinite(v):
|
| 277 |
+
return -np.inf
|
| 278 |
+
return v - float(np.asarray(V_main)[s])
|
hibs_lnn/skill_agent.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""skill_agent.py — 在 KeyDoorMDP 上做 B 矩阵的智能体。
|
| 2 |
+
|
| 3 |
+
## 要回答的问题 (用户 2026-09-14 框架里的 H5 + 复用)
|
| 4 |
+
|
| 5 |
+
"Option 是否保存了可以重新利用的经验?"
|
| 6 |
+
|
| 7 |
+
即 A→B→C→A→B→C 反复漂移时, 是否出现
|
| 8 |
+
|
| 9 |
+
T_A^(2) < T_A^(1), T_A^(3) < T_A^(2)
|
| 10 |
+
|
| 11 |
+
## 公平对照的两个设计要点
|
| 12 |
+
|
| 13 |
+
1. **两个 agent 都保留 Q 表** (跨 regime 不重置)
|
| 14 |
+
否则"复用"测的是"有没有记忆", 而不是"option 有没有带来额外的可复用结构"。
|
| 15 |
+
我们要隔离的是 **option 库** 的额外贡献。
|
| 16 |
+
|
| 17 |
+
2. **相同计算预算**: option agent 的额外开销是"拟合世界模型 + 发现 option",
|
| 18 |
+
所以给它和 primitive 相同数量的**环境交互**。
|
| 19 |
+
测的是 **样本效率 R(N)**, 不是"跑得更久所以更好"。
|
| 20 |
+
|
| 21 |
+
## 关键: regime 变化后 option 库会不会失效?
|
| 22 |
+
|
| 23 |
+
一个 regime = (key_pos, door_pos, goal_pos)。换 regime 后:
|
| 24 |
+
- **技能本身仍然成立** ("走到 key 位置并抓取" 这个**行为模式**没变)
|
| 25 |
+
- 但**地点变了** -> 旧 option 的 goal_center 是**过期的**
|
| 26 |
+
|
| 27 |
+
所以本实现区分两种 option 使用方式:
|
| 28 |
+
- `reuse_stale` : 直接用旧 option (预期: 会被过期目标拖累)
|
| 29 |
+
- `rediscover` : 换 regime 后**重新发现**, 但库不清空 (预期: 应该更快)
|
| 30 |
+
这正好对应"技能可复用"的两种理解, 也是判据所在。
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import sys
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
|
| 39 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 40 |
+
if str(ROOT) not in sys.path:
|
| 41 |
+
sys.path.insert(0, str(ROOT))
|
| 42 |
+
|
| 43 |
+
from hibs_lnn.skill_mdp import N_ACT, KeyDoorMDP # noqa: E402
|
| 44 |
+
from hibs_lnn.uncertainty_gate import TabularTransition # noqa: E402
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class QLearner:
|
| 48 |
+
"""表格 Q-learning (跨 regime 保留 Q 表 —— 这是"记忆"基线)。"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, n_states, n_act=N_ACT, lr=0.2, gamma=0.95,
|
| 51 |
+
eps=0.15, seed=0, q_init=1.0):
|
| 52 |
+
# ★ **乐观初始化** —— 稀疏奖励下这是必需的, 不是调参技巧。
|
| 53 |
+
# 若 Q 全为 0, `argmax` 恒返回动作 0, agent 会**一直做同一个动作**
|
| 54 |
+
# (实测: 在 KeyDoor 上一直 left, 卡在 pos 0), 只有 ε-随机步才动,
|
| 55 |
+
# 于是几乎永远到不了目标 -> 600 回合成功率仍为 0.00。
|
| 56 |
+
# 初值取正 (q_init=+1) 使每个 (s,a) 都先被尝一次, 由更新把它拉回真值。
|
| 57 |
+
self.Q = np.full((n_states, n_act), float(q_init))
|
| 58 |
+
self.n_states, self.n_act = n_states, n_act
|
| 59 |
+
self.lr, self.gamma, self.eps = lr, gamma, eps
|
| 60 |
+
self.q_init = float(q_init)
|
| 61 |
+
self.rng = np.random.RandomState(seed)
|
| 62 |
+
|
| 63 |
+
def act(self, s):
|
| 64 |
+
if self.rng.rand() < self.eps:
|
| 65 |
+
return int(self.rng.randint(0, self.n_act))
|
| 66 |
+
return int(np.argmax(self.Q[s]))
|
| 67 |
+
|
| 68 |
+
def update(self, s, a, r, s2, done):
|
| 69 |
+
tgt = r if done else r + self.gamma * float(np.max(self.Q[s2]))
|
| 70 |
+
self.Q[s, a] += self.lr * (tgt - self.Q[s, a])
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class SkillAgent:
|
| 74 |
+
"""Q-learning + 一个**跨 regime 保留**的技能库。"""
|
| 75 |
+
|
| 76 |
+
def __init__(self, mdp: KeyDoorMDP, mode="primitive", seed=0,
|
| 77 |
+
discover_every=25, min_trans=120, opt_prob=0.5,
|
| 78 |
+
use_subgoals=1, n_regions=16, max_len=5, term_eps=None):
|
| 79 |
+
self.mdp = mdp
|
| 80 |
+
self.mode = mode # primitive / reuse_stale / rediscover / macro
|
| 81 |
+
self.q = QLearner(mdp.n_states, seed=seed)
|
| 82 |
+
self.rng = np.random.RandomState(seed + 1)
|
| 83 |
+
self.discover_every = int(discover_every)
|
| 84 |
+
self.min_trans = int(min_trans)
|
| 85 |
+
self.opt_prob = float(opt_prob)
|
| 86 |
+
self.use_subgoals = int(use_subgoals)
|
| 87 |
+
self.n_regions = int(n_regions)
|
| 88 |
+
self.max_len = int(max_len)
|
| 89 |
+
# ★ β_o 的"目标达成"阈值必须与**状态空间尺度**匹配。
|
| 90 |
+
# KeyDoor 的 vec() = one-hot(pos) ⊕ has_key ⊕ door_open (n_pos+2 维)。
|
| 91 |
+
# **两个不同位置之间的 L2 距离是 √2 ≈ 1.414**, 而到达"某个目标区域"
|
| 92 |
+
# 的合理判据是"落进该区域", 该区域半径就是 OptionManager 的
|
| 93 |
+
# `init_radius`(按区域最近邻距离中位数 * 0.75 自适应推断)。
|
| 94 |
+
# 固定 eps=0.05 (给"归一化距离"用的量级) 在这里**几乎永不满足**
|
| 95 |
+
# -> β_o 只在预算耗尽时触发 -> success 恒 0 -> 技能库从不学习。
|
| 96 |
+
# 这是本项目第 5 次同型错误("阈值与状态空间尺度不匹配"),
|
| 97 |
+
# 故此处**默认 None = 运行时取 om.init_radius**, 不再手写常数。
|
| 98 |
+
self.term_eps = None if term_eps is None else float(term_eps)
|
| 99 |
+
|
| 100 |
+
self.trans = [] # 全历史 (跨 regime 累积)
|
| 101 |
+
self.om = None # 当前技能库
|
| 102 |
+
self.episodes = 0
|
| 103 |
+
self.n_discover = 0
|
| 104 |
+
|
| 105 |
+
# ── 技能库维护 ──────────────────────────────────────────────────
|
| 106 |
+
def maybe_discover(self, force=False):
|
| 107 |
+
"""周期性 (或强制) 用**已有全部转移**重建技能库。
|
| 108 |
+
|
| 109 |
+
★ 库**不清空**: 新 options 与旧 options 合并去重。
|
| 110 |
+
这才是"技能可复用"的实现 —— 换 regime 只是新增地点变体,
|
| 111 |
+
而不是从头学。
|
| 112 |
+
"""
|
| 113 |
+
if not force:
|
| 114 |
+
if self.episodes % self.discover_every != 0:
|
| 115 |
+
return
|
| 116 |
+
if len(self.trans) < self.min_trans:
|
| 117 |
+
return
|
| 118 |
+
X = np.array([np.append(v, a) for v, a, _ in self.trans])
|
| 119 |
+
Y = np.array([vn for _, _, vn in self.trans])
|
| 120 |
+
dim = X.shape[1] - 1
|
| 121 |
+
T = TabularTransition().fit(X, Y, N_ACT)
|
| 122 |
+
from hibs_lnn.option_manager import OptionManager
|
| 123 |
+
from hibs_lnn.uncertainty_gate import UncertaintyGate
|
| 124 |
+
g = UncertaintyGate(tau_C=1.0)
|
| 125 |
+
g.calibrate([T.predict(X[i, :dim], int(X[i, -1]))[1]
|
| 126 |
+
for i in range(0, len(X), max(1, len(X) // 100))])
|
| 127 |
+
om = OptionManager(T, g, max_len=self.max_len, seed=0)
|
| 128 |
+
n_uniq = len(np.unique(np.round(X[:, :dim], 6), axis=0))
|
| 129 |
+
nreg = min(self.n_regions, max(4, n_uniq))
|
| 130 |
+
if self.use_subgoals:
|
| 131 |
+
om.discover_subgoals(X[:, :dim], X[:, -1].astype(int), n_regions=nreg)
|
| 132 |
+
else:
|
| 133 |
+
om.discover(X[:, :dim], X[:, -1].astype(int), n_regions=nreg)
|
| 134 |
+
if self.om is not None:
|
| 135 |
+
# 合并: 旧技能在前 (先验偏好), 新技能去重后追加
|
| 136 |
+
seen = {tuple(int(x) for x in o.actions) for o in self.om.options}
|
| 137 |
+
for o in om.options:
|
| 138 |
+
k = tuple(int(x) for x in o.actions)
|
| 139 |
+
if k not in seen:
|
| 140 |
+
self.om.options.append(o)
|
| 141 |
+
seen.add(k)
|
| 142 |
+
else:
|
| 143 |
+
self.om = om
|
| 144 |
+
self.n_discover += 1
|
| 145 |
+
|
| 146 |
+
def _select_option(self, v):
|
| 147 |
+
"""选一个可启动的 option(与 _option_action 同源, 抽出复用)。"""
|
| 148 |
+
if self.om is None or not self.om.options:
|
| 149 |
+
return None
|
| 150 |
+
goal_vec = None
|
| 151 |
+
try:
|
| 152 |
+
key_pos, door_pos, goal_pos = self.mdp.regime
|
| 153 |
+
gv = np.zeros_like(v)
|
| 154 |
+
gv[goal_pos] = 1.0
|
| 155 |
+
gv[self.mdp.n_pos] = 1.0 # has_key = 1
|
| 156 |
+
gv[self.mdp.n_pos + 1] = 1.0 # door_open = 1
|
| 157 |
+
goal_vec = gv
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
return self.om.select(v, goal=goal_vec, exclude_unc=True)
|
| 161 |
+
|
| 162 |
+
def _option_action(self, s):
|
| 163 |
+
"""按 option 出动作 —— **必须尊重启动集 I_o**。
|
| 164 |
+
|
| 165 |
+
★ 原来的写法是「从技能库里**均匀随机**挑一个 option, 取它的第一个动作」。
|
| 166 |
+
这既不看 `I_o`(启动集), 也不管目标是否相关, 等于往动作里注入
|
| 167 |
+
一半的噪声。实测后果 (KeyDoor B 矩阵):
|
| 168 |
+
技能库涨到 54~60 个后, rediscover 在全部 6 段上都差于 primitive,
|
| 169 |
+
其中两段**永不收敛** (T_adapt = 上限 300)。
|
| 170 |
+
|
| 171 |
+
正确做法 (用户给的 `Option=(I_o,g_o,π_o,β_o)`):
|
| 172 |
+
用 `OptionManager.select(s, goal=...)` —— 它**会**检查 `initiable(s)`,
|
| 173 |
+
也就是"当前状态是否落在该 option 的启动集里"。这才是 `I_o` 的语义。
|
| 174 |
+
|
| 175 |
+
★★ 本函数是**逐步顾问**语义 (每步问一次, 执行一个 primitive 动作)。
|
| 176 |
+
它不是时间抽象 —— option 的多步结构完全没用上, 成败也无从统计
|
| 177 |
+
(`Option.visits/success` 因此永远是 0, `select()` 里
|
| 178 |
+
`rate = success/visits` 恒取默认 0.5, 技能库**从不学习哪些 option 管用**)。
|
| 179 |
+
真正的宏执行见 `_macro_step`。
|
| 180 |
+
"""
|
| 181 |
+
o = self._select_option(self.mdp.vec())
|
| 182 |
+
if o is None:
|
| 183 |
+
return None # 没有可启动的技能 -> 交回 Q (这才是正确的退让)
|
| 184 |
+
acts = [int(x) for x in o.actions]
|
| 185 |
+
if not acts:
|
| 186 |
+
return None
|
| 187 |
+
# 闭环: 用世界模型算"朝该 option 目标前进最多"的动作; 失败则退回动作序列
|
| 188 |
+
a = None
|
| 189 |
+
if self.om.T is not None:
|
| 190 |
+
a = self._goal_action(self.mdp.vec(), o.goal_center)
|
| 191 |
+
if a is None:
|
| 192 |
+
a = acts[0]
|
| 193 |
+
return int(a)
|
| 194 |
+
|
| 195 |
+
def _macro_step(self, v, active, steps):
|
| 196 |
+
"""把一个 option 当**宏动作**执行。
|
| 197 |
+
|
| 198 |
+
返回 `(动作, 新的 active, 新的 steps)`。`active is None` 表示本步先选一个
|
| 199 |
+
option 再出动作; 每步都重算 `π_o` (闭环), 当 `β_o` 命中
|
| 200 |
+
(目标达成 / 步数上限) 时结算成败并交回 base policy。
|
| 201 |
+
|
| 202 |
+
## 为什么要这个
|
| 203 |
+
|
| 204 |
+
原 `rediscover` 把 option 当**单步动作顾问**: 每步按概率问一次 "现在该做
|
| 205 |
+
什么动作", option 的 `(I_o, g_o, π_o, β_o)` 里只有 `π_o` 的一步被用到。
|
| 206 |
+
后果有两个, 都是实测到的:
|
| 207 |
+
|
| 208 |
+
1. **成败无从统计** -> `visits/success` 恒 0 -> `select()` 的
|
| 209 |
+
`rate = success/visits` 恒为默认 0.5 -> 54~60 个 option 在启动集���
|
| 210 |
+
**同分**, 选择等于抛硬币 -> 全部 6 段都差于 primitive。
|
| 211 |
+
2. **不是时间抽象** -> 逐步咨询等于给 base policy 注入噪声,
|
| 212 |
+
没有任何"一段行为被当作一个决策单位"的效果。
|
| 213 |
+
"""
|
| 214 |
+
# 目标达成阈值: 未显式给定时, 用 OptionManager 自适应推断的
|
| 215 |
+
# `init_radius`(区域尺度) —— 同一把尺子量启动集与目标区。
|
| 216 |
+
eps = self.term_eps
|
| 217 |
+
if eps is None:
|
| 218 |
+
eps = float(getattr(self.om, "init_radius", 0.75))
|
| 219 |
+
if active is None:
|
| 220 |
+
active = self._select_option(v)
|
| 221 |
+
steps = 0
|
| 222 |
+
if active is None:
|
| 223 |
+
return None, None, 0
|
| 224 |
+
a = self._goal_action(v, active.goal_center) if self.om.T is not None else None
|
| 225 |
+
if a is None:
|
| 226 |
+
a = int(active.actions[0]) if active.actions else None
|
| 227 |
+
steps += 1
|
| 228 |
+
# β_o: 目标达成 或 预算耗尽 -> 结算
|
| 229 |
+
# ★ 主判据 = **区域归属** (in_goal_region): "已进入目标区域"。
|
| 230 |
+
# 旧判据 `goal_distance(v) < eps` 在离散状态空间里退化 ——
|
| 231 |
+
# 实测 2737 次执行中 0 次因它触发 (eps=0.754 只接受精确到达,
|
| 232 |
+
# 而可达的最小距离中位数是 1.000)。距离判据保留为兜底。
|
| 233 |
+
try:
|
| 234 |
+
reached = bool(active.in_goal_region(v))
|
| 235 |
+
if not reached:
|
| 236 |
+
reached = active.goal_distance(v) < eps
|
| 237 |
+
except Exception:
|
| 238 |
+
reached = False
|
| 239 |
+
if reached or steps >= max(1, int(self.max_len)):
|
| 240 |
+
active.visits += 1
|
| 241 |
+
if reached:
|
| 242 |
+
active.success += 1
|
| 243 |
+
active = None
|
| 244 |
+
steps = 0
|
| 245 |
+
return a, active, steps
|
| 246 |
+
|
| 247 |
+
def _goal_action(self, v, g):
|
| 248 |
+
"""π_o(s) = argmax_a [ ‖g−s‖ − ‖g−T(s,a)‖ ], 每步重算。"""
|
| 249 |
+
g = np.asarray(g, dtype=float)
|
| 250 |
+
d_now = float(np.linalg.norm(v - g))
|
| 251 |
+
best_a, best_gain = None, -np.inf
|
| 252 |
+
for a in range(N_ACT):
|
| 253 |
+
sn, unc = self.om.T.predict(v, a)
|
| 254 |
+
if not np.isfinite(unc):
|
| 255 |
+
continue
|
| 256 |
+
gain = d_now - float(np.linalg.norm(np.asarray(sn, dtype=float) - g))
|
| 257 |
+
if gain > best_gain:
|
| 258 |
+
best_a, best_gain = a, gain
|
| 259 |
+
return best_a
|
| 260 |
+
|
| 261 |
+
# ── 一个 episode ────────────────────────────────────────────────
|
| 262 |
+
def run_episode(self):
|
| 263 |
+
s = self.mdp.reset()
|
| 264 |
+
done = False
|
| 265 |
+
n = 0
|
| 266 |
+
# ── macro 模式的跨步状态: 当前正在执行的宏动作 ──────────────
|
| 267 |
+
active, o_steps = None, 0
|
| 268 |
+
while not done and n < self.mdp.horizon:
|
| 269 |
+
v = self.mdp.vec()
|
| 270 |
+
a = None
|
| 271 |
+
if self.mode == "macro":
|
| 272 |
+
# 宏执行: 选一个 option -> 跑到 β_o 触发 -> 结算成败
|
| 273 |
+
if self.rng.rand() < self.opt_prob or active is not None:
|
| 274 |
+
a, active, o_steps = self._macro_step(v, active, o_steps)
|
| 275 |
+
elif self.mode != "primitive" and self.rng.rand() < self.opt_prob:
|
| 276 |
+
# rediscover / reuse_stale: 逐步顾问语义 (保持不变, 作为对照)
|
| 277 |
+
a = self._option_action(s)
|
| 278 |
+
if a is None:
|
| 279 |
+
active, o_steps = None, 0 # 交回 base policy 时宏动作结束
|
| 280 |
+
a = self.q.act(s)
|
| 281 |
+
s2, r, done = self.mdp.step(a)
|
| 282 |
+
self.q.update(s, a, r, s2, done)
|
| 283 |
+
self.trans.append((v, a, self.mdp.vec()))
|
| 284 |
+
s = s2
|
| 285 |
+
n += 1
|
| 286 |
+
self.episodes += 1
|
| 287 |
+
if self.mode != "primitive":
|
| 288 |
+
self.maybe_discover()
|
| 289 |
+
return bool(done and self.mdp._reached()), n
|
| 290 |
+
|
| 291 |
+
def option_bookkeeping(self):
|
| 292 |
+
"""技能库的成败台账(诊断用: 证明 visits/success 真的被回填了)。"""
|
| 293 |
+
if self.om is None:
|
| 294 |
+
return {"n_options": 0, "n_used": 0, "total_visits": 0, "total_success": 0}
|
| 295 |
+
vs = [o.visits for o in self.om.options]
|
| 296 |
+
ss = [o.success for o in self.om.options]
|
| 297 |
+
return {"n_options": len(self.om.options),
|
| 298 |
+
"n_used": int(sum(1 for v_ in vs if v_ > 0)),
|
| 299 |
+
"total_visits": int(sum(vs)), "total_success": int(sum(ss))}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ── B 矩阵: regime 链 ───────────────────────────────────────────────
|
| 303 |
+
|
| 304 |
+
DEFAULT_REGIMES = [
|
| 305 |
+
dict(key_pos=2, door_pos=5, goal_pos=7), # A
|
| 306 |
+
dict(key_pos=5, door_pos=2, goal_pos=6), # B (key/door 换位)
|
| 307 |
+
dict(key_pos=1, door_pos=6, goal_pos=7), # C
|
| 308 |
+
]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def run_regime_chain(mode="primitive", chain=None, n_pos=8, episodes_per=400,
|
| 312 |
+
seed=0, thresh=0.8, window=20, **kw):
|
| 313 |
+
"""跑 A→B→C→A→B→C 链, 返回每次 regime 的 T_adapt(达到 thresh 所需回合)。
|
| 314 |
+
|
| 315 |
+
T_adapt = 该 regime 段内, 滑动成功率首次 ��� thresh 时的回合数;
|
| 316 |
+
从未达到则记为 episodes_per (上限)。
|
| 317 |
+
"""
|
| 318 |
+
chain = chain or (DEFAULT_REGIMES * 2)
|
| 319 |
+
mdp = KeyDoorMDP(n_pos=n_pos, horizon=30, **chain[0])
|
| 320 |
+
agent = SkillAgent(mdp, mode=mode, seed=seed, **kw)
|
| 321 |
+
out = []
|
| 322 |
+
for ci, reg in enumerate(chain):
|
| 323 |
+
mdp.set_regime(reg["key_pos"], reg["door_pos"], reg["goal_pos"])
|
| 324 |
+
if mode != "primitive":
|
| 325 |
+
agent.maybe_discover(force=True)
|
| 326 |
+
succ, t_adapt = [], None
|
| 327 |
+
for ep in range(episodes_per):
|
| 328 |
+
ok, _ = agent.run_episode()
|
| 329 |
+
succ.append(1.0 if ok else 0.0)
|
| 330 |
+
if t_adapt is None and len(succ) >= window:
|
| 331 |
+
if float(np.mean(succ[-window:])) >= thresh:
|
| 332 |
+
t_adapt = ep + 1
|
| 333 |
+
out.append({"regime": ci, "key": reg["key_pos"], "door": reg["door_pos"],
|
| 334 |
+
"goal": reg["goal_pos"],
|
| 335 |
+
"t_adapt": int(t_adapt if t_adapt is not None else episodes_per),
|
| 336 |
+
"final_succ": float(np.mean(succ[-window:])) if succ else 0.0,
|
| 337 |
+
"n_options": len(agent.om.options) if agent.om else 0})
|
| 338 |
+
return out, agent
|
hibs_lnn/skill_mdp.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""skill_mdp.py — 一个有**已知 option 结构**的 MDP, 用来判定:
|
| 2 |
+
|
| 3 |
+
(a) OptionManager 到底能不能发现并利用 option?
|
| 4 |
+
(b) E9/E10 的负结果 是**实现坏了**, 还是 **lm4 benchmark 的性质**?
|
| 5 |
+
|
| 6 |
+
## 为什么需要它
|
| 7 |
+
|
| 8 |
+
lm4 的测量已经证明那个 benchmark **无时序结构**(不对称性 A ≡ 0, 动作可交换)。
|
| 9 |
+
在那种环境下 option 无论怎么实现都不会有用 —— 所以 lm4 上的负结果
|
| 10 |
+
**无法判定实现好坏**。
|
| 11 |
+
|
| 12 |
+
要判定实现, 必须换到一个**option 客观上应该有用**的环境。
|
| 13 |
+
这就是经典的「钥匙—门」链式任务: 最优解要求一段**有严格顺序**的多步行为。
|
| 14 |
+
若 OptionManager 在这里也发现不了/用不上 option, 那才是实现的问题。
|
| 15 |
+
|
| 16 |
+
## 任务结构
|
| 17 |
+
|
| 18 |
+
state = (pos, has_key) pos ∈ {0..N-1}, has_key ∈ {0,1}
|
| 19 |
+
actions:
|
| 20 |
+
0 left 位置 -1
|
| 21 |
+
1 right 位置 +1
|
| 22 |
+
2 grab 仅在 key_pos 且 !has_key 时拿到钥匙
|
| 23 |
+
3 open 仅在 door_pos 且 has_key 时开门 (此后 goal 可达)
|
| 24 |
+
|
| 25 |
+
reward:
|
| 26 |
+
到达 goal 且门已开 -> +1, 回合结束
|
| 27 |
+
步数超过上限 -> 0, 回合结束
|
| 28 |
+
|
| 29 |
+
★ **顺序依赖是内生的**:
|
| 30 |
+
- "go_right 到 key" 只在**还没拿钥匙**时有用
|
| 31 |
+
- "grab" 只在 key 位置有用
|
| 32 |
+
- "open" 只在**已拿钥匙**时有用
|
| 33 |
+
-> A(a,b) ≠ 0, 且不对称 (go_key → grab ≠ grab → go_key)
|
| 34 |
+
-> 这正是 lm4 里**完全缺失**的结构
|
| 35 |
+
|
| 36 |
+
## regime (用于 A→B→A 复用测试)
|
| 37 |
+
|
| 38 |
+
一个 regime = (key_pos, door_pos, goal_pos)。
|
| 39 |
+
换 regime = 同样的技能 (拿钥匙/开门) 仍然有效, 但**地点变了**
|
| 40 |
+
-> 这正是"可复用行为结构"的定义, 也是 option 该体现价值的地方。
|
| 41 |
+
"""
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import numpy as np
|
| 45 |
+
|
| 46 |
+
LEFT, RIGHT, GRAB, OPEN = 0, 1, 2, 3
|
| 47 |
+
N_ACT = 4
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class KeyDoorMDP:
|
| 51 |
+
"""钥匙—门 链式 MDP。`state = (pos, has_key)` 编码成整数 pos*2 + has_key。"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, n_pos=8, key_pos=2, door_pos=5, goal_pos=7, horizon=30):
|
| 54 |
+
self.n_pos = int(n_pos)
|
| 55 |
+
self.regime = (int(key_pos), int(door_pos), int(goal_pos))
|
| 56 |
+
self.horizon = int(horizon)
|
| 57 |
+
# ★ state 必须包含 door_open! 它影响转移(开门后目标才可达)。
|
| 58 |
+
# 早先编码 pos*2+has_key **漏掉了 door_open** -> 同一 (pos,has_key)
|
| 59 |
+
# 在门开/门关时无法区分 -> 任务退化成 POMDP -> Q-learning 学不会
|
| 60 |
+
# (实测: 400 回合成功率仍为 0.0)。
|
| 61 |
+
# 现在: pos*4 + has_key*2 + door_open -> n_pos*4 个状态
|
| 62 |
+
self.n_states = self.n_pos * 4
|
| 63 |
+
self.t = 0
|
| 64 |
+
self.pos, self.has_key, self.door_open = 0, 0, 0
|
| 65 |
+
|
| 66 |
+
# ── 状态编码 ────────────────────────────────────────────────────
|
| 67 |
+
def state(self):
|
| 68 |
+
return self.pos * 4 + self.has_key * 2 + self.door_open
|
| 69 |
+
|
| 70 |
+
def decode(self, s):
|
| 71 |
+
s = int(s)
|
| 72 |
+
return s // 4, (s // 2) % 2, s % 2 # pos, has_key, door_open
|
| 73 |
+
|
| 74 |
+
def vec(self):
|
| 75 |
+
"""给知识层用的连续表示: one-hot(pos) ⊕ has_key ⊕ door_open。"""
|
| 76 |
+
v = np.zeros(self.n_pos + 2)
|
| 77 |
+
v[self.pos] = 1.0
|
| 78 |
+
v[self.n_pos] = float(self.has_key)
|
| 79 |
+
v[self.n_pos + 1] = float(self.door_open)
|
| 80 |
+
return v
|
| 81 |
+
|
| 82 |
+
def set_regime(self, key_pos, door_pos, goal_pos):
|
| 83 |
+
self.regime = (int(key_pos), int(door_pos), int(goal_pos))
|
| 84 |
+
|
| 85 |
+
def reset(self):
|
| 86 |
+
self.t = 0
|
| 87 |
+
self.pos, self.has_key, self.door_open = 0, 0, 0
|
| 88 |
+
return self.state()
|
| 89 |
+
|
| 90 |
+
# ── 动力学 ──────────────────────────────────────────────────────
|
| 91 |
+
def step(self, a):
|
| 92 |
+
key_pos, door_pos, goal_pos = self.regime
|
| 93 |
+
self.t += 1
|
| 94 |
+
if a == LEFT:
|
| 95 |
+
self.pos = max(0, self.pos - 1)
|
| 96 |
+
elif a == RIGHT:
|
| 97 |
+
self.pos = min(self.n_pos - 1, self.pos + 1)
|
| 98 |
+
elif a == GRAB:
|
| 99 |
+
if self.pos == key_pos and not self.has_key:
|
| 100 |
+
self.has_key = 1
|
| 101 |
+
elif a == OPEN:
|
| 102 |
+
if self.pos == door_pos and self.has_key and not self.door_open:
|
| 103 |
+
self.door_open = 1
|
| 104 |
+
# 到达目标
|
| 105 |
+
done = False
|
| 106 |
+
r = 0.0
|
| 107 |
+
if self.pos == goal_pos and (self.door_open or goal_pos < door_pos):
|
| 108 |
+
r, done = 1.0, True
|
| 109 |
+
elif self.t >= self.horizon:
|
| 110 |
+
done = True
|
| 111 |
+
return self.state(), r, done
|
| 112 |
+
|
| 113 |
+
def _reached(self):
|
| 114 |
+
key_pos, door_pos, goal_pos = self.regime
|
| 115 |
+
return self.pos == goal_pos and (self.door_open or goal_pos < door_pos)
|
| 116 |
+
|
| 117 |
+
def greedy_action(self):
|
| 118 |
+
"""这个任务的最优一步策略 (只为给 baseline/交互测量用)。"""
|
| 119 |
+
key_pos, door_pos, goal_pos = self.regime
|
| 120 |
+
if self.pos != key_pos and not self.has_key and key_pos >= 0:
|
| 121 |
+
return RIGHT if key_pos > self.pos else LEFT
|
| 122 |
+
if self.pos == key_pos and not self.has_key:
|
| 123 |
+
return GRAB
|
| 124 |
+
if not self.door_open and goal_pos >= door_pos:
|
| 125 |
+
if self.pos != door_pos:
|
| 126 |
+
return RIGHT if door_pos > self.pos else LEFT
|
| 127 |
+
if self.has_key:
|
| 128 |
+
return OPEN
|
| 129 |
+
return RIGHT if goal_pos > self.pos else LEFT
|
| 130 |
+
|
| 131 |
+
def optimal_steps(self):
|
| 132 |
+
"""最优解需要多少步 (用于设 T_adapt 阈值)。"""
|
| 133 |
+
key_pos, door_pos, goal_pos = self.regime
|
| 134 |
+
n = abs(key_pos - 0) + 1 # 走到 key + grab
|
| 135 |
+
if goal_pos >= door_pos:
|
| 136 |
+
n += abs(door_pos - key_pos) + 1 # 走到 door + open
|
| 137 |
+
n += abs(goal_pos - door_pos)
|
| 138 |
+
else:
|
| 139 |
+
n += abs(goal_pos - key_pos)
|
| 140 |
+
return n
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def interaction_asymmetry(mdp, a, b, n_ep=200, seed=0):
|
| 144 |
+
"""A(a,b) = I(a,b) − I(b,a), 用**走到目标的步数**度量。
|
| 145 |
+
|
| 146 |
+
S_seq = 从初始态做完整序列再贪心走到目标的**总步数**
|
| 147 |
+
I(a,b) = S_b − S_ab (先做 a 再做 b, 比只做 b 少用多少步)
|
| 148 |
+
|
| 149 |
+
★ 为什么不能用「成功率」: 贪心策略从**任何**状态都能解出任务,
|
| 150 |
+
于是成功率恒为 1.000, 前缀完全不影响 —— 测量饱和, 什么也测不出。
|
| 151 |
+
(这是本项目第二次踩同一个坑; 上一次是 lm4 的 `b_only = 1.0000`。)
|
| 152 |
+
步数才对前缀敏感。
|
| 153 |
+
"""
|
| 154 |
+
def steps(seq):
|
| 155 |
+
tot = 0
|
| 156 |
+
for _ in range(n_ep):
|
| 157 |
+
mdp.reset()
|
| 158 |
+
n = 0
|
| 159 |
+
done = False
|
| 160 |
+
for x in seq:
|
| 161 |
+
_, _, done = mdp.step(x)
|
| 162 |
+
n += 1
|
| 163 |
+
if done:
|
| 164 |
+
break
|
| 165 |
+
while not done and n < mdp.horizon * 3:
|
| 166 |
+
_, _, done = mdp.step(mdp.greedy_action())
|
| 167 |
+
n += 1
|
| 168 |
+
tot += n
|
| 169 |
+
return tot / n_ep
|
| 170 |
+
|
| 171 |
+
S_ab = steps([a, b]); S_ba = steps([b, a])
|
| 172 |
+
S_a = steps([a]); S_b = steps([b])
|
| 173 |
+
# 基线: 什么都不强迫
|
| 174 |
+
S_none = steps([])
|
| 175 |
+
I_ab, I_ba = S_b - S_ab, S_a - S_ba
|
| 176 |
+
return dict(a=a, b=b, S_none=S_none, S_a=S_a, S_b=S_b, S_ab=S_ab, S_ba=S_ba,
|
| 177 |
+
I_ab=I_ab, I_ba=I_ba, asym=I_ab - I_ba)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def has_temporal_structure(mdp, n_ep=200, seed=0, tol=0.5):
|
| 181 |
+
"""对全部有序动作对测 A(a,b), 返回是否**存在**顺序依赖。"""
|
| 182 |
+
import itertools
|
| 183 |
+
rows = [interaction_asymmetry(mdp, a, b, n_ep=n_ep, seed=seed)
|
| 184 |
+
for a, b in itertools.permutations(range(N_ACT), 2)]
|
| 185 |
+
A = np.array([r["asym"] for r in rows])
|
| 186 |
+
frac = float((np.abs(A) > tol).mean())
|
| 187 |
+
return rows, A, frac
|
hibs_lnn/ssm_v30_3.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
V30.3: 内部纠缠 SSM 层 (减法版)
|
| 3 |
+
===================================
|
| 4 |
+
核心改动: 在 SSM 递推的每一步, 用当前隐藏状态的跨维度投影
|
| 5 |
+
调制下一步的 A 矩阵相位。5 行代码, 无外部引擎。
|
| 6 |
+
|
| 7 |
+
减法:
|
| 8 |
+
V30.1: +EntanglementEngine (后处理)
|
| 9 |
+
V30.2: +EntangleDecohereEngine (纠缠+退相干+熵正则)
|
| 10 |
+
V30.3: 只改 SSM 内部, 不加任何外部模块
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SSM_Layer_V30_3(nn.Module):
|
| 19 |
+
"""
|
| 20 |
+
V30.3 SSM 层: V29 基线 + 内部纠缠。
|
| 21 |
+
|
| 22 |
+
在串行扫描的每一步(h_mean 计算后):
|
| 23 |
+
if ent_mode != 'none':
|
| 24 |
+
# 调 h 的相位: 纯相位调制, 不改变振幅
|
| 25 |
+
phase_shift = ent_mod_net(h_norm) # 跨 N 维度投影
|
| 26 |
+
h = h * exp(i · phase_shift) # 纯相位旋转
|
| 27 |
+
|
| 28 |
+
纠缠特性:
|
| 29 |
+
- 发生在 SSM 递推内部 (每个 token 步)
|
| 30 |
+
- 跨 N 维度耦合 (ent_k=3 时 top-3)
|
| 31 |
+
- 纯相位 (不破坏已学习的振幅结构)
|
| 32 |
+
- 因果: 只影响未来状态, 不影响过去
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, d_model, d_state=8, fiber=None, layer_idx=0,
|
| 36 |
+
ent_mode='none', ent_k=3, ent_strength=0.05):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.d_model = d_model
|
| 39 |
+
self.d_state = d_state
|
| 40 |
+
self.ent_mode = ent_mode
|
| 41 |
+
self.ent_k = ent_k
|
| 42 |
+
self.ent_strength = ent_strength
|
| 43 |
+
|
| 44 |
+
# 沿用 V29 架构
|
| 45 |
+
self.norm = nn.LayerNorm(d_model)
|
| 46 |
+
self.in_proj = nn.Linear(d_model, 2 * d_model)
|
| 47 |
+
self.conv = nn.Conv1d(d_model, d_model, kernel_size=4, groups=d_model, padding=3)
|
| 48 |
+
self.out_proj = nn.Linear(d_model, d_model)
|
| 49 |
+
|
| 50 |
+
# SSM 参数
|
| 51 |
+
self.ssm_logsigma = nn.Parameter(torch.randn(d_model, d_state) * 0.1)
|
| 52 |
+
self.ssm_theta = nn.Parameter(torch.randn(d_model, d_state) * 0.5)
|
| 53 |
+
self.dt_proj = nn.Linear(d_model, d_model)
|
| 54 |
+
self.B_proj = nn.Linear(d_model, d_state)
|
| 55 |
+
self.C_proj = nn.Linear(d_model, d_state)
|
| 56 |
+
self.ssm_D = nn.Parameter(torch.ones(d_model))
|
| 57 |
+
|
| 58 |
+
# V33 路由进递推 (INTACT 意图条件化): cond 调制 dt/B (读写门意图感知)
|
| 59 |
+
# 零初始化 → 加载 Stage1 缓存后 cond 任意值 ≡ 无条件 (无需重训 Stage1)
|
| 60 |
+
self.cond_dt = nn.Linear(d_model, d_model)
|
| 61 |
+
self.cond_b = nn.Linear(d_model, d_state)
|
| 62 |
+
with torch.no_grad():
|
| 63 |
+
self.cond_dt.weight.zero_(); self.cond_dt.bias.zero_()
|
| 64 |
+
self.cond_b.weight.zero_(); self.cond_b.bias.zero_()
|
| 65 |
+
|
| 66 |
+
# KL 相关 (从 V29 继承)
|
| 67 |
+
self.logvar_net = nn.Sequential(
|
| 68 |
+
nn.Linear(d_model, d_model), nn.SiLU(),
|
| 69 |
+
nn.Linear(d_model, d_state),
|
| 70 |
+
)
|
| 71 |
+
self.base_prior_logvar = nn.Parameter(torch.zeros(d_state))
|
| 72 |
+
self.fiber_prior_strength = nn.Parameter(torch.tensor(0.1))
|
| 73 |
+
self.register_buffer('kl_scale', torch.tensor(0.0))
|
| 74 |
+
|
| 75 |
+
# ── V30.3 内部纠缠: 极小调制网络 ──
|
| 76 |
+
# 输入: |h| 投影到 N 维 (跨维度耦合)
|
| 77 |
+
# 输出: 相位调制量 (纯相位, 不改变振幅)
|
| 78 |
+
if ent_mode != 'none':
|
| 79 |
+
self.ent_mod = nn.Linear(d_state, d_state, bias=False)
|
| 80 |
+
self.ent_amp = nn.Linear(d_state, 1, bias=False) # 振幅门控
|
| 81 |
+
|
| 82 |
+
self._entropy_trace = []
|
| 83 |
+
|
| 84 |
+
def compute_entropy(self, h):
|
| 85 |
+
B, D, N = h.shape
|
| 86 |
+
amp = h.abs()
|
| 87 |
+
rho = amp.view(B, D, N, 1) * amp.view(B, D, 1, N)
|
| 88 |
+
rho = rho / (rho.sum(dim=(-1, -2), keepdim=True) + 1e-10)
|
| 89 |
+
try:
|
| 90 |
+
e = torch.linalg.eigvalsh(rho).clamp(min=1e-10)
|
| 91 |
+
return -(e * e.log()).sum(dim=-1).mean().item()
|
| 92 |
+
except:
|
| 93 |
+
return 0.0
|
| 94 |
+
|
| 95 |
+
def forward(self, x, cond=None):
|
| 96 |
+
r = x
|
| 97 |
+
x = self.norm(x)
|
| 98 |
+
a, b = self.in_proj(x).chunk(2, dim=-1)
|
| 99 |
+
a = F.silu(self.conv(a.transpose(-1, -2))[:, :, :x.shape[1]].transpose(-1, -2))
|
| 100 |
+
|
| 101 |
+
B_, L_, D_ = a.shape
|
| 102 |
+
S_ = self.ssm_logsigma.shape[-1]
|
| 103 |
+
sigma = -self.ssm_logsigma.exp()
|
| 104 |
+
theta0 = self.ssm_theta
|
| 105 |
+
A_eff = sigma.unsqueeze(0).unsqueeze(0) + 1j * theta0.unsqueeze(0).unsqueeze(0)
|
| 106 |
+
|
| 107 |
+
if cond is not None:
|
| 108 |
+
# V33 路由: 意图 cond 调制 dt 与 B (读写门意图感知), 广播到每步
|
| 109 |
+
c_dt = self.cond_dt(cond) # (..., d)
|
| 110 |
+
c_b = self.cond_b(cond) # (..., d_state)
|
| 111 |
+
if c_dt.dim() == 1:
|
| 112 |
+
c_dt = c_dt.unsqueeze(0)
|
| 113 |
+
c_b = c_b.unsqueeze(0)
|
| 114 |
+
dt = F.softplus(self.dt_proj(a) + c_dt.unsqueeze(1)) + 1e-4
|
| 115 |
+
Bk = self.B_proj(a) + c_b.unsqueeze(1)
|
| 116 |
+
else:
|
| 117 |
+
dt = F.softplus(self.dt_proj(a)) + 1e-4
|
| 118 |
+
Bk = self.B_proj(a)
|
| 119 |
+
Ck = self.C_proj(a)
|
| 120 |
+
Ab = torch.exp(dt.unsqueeze(-1) * A_eff)
|
| 121 |
+
bk = (Ab - 1.0) / (A_eff + 1e-8) * Bk.unsqueeze(2) * a.unsqueeze(-1)
|
| 122 |
+
|
| 123 |
+
h = torch.zeros(B_, D_, S_, dtype=torch.complex64, device=Ab.device)
|
| 124 |
+
ys = torch.zeros(B_, L_, D_, device=a.device)
|
| 125 |
+
|
| 126 |
+
for k in range(L_):
|
| 127 |
+
h_mean = Ab[:, k] * h + bk[:, k]
|
| 128 |
+
|
| 129 |
+
# ── V30.3: 内部纠缠 ──────────────
|
| 130 |
+
if self.ent_mode != 'none' and k > 0:
|
| 131 |
+
# 从 |h| 导出纯相位调制 (跨 N 维度耦合)
|
| 132 |
+
h_norm = h_mean / (h_mean.abs() + 1e-8) # 归一化复数
|
| 133 |
+
h_amp = h_mean.abs() # 振幅
|
| 134 |
+
phase_input = h_norm.real * h_amp # (B, D, N) 振幅加权相位信号
|
| 135 |
+
|
| 136 |
+
# 跨 N 维度的线性耦合
|
| 137 |
+
delta_raw = self.ent_mod(phase_input) # (B, D, N)
|
| 138 |
+
|
| 139 |
+
if self.ent_k > 0 and self.ent_k < S_:
|
| 140 |
+
# top-k 稀疏化: 只纠缠振幅最大的维度
|
| 141 |
+
_, top_idx = h_amp.topk(self.ent_k, dim=-1)
|
| 142 |
+
mask = torch.zeros_like(delta_raw)
|
| 143 |
+
mask.scatter_(-1, top_idx, 1.0)
|
| 144 |
+
delta_raw = delta_raw * mask
|
| 145 |
+
|
| 146 |
+
# 振幅门控: 强信号有更强的纠缠, 但不改变本身振幅
|
| 147 |
+
amp_gate = torch.sigmoid(self.ent_amp(h_amp)) # (B, D, 1)
|
| 148 |
+
phase_shift = torch.tanh(delta_raw) * self.ent_strength * amp_gate
|
| 149 |
+
|
| 150 |
+
# 纯相位旋转: 不改变振幅
|
| 151 |
+
h = h_mean * torch.exp(1j * phase_shift)
|
| 152 |
+
else:
|
| 153 |
+
h = h_mean
|
| 154 |
+
|
| 155 |
+
y = (Ck[:, k].unsqueeze(1) * h).sum(dim=-1).real + self.ssm_D * a[:, k]
|
| 156 |
+
ys[:, k] = y
|
| 157 |
+
|
| 158 |
+
if self.ent_mode != 'none':
|
| 159 |
+
self._entropy_trace.append(self.compute_entropy(h))
|
| 160 |
+
|
| 161 |
+
out = self.out_proj(ys * F.silu(b)) + r
|
| 162 |
+
|
| 163 |
+
# 兼容 V30_CodeLoop: 保存最后一个激活
|
| 164 |
+
self._last_a = a[:, -1, :]
|
| 165 |
+
|
| 166 |
+
return out, 0.0 # (y, kl=0 兼容 V30_CodeLoop)
|
hibs_lnn/stochastic_keydoor.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""A′ 的三个环境:把 `novelty` 与 `dynamics change` 分开。
|
| 3 |
+
|
| 4 |
+
## 为什么需要新环境
|
| 5 |
+
|
| 6 |
+
原 `KeyDoorMDP.step()` 是**完全确定性**的(无任何随机源)。实测:所有访问过
|
| 7 |
+
`n>=2` 的 `(s,a)`,后继分布的最大熵**逐位为 0.000000**。于是
|
| 8 |
+
|
| 9 |
+
转移不确定性 H(T|S,A) ≡ 0
|
| 10 |
+
转移预测误差 1−p̂(s'|s,a) 的唯一非零来源是 `n<2` 分支
|
| 11 |
+
稳定区域 ≈ 同样的计数
|
| 12 |
+
|
| 13 |
+
三个语义不同的量坍缩成**同一个"我见过这个 (s,a) 几次"**。
|
| 14 |
+
⇒ 那个环境对 A 类**不可辨识**,不是 A 类不行。
|
| 15 |
+
|
| 16 |
+
## 三个环境(A′-2)
|
| 17 |
+
|
| 18 |
+
E0 KeyDoorMDP 确定性 —— 负对照, 保留不删
|
| 19 |
+
E1 StochasticKeyDoor 平稳随机 —— 不确定性可分离, 无 shift
|
| 20 |
+
E2 NonStationaryKeyDoor 非平稳(slip↑) —— ★ **可用**: 满足构型 II
|
| 21 |
+
FlipKeyDoor 非平稳(翻转) —— ❌ 失败构造, 保留为反例
|
| 22 |
+
|
| 23 |
+
★ **命名与直觉相反, 以实测为准**(探针③, 逐步紧邻等长窗口):
|
| 24 |
+
|
| 25 |
+
E2 翻转: N 3.62 A1 3.39 A2 2.75 <- N 跳得比 A1 还凶, 失败
|
| 26 |
+
E2 slip↑: N 0.88 A1 3.10 A2 1.25 <- N 平, A1 跳, A1/A2 分歧 ✓
|
| 27 |
+
|
| 28 |
+
我原以为"翻转(支撑集不变)"最干净、"slip↑"是反例。**测量给出相反答案**:
|
| 29 |
+
决定 novelty 的不是转移的支撑集, 而是最终的**去向分布**; 翻转恰好把 agent
|
| 30 |
+
送到它几乎没去过的伙伴目标。而均匀随机 slip↑ 是在**已熟悉的地盘内**
|
| 31 |
+
增加不可预测性 ⇒ 访问分布不动, `p̂` 变成错的。
|
| 32 |
+
|
| 33 |
+
## 设计要点:机制只写一份
|
| 34 |
+
|
| 35 |
+
每个环境只实现 **`_perturb(a) -> 实际执行的动作`**, `step()` 与诊断用的
|
| 36 |
+
`true_transition()` **共用**它。
|
| 37 |
+
|
| 38 |
+
第一版把机制在 `step()` 和 `true_transition()` 里各写了一遍, 后果是:
|
| 39 |
+
`true_transition` 里那条 `if rand < _slip_now(): a = randint(N)` 是**均匀随机**
|
| 40 |
+
版本, 对 `FlipKeyDoor` 完全不对(它只翻给伙伴动作)。于是"真值"量的是另一套
|
| 41 |
+
动力学, 而探针偏偏靠真值判断环境可不可辨识 —— **诊断工具自己错了,
|
| 42 |
+
却会给出一个看起来正常的结论**。两份实现只要漂移一次就足够。
|
| 43 |
+
"""
|
| 44 |
+
import numpy as np
|
| 45 |
+
|
| 46 |
+
from hibs_lnn.skill_mdp import KeyDoorMDP
|
| 47 |
+
|
| 48 |
+
__all__ = ["StochasticKeyDoor", "FlipKeyDoor", "NonStationaryKeyDoor",
|
| 49 |
+
"N_ACTIONS", "true_transition"]
|
| 50 |
+
|
| 51 |
+
N_ACTIONS = 4 # LEFT, RIGHT, GRAB, OPEN
|
| 52 |
+
_NEVER = 1 << 62
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class StochasticKeyDoor(KeyDoorMDP):
|
| 56 |
+
"""E1 平稳随机:`P(s'|s,a)` 全程不变。
|
| 57 |
+
|
| 58 |
+
以 `(1-slip)` 执行意图动作, 否则执行**均匀随机**动作。
|
| 59 |
+
作用 = 制造"不确定性高但**没有** regime shift"。
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(self, slip=0.10, seed=0, **kw):
|
| 63 |
+
super().__init__(**kw)
|
| 64 |
+
self.slip = float(slip)
|
| 65 |
+
self.rng = np.random.RandomState(int(seed))
|
| 66 |
+
self.t_total = 0 # 全局步数(跨 episode) —— 子类用它定位 shift
|
| 67 |
+
self.shifted = False
|
| 68 |
+
|
| 69 |
+
# ── 机制(唯一实现; step 与 true_transition 共用)──────────────
|
| 70 |
+
def _p_now(self):
|
| 71 |
+
return self.slip
|
| 72 |
+
|
| 73 |
+
def _perturb(self, a):
|
| 74 |
+
"""返回**实际执行**的动作。支撑集由子类的实现决定。"""
|
| 75 |
+
if self.rng.rand() < self._p_now():
|
| 76 |
+
return int(self.rng.randint(N_ACTIONS))
|
| 77 |
+
return int(a)
|
| 78 |
+
|
| 79 |
+
def step(self, a):
|
| 80 |
+
if not self.shifted and self.t_total >= getattr(self, "shift_at", _NEVER):
|
| 81 |
+
self.shifted = True
|
| 82 |
+
self.t_total += 1
|
| 83 |
+
return KeyDoorMDP.step(self, self._perturb(a))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class FlipKeyDoor(StochasticKeyDoor):
|
| 87 |
+
"""❌ 失败构造(保留为反例):"翻转分布、不扩张支撑集"**仍然混淆 novelty**。
|
| 88 |
+
|
| 89 |
+
## 我当初的推理(错的)
|
| 90 |
+
|
| 91 |
+
想让 `P(s'|s,a)` 的**支撑集恒定**、只翻转权重:
|
| 92 |
+
|
| 93 |
+
P(s'|s,a) = (1-p)·1[s'=T(s,a)] + p·1[s'=T(s,c(a))]
|
| 94 |
+
shift 前 p = 0.10 shift 后 p = 0.90
|
| 95 |
+
|
| 96 |
+
`c(a)` 是固定的伙伴动作(LEFT<->RIGHT, GRAB<->OPEN)。推理是:
|
| 97 |
+
支撑集 `{T(s,a), T(s,c(a))}` 两个 p 下都一样 ⇒ 到达的状态集合不变
|
| 98 |
+
⇒ novelty 不变; 而模型学到的 `p̂` 现在是错的 ⇒ A1 跳; 熵
|
| 99 |
+
`H({p,1−p})` 两 regime 同值 ⇒ A2 不跳。看起来是完美的 构型 II。
|
| 100 |
+
|
| 101 |
+
## 实测(逐步、紧邻等长窗口, 探针③)
|
| 102 |
+
|
| 103 |
+
E2 翻转: N 0.0597 -> 0.2160 (3.62) A1 0.1908 -> 0.6476 (3.39)
|
| 104 |
+
A2 0.1125 -> 0.3093 (2.75)
|
| 105 |
+
E2c slip↑: N 0.0997 -> 0.0881 (0.88) A1 0.1738 -> 0.5382 (3.10)
|
| 106 |
+
A2 0.1326 -> 0.1656 (1.25)
|
| 107 |
+
|
| 108 |
+
**翻转版失败**: N 跳 3.62 倍, 比 A1 还凶。
|
| 109 |
+
|
| 110 |
+
## 为什么推理错了(值得记)
|
| 111 |
+
|
| 112 |
+
> **决定 novelty 的不是转移的支撑集, 而是最终的去向分布。**
|
| 113 |
+
|
| 114 |
+
支撑集不变只保证"这些状态**可能**被到达"; 但翻转把 agent 在 90% 的
|
| 115 |
+
步子上送到**伙伴目标** —— 那是它极少去的地方 ⇒ 它真的闯进了新领���。
|
| 116 |
+
支撑集是"可能性", novelty 量的是"实际去过几次", 两者不是一回事。
|
| 117 |
+
|
| 118 |
+
## 教训
|
| 119 |
+
|
| 120 |
+
"不扩张支撑集"看起来是把 novelty 摘掉的自然做法, 但 agent 的行为是
|
| 121 |
+
**状态依赖**的 —— 改了动力学就改了轨迹, 改了轨迹就改了访问分布。
|
| 122 |
+
要抑制 novelty, 得直接让**访问分布**不变, 而不是让转移的支撑集不变。
|
| 123 |
+
`NonStationaryKeyDoor`(均匀随机 slip↑)无意中做到了这一点:
|
| 124 |
+
它在**已熟悉的地盘内**增加不可预测性。
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
COMPANION = (1, 0, 3, 2) # LEFT<->RIGHT, GRAB<->OPEN
|
| 128 |
+
|
| 129 |
+
def __init__(self, p_pre=0.10, p_post=0.90, shift_at=3000, seed=0, **kw):
|
| 130 |
+
super().__init__(slip=p_pre, seed=seed, **kw)
|
| 131 |
+
self.p_pre = float(p_pre)
|
| 132 |
+
self.p_post = float(p_post)
|
| 133 |
+
self.shift_at = int(shift_at)
|
| 134 |
+
|
| 135 |
+
def _p_now(self):
|
| 136 |
+
return self.p_pre if self.t_total < self.shift_at else self.p_post
|
| 137 |
+
|
| 138 |
+
def _perturb(self, a):
|
| 139 |
+
if self.rng.rand() < self._p_now():
|
| 140 |
+
return int(self.COMPANION[int(a)]) # 只翻给伙伴动作
|
| 141 |
+
return int(a)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class NonStationaryKeyDoor(FlipKeyDoor):
|
| 145 |
+
"""★ E2(**可用版**):均匀随机 slip 在 `shift_at` 处抬升。
|
| 146 |
+
|
| 147 |
+
E1: slip 恒定 0.10 E2: 0.10 -> 0.60 @shift_at
|
| 148 |
+
|
| 149 |
+
它最初是被当成"反例"写下来的(我原以为它会混淆 novelty, 因为
|
| 150 |
+
"slip 变大 ⇒ agent 走得不一样"). 实测**正好相反**, 它是唯一同时满足
|
| 151 |
+
构型 II 的构造:
|
| 152 |
+
|
| 153 |
+
N 0.0997 -> 0.0881 (0.88) ← 平坦: 没有更"新"
|
| 154 |
+
A1 0.1738 -> 0.5382 (3.10) ← 跳: 学到的 p̂ 现在错了
|
| 155 |
+
A2 0.1326 -> 0.1656 (1.25) ← 基本平 ⇒ **A1 与 A2 分歧**
|
| 156 |
+
|
| 157 |
+
为什么它反而干净: 均匀随机 slip 在 **已熟悉的地盘内** 增加不可预测性
|
| 158 |
+
(agent 早就把大部分 (s,a) 走过很多遍了), 所以访问分布几乎不动;
|
| 159 |
+
而 `p̂` 的**形状**变了 ⇒ 预测误差上去了。
|
| 160 |
+
|
| 161 |
+
于是这条环境上 "变" 与 "新" 可辨识, 并且 A1/A2 读数**明显分离** ——
|
| 162 |
+
这是 A 类候选第一次在同一环境上表现出不同的量。
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
def __init__(self, slip0=0.10, slip1=0.60, shift_at=3000, seed=0, **kw):
|
| 166 |
+
super().__init__(p_pre=slip0, p_post=slip1, shift_at=shift_at,
|
| 167 |
+
seed=seed, **kw)
|
| 168 |
+
|
| 169 |
+
def _perturb(self, a):
|
| 170 |
+
if self.rng.rand() < self._p_now():
|
| 171 |
+
return int(self.rng.randint(N_ACTIONS)) # 均匀随机 = 不选边
|
| 172 |
+
return int(a)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def true_transition(env, s, a, trials=400):
|
| 176 |
+
"""把 `(s,a)` 的真实后继分布**数出来**(穷举环境, 非模型)。
|
| 177 |
+
|
| 178 |
+
只用于验证"这个 benchmark 真的可辨识"(判据 ⑨′a/⑨′b)。
|
| 179 |
+
agent 侧一律只用在线计数表, **不碰这个函数**。
|
| 180 |
+
|
| 181 |
+
★ 设置状态必须走 `decode()`:`s = pos*4 + has_key*2 + door_open`,
|
| 182 |
+
`has_key`/`door_open` 也影响转移。手写 `env.s = s` 是错的
|
| 183 |
+
(那个属性不存在, 而且漏掉两个标志位)。
|
| 184 |
+
★ 扰动走 `env._perturb()` —— 与 `step()` **同一份实现**。E0 没有该方法,
|
| 185 |
+
退化成恒等(确定性), 正好是我们要的负对照。
|
| 186 |
+
"""
|
| 187 |
+
pert = getattr(env, "_perturb", None)
|
| 188 |
+
cnt = {}
|
| 189 |
+
for _ in range(int(trials)):
|
| 190 |
+
env.pos, env.has_key, env.door_open = env.decode(s)
|
| 191 |
+
env.t = 0
|
| 192 |
+
aa = int(a) if pert is None else int(pert(a))
|
| 193 |
+
s2, _, _ = KeyDoorMDP.step(env, aa)
|
| 194 |
+
cnt[int(s2)] = cnt.get(int(s2), 0) + 1
|
| 195 |
+
return cnt
|
hibs_lnn/subtask_options.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""subtask_options.py — **按论文顺序**实现 STOMP(arXiv 2202.03466)。
|
| 2 |
+
|
| 3 |
+
SubTask ──> Option ──> Model ──> Planning
|
| 4 |
+
|
| 5 |
+
本文件是对本项目现有 `OptionManager` 的**修正版**。差别(逐条对齐论文):
|
| 6 |
+
|
| 7 |
+
| | 现有 `option_manager.py` | 本文件 |
|
| 8 |
+
|:--|:--|:--|
|
| 9 |
+
| 起点 | **转移图**(model-first) | **subtask**:先定 GVF 的 `(c, z)` |
|
| 10 |
+
| 目标 | 状态向量 `g_o` + 欧氏距离 | **stopping value `z_i(s)`**(带 stopping bonus) |
|
| 11 |
+
| `π_o` | 对模型贪心 argmax | 在 subtask MDP 上解出来的策略 |
|
| 12 |
+
| `β_o` | 逻辑回归拟合 | **stop 动作最优即停**(与论文 Fig.2 的"红色停止格"一致) |
|
| 13 |
+
| model | 无 | **option model**(终止分布 + 累计奖励)显式学出来 |
|
| 14 |
+
| 用途 | 替换 base policy 的动作 | **进 planner**,指标 = planning look-ahead 操作数 |
|
| 15 |
+
|
| 16 |
+
## 论文的两种 subtask(必须都实现, 才有正反例)
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
shortest-path (bottleneck) c = −1 z = 0 at subgoal
|
| 20 |
+
reward-respecting c = R (与主任务相同) z_i(s) = v(s) − w_i x_i(s) + w̄_i x_i(s)
|
| 21 |
+
\____ stopping bonus ____/
|
| 22 |
+
```
|
| 23 |
+
论文 Fig.1 的结论:`shortest-path` 的 planning **比 primitive 还慢**;
|
| 24 |
+
`reward-respecting` **明显更快**。**这两个已知结论就是本项目的 ground truth。**
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
from hibs_lnn.gridworld import N_ACT, UP, RIGHT, DOWN, LEFT
|
| 31 |
+
|
| 32 |
+
STOP = N_ACT # 停止动作的编号
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 36 |
+
# 1. Subtask 规格:GVF 的 cumulant c 与 stopping value z
|
| 37 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 38 |
+
|
| 39 |
+
class Subtask:
|
| 40 |
+
"""一个 GVF 子任务 = (c, z)。
|
| 41 |
+
|
| 42 |
+
c(s, a) : cumulant(论文里最短路径 subtask 是 −1, reward-respecting 是主任务奖励)
|
| 43 |
+
z(s) : stopping value(在 s 停止时拿到的终止价值)
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, name, c_fn, z_fn):
|
| 47 |
+
self.name = name
|
| 48 |
+
self.c_fn = c_fn # (s, a) -> float, 该转移的期望 cumulant
|
| 49 |
+
self.z_fn = z_fn # (s) -> float
|
| 50 |
+
self.gamma = 0.99 # 由 solve(env) 覆盖
|
| 51 |
+
self.policy = None # π_o:(s) -> a 或 STOP
|
| 52 |
+
self.value = None # 该 subtask 的价值函数
|
| 53 |
+
self.model = None # option model
|
| 54 |
+
self.beta = None # β_o(s): 是否停止
|
| 55 |
+
|
| 56 |
+
# ── 解 subtask -> 得到 option(论文 §3) ────────────────────────────
|
| 57 |
+
def solve(self, env, max_sweeps=2000, tol=1e-10, resume_from=None):
|
| 58 |
+
"""在 subtask MDP 上做价值迭代(action 集 = 4 个 primitive + STOP)。
|
| 59 |
+
|
| 60 |
+
目标: 最大化 E[ Σ γ^t c_t + γ^K z(S_K) ]
|
| 61 |
+
Q(s, a) = c(s,a) + γ (1−β) V(s') + γ β z(s') # β 由"停止是否最优"隐式决定
|
| 62 |
+
"""
|
| 63 |
+
n = env.n_states
|
| 64 |
+
V = np.zeros(n) if resume_from is None else resume_from.copy()
|
| 65 |
+
for sweep in range(max_sweeps):
|
| 66 |
+
delta = 0.0
|
| 67 |
+
Vn = V.copy()
|
| 68 |
+
for s in range(n):
|
| 69 |
+
if env.is_goal[s]:
|
| 70 |
+
Vn[s] = 0.0 # goal 是终止状态(episode 结束)
|
| 71 |
+
delta = max(delta, abs(Vn[s] - V[s]))
|
| 72 |
+
continue
|
| 73 |
+
# 停下:s 的价值就是 z(s)
|
| 74 |
+
best = self.z_fn(s)
|
| 75 |
+
for a in range(N_ACT):
|
| 76 |
+
c = self.c_fn(s, a)
|
| 77 |
+
if c is None:
|
| 78 |
+
continue
|
| 79 |
+
val = 0.0
|
| 80 |
+
for p, ns in env.T[s][a]:
|
| 81 |
+
# 在 ns 决定:继续(value)还是停(z)
|
| 82 |
+
val += p * (c + env.gamma * max(V[ns], self.z_fn(ns)))
|
| 83 |
+
if val > best:
|
| 84 |
+
best = val
|
| 85 |
+
Vn[s] = best
|
| 86 |
+
delta = max(delta, abs(Vn[s] - V[s]))
|
| 87 |
+
V = Vn
|
| 88 |
+
if delta < tol:
|
| 89 |
+
break
|
| 90 |
+
self.value = V
|
| 91 |
+
# 导出 π_o 与 β_o
|
| 92 |
+
pol = np.full(n, STOP, dtype=int)
|
| 93 |
+
for s in range(n):
|
| 94 |
+
if env.is_goal[s]:
|
| 95 |
+
pol[s] = STOP
|
| 96 |
+
continue
|
| 97 |
+
best_a, best_v = STOP, self.z_fn(s)
|
| 98 |
+
for a in range(N_ACT):
|
| 99 |
+
c = self.c_fn(s, a)
|
| 100 |
+
if c is None:
|
| 101 |
+
continue
|
| 102 |
+
val = 0.0
|
| 103 |
+
for p, ns in env.T[s][a]:
|
| 104 |
+
val += p * (c + env.gamma * max(V[ns], self.z_fn(ns)))
|
| 105 |
+
if val > best_v + 1e-12:
|
| 106 |
+
best_a, best_v = a, val
|
| 107 |
+
pol[s] = best_a
|
| 108 |
+
self.policy = pol
|
| 109 |
+
# β_o(s) = True 表示"停在 s 是最优的"
|
| 110 |
+
# 判据: z(s) >= "继续走"的价值 —— 与论文 Fig.2 的红色停止格一致
|
| 111 |
+
cont = np.full(n, -np.inf)
|
| 112 |
+
for s in range(n):
|
| 113 |
+
if env.is_goal[s]:
|
| 114 |
+
continue
|
| 115 |
+
best = -np.inf
|
| 116 |
+
for a in range(N_ACT):
|
| 117 |
+
c = self.c_fn(s, a)
|
| 118 |
+
if c is None:
|
| 119 |
+
continue
|
| 120 |
+
val = 0.0
|
| 121 |
+
for p_, ns in env.T[s][a]:
|
| 122 |
+
val += p_ * (c + env.gamma * max(V[ns], self.z_fn(ns)))
|
| 123 |
+
best = max(best, val)
|
| 124 |
+
cont[s] = best
|
| 125 |
+
self.beta = np.array([bool(self.z_fn(s) >= cont[s] - 1e-12) for s in range(n)],
|
| 126 |
+
dtype=bool)
|
| 127 |
+
self.gamma = env.gamma
|
| 128 |
+
return self
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 132 |
+
# 2. 两种 subtask 的具体定义(论文 Fig.1 的对照)
|
| 133 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 134 |
+
|
| 135 |
+
def shortest_path_subtask(env, subgoal_state, neg_on_neg=True):
|
| 136 |
+
"""论文:shortest-path / bottleneck option。`c = −1`,`z = 0` 只在子目标处。"""
|
| 137 |
+
|
| 138 |
+
def c(s, a):
|
| 139 |
+
return -1.0
|
| 140 |
+
|
| 141 |
+
def z(s):
|
| 142 |
+
return 0.0 if s == subgoal_state else -np.inf
|
| 143 |
+
|
| 144 |
+
st = Subtask("shortest_path", c, z)
|
| 145 |
+
return st
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def reward_respecting_subtask(env, feature_vec, V_main, bonus_weight=1.0,
|
| 149 |
+
stop_at_goal=True):
|
| 150 |
+
r"""论文式(4):reward-respecting subtask of feature attainment。
|
| 151 |
+
|
| 152 |
+
c(s,a) = R(s,a) -- **与主任务相同的奖励**
|
| 153 |
+
z_i(s) = [w^T x(s) - w_i*x_i(s)] + w_bar_i*x_i(s)
|
| 154 |
+
= V_main(s) + (w_bar_i - w_i)*x_i(s)
|
| 155 |
+
\__ stopping bonus __/
|
| 156 |
+
|
| 157 |
+
*** 一个必须做对的细节(本项目在 ground-truth 基准上抓到的 bug) ***
|
| 158 |
+
式(4) 里 `w_i` 是 **特征 i 在主任务价值函数里的权重**, 不是 1。
|
| 159 |
+
|
| 160 |
+
第一版把 `w_i` 硬写成 1.0 且 `w_bar_i = 1` -> stopping bonus 恒为 0
|
| 161 |
+
-> `z_i(s) == V_main(s)` -> 子任务近似主任务 -> `beta_o` 处处为空
|
| 162 |
+
-> option 永不停止 -> option model 退化 -> reward-respecting 与
|
| 163 |
+
shortest-path 给出**完全相同**的 planning 曲线(实测 2145 / 2145)。
|
| 164 |
+
|
| 165 |
+
这正是论文原话警告的情形:
|
| 166 |
+
"The stopping values should not equal the estimated values because then the
|
| 167 |
+
subtask would approximate the main task and solving it would probably add
|
| 168 |
+
nothing new."
|
| 169 |
+
|
| 170 |
+
对一热特征 x_i(s) in {0,1}, 主任务线性近似下特征 i 的权重就是
|
| 171 |
+
**该特征为 1 的诸状态上的价值贡献** -> w_i = mean(V_main[feature > 0])。
|
| 172 |
+
|
| 173 |
+
bonus_weight 就是论文的 w_bar_i:
|
| 174 |
+
w_bar ~ w_i bonus 小, option 几乎不提前停
|
| 175 |
+
w_bar = 1 论文主用值
|
| 176 |
+
w_bar -> 很大 退化成 shortest-path(论文 §6 实测, w_bar=100 走最短路)
|
| 177 |
+
"""
|
| 178 |
+
on = np.asarray(feature_vec, dtype=float) > 0
|
| 179 |
+
w_i = float(np.mean(np.asarray(V_main, dtype=float)[on])) if on.any() else 0.0
|
| 180 |
+
|
| 181 |
+
def c(s, a):
|
| 182 |
+
return float(env.R[s][a])
|
| 183 |
+
|
| 184 |
+
def z(s):
|
| 185 |
+
if not on[s] and not (stop_at_goal and env.is_goal[s]):
|
| 186 |
+
# 特征为 0 处**不允许停**(否则"停"与"继续"同价 -> beta 退化)
|
| 187 |
+
return -np.inf
|
| 188 |
+
return float(V_main[s]) + (bonus_weight - w_i) * float(feature_vec[s])
|
| 189 |
+
|
| 190 |
+
st = Subtask("reward_respecting", c, z)
|
| 191 |
+
st.w_i = w_i
|
| 192 |
+
st.bonus_weight = bonus_weight
|
| 193 |
+
return st
|
| 194 |
+
|
| 195 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 196 |
+
# 3. Option model(论文 §4:第三步,在 option 之后)
|
| 197 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 198 |
+
|
| 199 |
+
def build_option_model(env, opt, max_steps=200):
|
| 200 |
+
r"""由 option 的 (pi_o, beta_o) 推出**SMDP 形式的 option model**。
|
| 201 |
+
|
| 202 |
+
返回 `M[s] = [(p, s_term, r_disc, k), ...]`,其中
|
| 203 |
+
|
| 204 |
+
p : 从 s 出发最终终止于 s_term 的概率
|
| 205 |
+
r_disc : 沿途中**已贴现**的累计奖励 sum_t gamma^t r_t
|
| 206 |
+
k : 平均步数
|
| 207 |
+
|
| 208 |
+
★★ 第二个必须做对的细节(ground-truth 基准抓到的)
|
| 209 |
+
-------------------------------------------------
|
| 210 |
+
第一版只返回未贴现的累计奖励, 且 planner 里写成 ``r + gamma*V(s_term)``
|
| 211 |
+
—— 即只贴现**一步**, 而不是 option 实际占用的 k 步。
|
| 212 |
+
-> option 被系统性高估 -> 价值迭代发散
|
| 213 |
+
(实测: look-ahead ��满 300000 仍未收敛, 而 primitive 只需 1716)。
|
| 214 |
+
|
| 215 |
+
SMDP 的正确形式是: Q(s, o) = E[ r_disc + gamma^k * V(s_term) ]
|
| 216 |
+
"""
|
| 217 |
+
n = env.n_states
|
| 218 |
+
M = []
|
| 219 |
+
for s0 in range(n):
|
| 220 |
+
cur = {int(s0): (1.0, 0.0, 0.0)} # state -> (prob, 期望贴现回报, 期望步数)
|
| 221 |
+
term = {}
|
| 222 |
+
|
| 223 |
+
def merge(d, key, p, dr, k):
|
| 224 |
+
o = d.get(key)
|
| 225 |
+
if o is None:
|
| 226 |
+
d[key] = [p, p * dr, p * k]
|
| 227 |
+
else:
|
| 228 |
+
o[0] += p
|
| 229 |
+
o[1] += p * dr
|
| 230 |
+
o[2] += p * k
|
| 231 |
+
|
| 232 |
+
for _step in range(max_steps):
|
| 233 |
+
if not cur:
|
| 234 |
+
break
|
| 235 |
+
nxt = {}
|
| 236 |
+
for s, (p, dr, k) in cur.items():
|
| 237 |
+
if opt.beta[s] or opt.policy[s] == STOP:
|
| 238 |
+
merge(term, s, p, dr, k)
|
| 239 |
+
continue
|
| 240 |
+
a = int(opt.policy[s])
|
| 241 |
+
r_imm = float(opt.c_fn(s, a))
|
| 242 |
+
for pt, ns in env.T[s][a]:
|
| 243 |
+
pp = p * pt
|
| 244 |
+
merge(nxt, int(ns), pp, dr + (env.gamma ** k) * r_imm, k + 1.0)
|
| 245 |
+
cur = {s: (v[0], v[1] / v[0] if v[0] > 0 else 0.0,
|
| 246 |
+
v[2] / v[0] if v[0] > 0 else 0.0) for s, v in nxt.items()}
|
| 247 |
+
|
| 248 |
+
outs = []
|
| 249 |
+
for s, (psum, drw, kw) in sorted(term.items()):
|
| 250 |
+
if psum <= 1e-12:
|
| 251 |
+
continue
|
| 252 |
+
outs.append((float(psum), int(s), float(drw / psum), float(kw / psum)))
|
| 253 |
+
if not outs:
|
| 254 |
+
outs = [(1.0, int(s0), 0.0, 1.0)]
|
| 255 |
+
M.append(outs)
|
| 256 |
+
return M
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 261 |
+
# 4. Planning(论文 §5):指标 = look-ahead 操作数
|
| 262 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 263 |
+
|
| 264 |
+
def planning_curve(env, option_models, v_star, s0, tol=1e-3, max_ops=400000,
|
| 265 |
+
sweeps_between=1):
|
| 266 |
+
"""带 option 的价值迭代;记录"每消耗 N 次 look-ahead 时 V(s0) 的误差"。
|
| 267 |
+
|
| 268 |
+
option_models : {name: M}, M 来自 build_option_model。空 dict = 只用 primitive。
|
| 269 |
+
返回 (ops_list, err_list) —— 与论文 Fig.1 的横/纵轴一致。
|
| 270 |
+
"""
|
| 271 |
+
n = env.n_states
|
| 272 |
+
V = np.zeros(n)
|
| 273 |
+
ops_list, err_list = [], []
|
| 274 |
+
total = 0
|
| 275 |
+
# 每次都从 s0 开始扫(顺序会显著影响效率, 这里固定在自然顺序, 对三种方法一致)
|
| 276 |
+
order = list(range(n))
|
| 277 |
+
while total < max_ops:
|
| 278 |
+
for s in order:
|
| 279 |
+
if env.is_goal[s]:
|
| 280 |
+
V[s] = 0.0
|
| 281 |
+
continue
|
| 282 |
+
best = -np.inf
|
| 283 |
+
# primitive
|
| 284 |
+
for a in range(N_ACT):
|
| 285 |
+
val = 0.0
|
| 286 |
+
for p, ns in env.T[s][a]:
|
| 287 |
+
val += p * (env.R[s][a] + env.gamma * V[ns])
|
| 288 |
+
total += 1
|
| 289 |
+
if val > best:
|
| 290 |
+
best = val
|
| 291 |
+
# options —— SMDP: r_disc + gamma^k * V(s_term)
|
| 292 |
+
for name, M in option_models.items():
|
| 293 |
+
for ent in M[s]:
|
| 294 |
+
p, s_term, r_disc, k = ent[0], ent[1], ent[2], (ent[3] if len(ent) > 3 else 1.0)
|
| 295 |
+
val = p * (r_disc + (env.gamma ** k) * V[s_term])
|
| 296 |
+
total += 1
|
| 297 |
+
if val > best:
|
| 298 |
+
best = val
|
| 299 |
+
V[s] = best
|
| 300 |
+
err = abs(V[s0] - v_star)
|
| 301 |
+
ops_list.append(total)
|
| 302 |
+
err_list.append(err)
|
| 303 |
+
if err < tol:
|
| 304 |
+
break
|
| 305 |
+
return ops_list, err_list
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def ops_to_tolerance(env, option_models, v_star, s0, tol=0.01, max_ops=400000):
|
| 309 |
+
ops, errs = planning_curve(env, option_models, v_star, s0, tol=tol, max_ops=max_ops)
|
| 310 |
+
return ops[-1], errs[-1], ops, errs
|
hibs_lnn/swifttd_head.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
V31.1: SwiftTD 式 per-feature step-size 优化 (Javed et al., RLC 2024)
|
| 3 |
+
=====================================================================
|
| 4 |
+
解决 V31.0 发现的问题: IDBD 融合的 β 通过 BPTT 外循环学 (Meta-SGD 风格),
|
| 5 |
+
meta 梯度 ~1e-6 学不动 (arXiv 2401.17401 Figure 4)。
|
| 6 |
+
|
| 7 |
+
SwiftTD 三件套 (全部在线, 无 BPTT) — 精确对应论文 Algorithm 1:
|
| 8 |
+
1. per-feature step-size 优化: β[i] = e^{θ[i]}, IDBD 在线更新:
|
| 9 |
+
θ[i] += κ·δ[i]·h[i]·φ[i] (meta 更新, 在 decay/重置之前)
|
| 10 |
+
h[i] ← (1-β[i]·φ[i]²)⁺·h[i] + β[i]·δ[i]·φ[i] (step-size 迹)
|
| 11 |
+
2. overshoot bound: correction ratio τ = Σᵢ β[i]·φ[i]²,
|
| 12 |
+
τ > η 时更新缩放 s = η/τ (η = 0.1, max correction ratio)
|
| 13 |
+
3. step-size decay: τ > η 时 θ[i] += φ[i]²·ln(ϵ) (ϵ = 0.99 decay rate,
|
| 14 |
+
论文 Algorithm 1: 对 θ 的绝对衰减, 无 β 因子; β ← β·ϵ^{φ²})
|
| 15 |
+
且重置该组的迹 (论文 L524-526)
|
| 16 |
+
|
| 17 |
+
论文关键超参: η = 0.1, ϵ = 0.99, η_min = e^-15, β 裁剪到 [ln(η_min), ln(η)] → β_max = η。
|
| 18 |
+
|
| 19 |
+
本实现的关键适配 (记录在 wiki):
|
| 20 |
+
τ 按输出行 (per-row) 计算。论文的 correction ratio 定义在单个标量预测
|
| 21 |
+
上 (v = w·φ, 更新后 v' = v + s·β·δ·Σφ²); 我们的 head 有 n2 = 1616 个
|
| 22 |
+
标量输出 (8 响应 × 202 vocab), 每个输出是独立的预测、独立的误差。
|
| 23 |
+
w2 的行 u 有它自己的 τ_u = Σₖ β[u,k]·xₖ² 和缩放 s_u — 这是论文
|
| 24 |
+
"单预测 bound" 对多输出层的直接推广。若用全局 τ (早期版本), 1616 行
|
| 25 |
+
复制结构使 τ 被维度主导 → bound 永远触发 → 更新塌缩到 ~0 (实测失败)。
|
| 26 |
+
同样地, w1 按 d 行, b1/b2 是向量 (单一 τ)。
|
| 27 |
+
|
| 28 |
+
没有特征归一化: raw 特征保持 IDBD 元梯度 O(1) 尺度 (归一化会把
|
| 29 |
+
per-feature 元更新稀释到 1/N, 40 步内 β 纹丝不动, 实测失败)。
|
| 30 |
+
|
| 31 |
+
行为: β ≤ η 且 τ ≈ β̄·Σφ² ≫ η (raw 特征) → bound 常态触发 → 每行每步
|
| 32 |
+
修正恰好 η·errs (10%), decay 把 β 压到 τ≈η 的预算, IDBD 在预算内把
|
| 33 |
+
步长分配给有用特征 (const 特征 → β_min, 相关特征 → 维持)。这正是论文
|
| 34 |
+
Section 6 描述的收敛行为。
|
| 35 |
+
|
| 36 |
+
应用对象: PLN (2 层 MLP head)。每组用"入边激活"作为 IDBD 特征 φ:
|
| 37 |
+
w1 (d,d) : φ = hidden[j] (SSM 输出), δ = 回传误差 g[i]
|
| 38 |
+
b1 (d,) : φ = 1, δ = g[i]
|
| 39 |
+
w2 (n2,d) : φ = x[i] (tanh 激活), δ = errs[u]
|
| 40 |
+
b2 (n2,) : φ = 1, δ = errs[u]
|
| 41 |
+
其中 errs = p - onehot 是 CE 对 logits 的残差 (梯度)。
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
import math
|
| 45 |
+
import torch
|
| 46 |
+
import torch.nn.functional as F
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SwiftTDHead:
|
| 50 |
+
"""
|
| 51 |
+
2 层 MLP head 的 SwiftTD 步长状态机 (V31.1)。
|
| 52 |
+
|
| 53 |
+
参数布局 (与 V31 head_forward 一致):
|
| 54 |
+
W = [w1 (d,d), b1 (d,), w2 (n2,d), b2 (n2,)], n2 = vocab*n_resp
|
| 55 |
+
x = tanh(hidden @ w1^T + b1); logits = x @ w2^T + b2 → (n_resp, vocab)
|
| 56 |
+
|
| 57 |
+
状态:
|
| 58 |
+
theta: 每参数 log 步长 θ (对应 β = e^θ, 裁剪到 [β_min, β_max=η])
|
| 59 |
+
h : 每参数 step-size 迹 (IDBD 的 h 向量)
|
| 60 |
+
|
| 61 |
+
超参 (论文默认):
|
| 62 |
+
beta_init : 初始 β (论文 1e-7 长流; 短视野用 0.01)
|
| 63 |
+
kappa : IDBD meta step-size θ
|
| 64 |
+
eta : max correction ratio η = 0.1 (bound 阈值 = β 上限)
|
| 65 |
+
eps : decay rate ϵ = 0.99 (bound 触发时的衰减速率, 独立超参)
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, d_model, world_vocab, n_resp,
|
| 69 |
+
beta_init=0.01, kappa=0.05, eta=0.1, eps=0.99,
|
| 70 |
+
beta_min=1e-7, input_dim=None, tau_norm=None):
|
| 71 |
+
"""
|
| 72 |
+
input_dim (V32 意图注入用): w1 的输入维度。
|
| 73 |
+
默认 input_dim=d_model → 与 V31 布局完全一致 (向后兼容)。
|
| 74 |
+
d_model 始终是隐藏层宽度 (x 的维度, w2 的输入)。
|
| 75 |
+
tau_norm (V35.2 诊断 A): 按有效维度归一 τ。
|
| 76 |
+
None/False: 原版 τ = Σ β·φ² (绝对和)
|
| 77 |
+
'mean' : τ = Σ β·φ² / dim (per-dim 平均 — 修复 V32+ 3d 意图
|
| 78 |
+
注入后 384 维求和恒超 η → bound 常态触发 → h 迹归零
|
| 79 |
+
→ IDBD 分化死亡, β_std 1.6e-05 的诊断根因)
|
| 80 |
+
"""
|
| 81 |
+
self.d_model = d_model
|
| 82 |
+
self.input_dim = input_dim if input_dim is not None else d_model
|
| 83 |
+
self.world_vocab = world_vocab
|
| 84 |
+
self.n_resp = n_resp
|
| 85 |
+
self.n2 = world_vocab * n_resp
|
| 86 |
+
self.tau_norm = tau_norm
|
| 87 |
+
# 布局: [w1 (d, input_dim), b1 (d,), w2 (n2,d), b2 (n2,)]
|
| 88 |
+
self.shapes = [(d_model, self.input_dim), (d_model,),
|
| 89 |
+
(self.n2, d_model), (self.n2,)]
|
| 90 |
+
self.beta_init = beta_init
|
| 91 |
+
self.kappa = kappa
|
| 92 |
+
self.eta = eta # max correction ratio (bound 阈值)
|
| 93 |
+
self.eps = eps # decay rate (独立超参!)
|
| 94 |
+
self.beta_max = eta # 论文: β 裁剪上限 = ln(η)
|
| 95 |
+
self.beta_min = beta_min
|
| 96 |
+
self.reset_state()
|
| 97 |
+
|
| 98 |
+
# ── 状态��理 ─────────────────────────────────────────────
|
| 99 |
+
def reset_state(self):
|
| 100 |
+
self.theta = [torch.full(s, math.log(self.beta_init)) for s in self.shapes]
|
| 101 |
+
self.h = [torch.zeros(s) for s in self.shapes]
|
| 102 |
+
self.n_bound_triggers = 0
|
| 103 |
+
self.n_decays = 0
|
| 104 |
+
self.n_steps = 0
|
| 105 |
+
|
| 106 |
+
def to(self, device):
|
| 107 |
+
"""搬移 SwiftTD 状态 (θ/h 迹) 到 device (head 是普通类, 非 nn.Module)."""
|
| 108 |
+
self.theta = [t.to(device) for t in self.theta]
|
| 109 |
+
self.h = [t.to(device) for t in self.h]
|
| 110 |
+
return self
|
| 111 |
+
|
| 112 |
+
def cpu(self):
|
| 113 |
+
return self.to('cpu')
|
| 114 |
+
|
| 115 |
+
def beta(self):
|
| 116 |
+
return [t.exp().clamp(self.beta_min, self.beta_max) for t in self.theta]
|
| 117 |
+
|
| 118 |
+
def step_size_stats(self):
|
| 119 |
+
b = torch.cat([x.reshape(-1) for x in self.beta()])
|
| 120 |
+
return {
|
| 121 |
+
'n_params': int(b.numel()),
|
| 122 |
+
'beta_mean': float(b.mean()),
|
| 123 |
+
'beta_std': float(b.std()),
|
| 124 |
+
'beta_min': float(b.min()),
|
| 125 |
+
'beta_max': float(b.max()),
|
| 126 |
+
'frac_clipped_min': float(
|
| 127 |
+
(b <= self.beta_min * (1 + 1e-3)).float().mean()),
|
| 128 |
+
'frac_clipped_max': float(
|
| 129 |
+
(b >= self.beta_max * (1 - 1e-3)).float().mean()),
|
| 130 |
+
'n_bound_triggers': self.n_bound_triggers,
|
| 131 |
+
'n_decays': self.n_decays,
|
| 132 |
+
'n_steps': self.n_steps,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
def state(self):
|
| 136 |
+
"""保存当前状态 (θ/h 迹), 用于 sleep 前后快照。"""
|
| 137 |
+
return ([t.clone() for t in self.theta], [t.clone() for t in self.h])
|
| 138 |
+
|
| 139 |
+
def load_state(self, st):
|
| 140 |
+
theta, h = st
|
| 141 |
+
for t, v in zip(self.theta, theta):
|
| 142 |
+
t.copy_(v)
|
| 143 |
+
for t, v in zip(self.h, h):
|
| 144 |
+
t.copy_(v)
|
| 145 |
+
|
| 146 |
+
# ── 核心: 单样本在线一步 ─────────────────────────────────
|
| 147 |
+
def step(self, W, hidden, x, errs):
|
| 148 |
+
"""
|
| 149 |
+
对一个样本做一步 SwiftTD 更新 (原地修改 W, 无计算图)。
|
| 150 |
+
W : [w1,b1,w2,b2] 当前参数
|
| 151 |
+
hidden: SSM 输出 (d,)
|
| 152 |
+
x : tanh 激活 (d,)
|
| 153 |
+
errs : (n2,) = p - onehot (CE 对 logits 的残差)
|
| 154 |
+
"""
|
| 155 |
+
newW = self._rates(W, hidden, x, errs, graph=False)
|
| 156 |
+
for old, new in zip(W, newW):
|
| 157 |
+
old.copy_(new)
|
| 158 |
+
return W
|
| 159 |
+
|
| 160 |
+
def step_graph(self, W, hidden, x, errs):
|
| 161 |
+
"""
|
| 162 |
+
可微内循环一步 (V31.2 BPTT 外循环用):
|
| 163 |
+
与 step() 相同的 SwiftTD 速率 (s, β 全 detached),
|
| 164 |
+
但权重更新保持计算图 → 外循环梯度穿过内循环链
|
| 165 |
+
(RLN 特征 φ → 误差 δ → w 更新 → query loss)。
|
| 166 |
+
"""
|
| 167 |
+
return self._rates(W, hidden, x, errs, graph=True)
|
| 168 |
+
|
| 169 |
+
def _rates(self, W, hidden, x, errs, graph):
|
| 170 |
+
"""
|
| 171 |
+
SwiftTD 速率计算 + 权重更新 (graph=False 原地, graph=True 新张量)。
|
| 172 |
+
s 与 β 为 detached 速率 (bound/decay/clip 不可微, 论文 BPTT 不可用
|
| 173 |
+
→ 直通: 速率当作给定常数, 只对 w 链与特征链反传)。
|
| 174 |
+
"""
|
| 175 |
+
w1, b1, w2, b2 = W
|
| 176 |
+
d, din, n2 = self.d_model, self.input_dim, self.n2
|
| 177 |
+
|
| 178 |
+
# 各组特征 φ 与误差 δ (IDBD 逐元素形式)
|
| 179 |
+
dev = hidden.device
|
| 180 |
+
f_w1 = hidden.unsqueeze(0).expand(d, self.input_dim).contiguous()
|
| 181 |
+
f_b1 = torch.ones(d, device=dev)
|
| 182 |
+
f_w2 = x.unsqueeze(0).expand(n2, d).contiguous() # (n2,d)
|
| 183 |
+
f_b2 = torch.ones(n2, device=dev)
|
| 184 |
+
|
| 185 |
+
d_w1 = (w2.t() @ errs) * (1.0 - x * x) # (d,) 回传误差
|
| 186 |
+
d_w1 = d_w1.unsqueeze(1).expand(d, self.input_dim).contiguous()
|
| 187 |
+
d_b1 = d_w1[:, 0]
|
| 188 |
+
d_w2 = errs.unsqueeze(1).expand(n2, d).contiguous() # (n2,d)
|
| 189 |
+
d_b2 = errs
|
| 190 |
+
|
| 191 |
+
beta = self.beta()
|
| 192 |
+
ln_eps = math.log(self.eps) # ϵ<1 → 负; 论文 Algorithm 1: β += φ²·ln(ϵ)
|
| 193 |
+
|
| 194 |
+
def _group(w, f, dg, gi, row_tau):
|
| 195 |
+
"""返回 (更新后的 w, 速率 s·β 用于图形更新)。"""
|
| 196 |
+
b = beta[gi]
|
| 197 |
+
# 1) meta-θ 更新 (先于 decay/reset — 论文顺序)
|
| 198 |
+
self.theta[gi].add_(self.kappa * dg.detach() * self.h[gi] * f.detach())
|
| 199 |
+
# 2) 速率: τ 与缩放 s (detached)
|
| 200 |
+
if row_tau:
|
| 201 |
+
tau = (b * f.detach() * f.detach()).sum(dim=1)
|
| 202 |
+
if self.tau_norm == 'mean':
|
| 203 |
+
tau = tau / f.shape[1] # per-dim 平均 (诊断 A)
|
| 204 |
+
else:
|
| 205 |
+
tau = (b * f.detach() * f.detach()).sum()
|
| 206 |
+
if self.tau_norm == 'mean' and f.dim() == 1:
|
| 207 |
+
tau = tau / f.shape[0] # b1/b2 向量: 按元素数归一
|
| 208 |
+
s = torch.ones_like(tau)
|
| 209 |
+
trig = tau > self.eta
|
| 210 |
+
if bool(trig.any()):
|
| 211 |
+
s = torch.where(trig, self.eta / tau, s)
|
| 212 |
+
# 3) decay + 迹重置 (仅触发行)
|
| 213 |
+
self.theta[gi][trig].add_(
|
| 214 |
+
ln_eps * (f.detach() * f.detach())[trig])
|
| 215 |
+
self.h[gi][trig].zero_()
|
| 216 |
+
self.n_bound_triggers += int(trig.sum())
|
| 217 |
+
self.n_decays += int(trig.sum())
|
| 218 |
+
# 4) h 更新 + clip θ → β ∈ [β_min, η] (论文 Section 6)
|
| 219 |
+
self.h[gi].mul_(torch.clamp(
|
| 220 |
+
1.0 - b * f.detach() * f.detach(), min=0.0))
|
| 221 |
+
self.h[gi].add_(b * dg.detach() * f.detach())
|
| 222 |
+
self.theta[gi].clamp_(math.log(self.beta_min),
|
| 223 |
+
math.log(self.beta_max))
|
| 224 |
+
# 速率 (detached): s·β, 广播到 f 形状
|
| 225 |
+
if f.dim() == 2:
|
| 226 |
+
s = s.unsqueeze(1)
|
| 227 |
+
rate = (s * b).detach()
|
| 228 |
+
if graph:
|
| 229 |
+
return w - rate * dg * f, rate
|
| 230 |
+
return w - rate * dg * f, rate
|
| 231 |
+
|
| 232 |
+
w1n, _ = _group(w1, f_w1, d_w1, 0, row_tau=True)
|
| 233 |
+
b1n, _ = _group(b1, f_b1, d_b1, 1, row_tau=False)
|
| 234 |
+
w2n, _ = _group(w2, f_w2, d_w2, 2, row_tau=True)
|
| 235 |
+
b2n, _ = _group(b2, f_b2, d_b2, 3, row_tau=False)
|
| 236 |
+
|
| 237 |
+
self.n_steps += 1
|
| 238 |
+
return [w1n, b1n, w2n, b2n]
|
| 239 |
+
|
| 240 |
+
# ── 便捷封装: 单样本适应 ─────────────────────────────────
|
| 241 |
+
def adapt_sample(self, W, hidden, tgt, V):
|
| 242 |
+
"""hidden: (1,d) SSM 输出; tgt: (n_resp,) 目标 token。"""
|
| 243 |
+
d = self.d_model
|
| 244 |
+
x = torch.tanh(hidden @ W[0].t() + W[1])
|
| 245 |
+
logits = x @ W[2].t() + W[3] # (1, n2)
|
| 246 |
+
logits = logits.view(1, self.n_resp, self.world_vocab)
|
| 247 |
+
p = F.softmax(logits, dim=-1)
|
| 248 |
+
onehot = F.one_hot(tgt, self.world_vocab).float()
|
| 249 |
+
errs = (p - onehot).reshape(-1) # (n2,)
|
| 250 |
+
self.step(W, hidden[0], x[0], errs.detach())
|
| 251 |
+
return logits
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ============================================================
|
| 255 |
+
# 自测 1: 论文 weight-flipping 问题 (arXiv 2401.17401 §3)
|
| 256 |
+
# 20 维特征, 前 15 维权重恒为 0 (该学低步长), 后 5 维每 20 步翻转 ±1
|
| 257 |
+
# (该学高步长)。IDBD 应学到 per-feature 步长分化, 且性能好于标量 SGD。
|
| 258 |
+
# 单行 = 单预测, 与模块 row_tau=True 逻辑一致。
|
| 259 |
+
# ============================================================
|
| 260 |
+
def _idbd_linear_step(w, theta, h, phi, delta, kappa, eta, eps,
|
| 261 |
+
beta_min, beta_max):
|
| 262 |
+
"""单行线性预测的 SwiftTD 一步 (与模块逻辑一致, 供自测复用)。"""
|
| 263 |
+
b = theta.exp().clamp(beta_min, beta_max)
|
| 264 |
+
# 1) meta-θ 更新 (先于 decay/reset — 论文顺序)
|
| 265 |
+
theta.add_(kappa * delta * h * phi)
|
| 266 |
+
# 2) 权重更新 (bound 缩放)
|
| 267 |
+
tau = (b * phi * phi).sum()
|
| 268 |
+
s = 1.0
|
| 269 |
+
decayed = False
|
| 270 |
+
if tau > eta:
|
| 271 |
+
s = eta / tau
|
| 272 |
+
# 3) decay (绝对衰减, 无 β 因子) + 迹重置
|
| 273 |
+
theta.add_(math.log(eps) * phi * phi)
|
| 274 |
+
h.zero_()
|
| 275 |
+
decayed = True
|
| 276 |
+
w.add_(s * b * delta * phi)
|
| 277 |
+
# 4) h 更新
|
| 278 |
+
h.mul_(torch.clamp(1.0 - b * phi * phi, min=0.0))
|
| 279 |
+
h.add_(b * delta * phi)
|
| 280 |
+
theta.clamp_(math.log(beta_min), math.log(beta_max))
|
| 281 |
+
return w, theta, h, decayed
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _self_test_weight_flipping(seed=42, n_steps=50000, kappa=0.05,
|
| 285 |
+
beta_init=0.1, verbose=True):
|
| 286 |
+
torch.manual_seed(seed)
|
| 287 |
+
d = 20
|
| 288 |
+
w_true = torch.zeros(d)
|
| 289 |
+
w_true[15:] = 1.0
|
| 290 |
+
|
| 291 |
+
def run_idbd():
|
| 292 |
+
# 快速追踪域 (论文 Figure 5 探索范围): η=0.5, β_max=1.0
|
| 293 |
+
# 翻转每 20 步一次 → 每步需 ~100% 修正 → β 上限需 > η。
|
| 294 |
+
# MLP head 的收敛域 (β_max=η=0.1) 由自测 3 覆盖。
|
| 295 |
+
beta_min, beta_max, eta, eps = 1e-5, 1.0, 0.5, 0.99
|
| 296 |
+
theta = torch.full((d,), math.log(beta_init))
|
| 297 |
+
h = torch.zeros(d)
|
| 298 |
+
w = torch.zeros(d)
|
| 299 |
+
errs = []
|
| 300 |
+
for t in range(n_steps):
|
| 301 |
+
if t % 20 == 0:
|
| 302 |
+
idx = 15 + (t // 20) % 5
|
| 303 |
+
w_true[idx] *= -1
|
| 304 |
+
phi = torch.randn(d)
|
| 305 |
+
y = (w_true * phi).sum()
|
| 306 |
+
delta = y - (w * phi).sum()
|
| 307 |
+
w, theta, h, _ = _idbd_linear_step(
|
| 308 |
+
w, theta, h, phi, delta, kappa, eta, eps, beta_min, beta_max)
|
| 309 |
+
errs.append(delta.item() ** 2)
|
| 310 |
+
return sum(errs) / len(errs), theta.exp()
|
| 311 |
+
|
| 312 |
+
def run_scalar(alpha):
|
| 313 |
+
w = torch.zeros(d)
|
| 314 |
+
errs = []
|
| 315 |
+
for t in range(n_steps):
|
| 316 |
+
if t % 20 == 0:
|
| 317 |
+
idx = 15 + (t // 20) % 5
|
| 318 |
+
w_true[idx] *= -1
|
| 319 |
+
phi = torch.randn(d)
|
| 320 |
+
y = (w_true * phi).sum()
|
| 321 |
+
delta = y - (w * phi).sum()
|
| 322 |
+
w.add_(alpha * delta * phi)
|
| 323 |
+
errs.append(delta.item() ** 2)
|
| 324 |
+
return sum(errs) / len(errs)
|
| 325 |
+
|
| 326 |
+
mse_idbd, beta_end = run_idbd()
|
| 327 |
+
b_hi = beta_end[15:].mean().item()
|
| 328 |
+
b_lo = beta_end[:15].mean().item()
|
| 329 |
+
# 标量基线: 在非发散区间内扫 α, 取最优
|
| 330 |
+
mse_scalar = min(run_scalar(a) for a in (0.001, 0.005, 0.01, 0.02, 0.05))
|
| 331 |
+
|
| 332 |
+
if verbose:
|
| 333 |
+
print(f" [weight-flipping] MSE: idbd={mse_idbd:.5f} "
|
| 334 |
+
f"scalar_best={mse_scalar:.5f}")
|
| 335 |
+
print(f" β 均值: const dims={b_lo:.5f} (应低), "
|
| 336 |
+
f"flip dims={b_hi:.5f} (应高), 比值={b_hi / max(b_lo, 1e-9):.1f}x")
|
| 337 |
+
|
| 338 |
+
ok = (mse_idbd <= mse_scalar * 1.05 and b_hi > b_lo * 3)
|
| 339 |
+
print(f" {'OK' if ok else 'FAIL'} IDBD 学到 per-feature 步长 "
|
| 340 |
+
f"(误差更低 + 高/低步长分化)")
|
| 341 |
+
return ok
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ============================================================
|
| 345 |
+
# 自测 2: 三件套在 β_init 过大时不发散 (bound + decay + 迹重置)
|
| 346 |
+
# ============================================================
|
| 347 |
+
def _self_test_bound_stability(seed=42, n_steps=20000, beta_init=0.5,
|
| 348 |
+
verbose=True):
|
| 349 |
+
torch.manual_seed(seed)
|
| 350 |
+
d = 20
|
| 351 |
+
w_true = torch.zeros(d)
|
| 352 |
+
w_true[15:] = 1.0
|
| 353 |
+
kappa, eta, eps, beta_min, beta_max = 0.05, 0.5, 0.99, 1e-5, 1.0
|
| 354 |
+
|
| 355 |
+
def run(alpha, use_swift=False):
|
| 356 |
+
theta = torch.full((d,), math.log(beta_init))
|
| 357 |
+
h = torch.zeros(d)
|
| 358 |
+
w = torch.zeros(d)
|
| 359 |
+
errs = []
|
| 360 |
+
for t in range(n_steps):
|
| 361 |
+
if t % 20 == 0:
|
| 362 |
+
idx = 15 + (t // 20) % 5
|
| 363 |
+
w_true[idx] *= -1
|
| 364 |
+
phi = torch.randn(d)
|
| 365 |
+
y = (w_true * phi).sum()
|
| 366 |
+
delta = y - (w * phi).sum()
|
| 367 |
+
if use_swift:
|
| 368 |
+
w, theta, h, _ = _idbd_linear_step(
|
| 369 |
+
w, theta, h, phi, delta, kappa, eta, eps,
|
| 370 |
+
beta_min, beta_max)
|
| 371 |
+
else:
|
| 372 |
+
w.add_(alpha * delta * phi)
|
| 373 |
+
errs.append(delta.item() ** 2)
|
| 374 |
+
if math.isnan(delta.item()):
|
| 375 |
+
return float('inf')
|
| 376 |
+
return sum(errs) / len(errs)
|
| 377 |
+
|
| 378 |
+
mse_swift = run(0.0, use_swift=True)
|
| 379 |
+
mse_scalar = run(beta_init)
|
| 380 |
+
ok = math.isfinite(mse_swift) and mse_swift < mse_scalar
|
| 381 |
+
if verbose:
|
| 382 |
+
print(f" [bound 稳定性] β_init={beta_init}: "
|
| 383 |
+
f"scalar(α={beta_init}) MSE={mse_scalar:.5f} (应发散/爆炸), "
|
| 384 |
+
f"SwiftTD MSE={mse_swift:.5f}")
|
| 385 |
+
print(f" {'OK' if ok else 'FAIL'} bound+decay 防止发散")
|
| 386 |
+
return ok
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ============================================================
|
| 390 |
+
# 自测 3: MLP head 单步形状 + loss 下降 + β 分化
|
| 391 |
+
# ============================================================
|
| 392 |
+
def _self_test_mlp_head():
|
| 393 |
+
torch.manual_seed(0)
|
| 394 |
+
d, V, n_resp = 8, 20, 2
|
| 395 |
+
head = SwiftTDHead(d, V, n_resp, beta_init=0.01)
|
| 396 |
+
w1 = torch.randn(d, d) * 0.5
|
| 397 |
+
b1 = torch.zeros(d)
|
| 398 |
+
w2 = torch.randn(V * n_resp, d) * 0.5
|
| 399 |
+
b2 = torch.zeros(V * n_resp)
|
| 400 |
+
W = [w1, b1, w2, b2]
|
| 401 |
+
hidden = torch.randn(1, d)
|
| 402 |
+
tgt = torch.tensor([3, 17])
|
| 403 |
+
|
| 404 |
+
def loss():
|
| 405 |
+
x = torch.tanh(hidden @ w1.t() + b1)
|
| 406 |
+
logits = (x @ w2.t() + b2).view(1, n_resp, V)
|
| 407 |
+
return F.cross_entropy(logits.view(-1, V), tgt.view(-1))
|
| 408 |
+
|
| 409 |
+
l0 = loss().item()
|
| 410 |
+
for _ in range(30):
|
| 411 |
+
x = torch.tanh(hidden @ w1.t() + b1)
|
| 412 |
+
logits = (x @ w2.t() + b2).view(1, n_resp, V)
|
| 413 |
+
p = F.softmax(logits, dim=-1)
|
| 414 |
+
onehot = F.one_hot(tgt, V).float()
|
| 415 |
+
errs = (p - onehot).reshape(-1)
|
| 416 |
+
head.step(W, hidden[0], x[0], errs.detach())
|
| 417 |
+
l1 = loss().item()
|
| 418 |
+
stats = head.step_size_stats()
|
| 419 |
+
ok = l1 < l0 and stats['beta_mean'] > 0
|
| 420 |
+
print(f" {'OK' if ok else 'FAIL'} MLP head 30 步: loss "
|
| 421 |
+
f"{l0:.4f} → {l1:.4f}, β_mean={stats['beta_mean']:.4f}, "
|
| 422 |
+
f"bound 触发={stats['n_bound_triggers']}, decay={stats['n_decays']}")
|
| 423 |
+
return ok
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _self_test():
|
| 427 |
+
ok1 = _self_test_weight_flipping()
|
| 428 |
+
ok2 = _self_test_bound_stability()
|
| 429 |
+
ok3 = _self_test_mlp_head()
|
| 430 |
+
print(f"\n {int(ok1) + int(ok2) + int(ok3)}/3 passed")
|
| 431 |
+
return ok1 and ok2 and ok3
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
if __name__ == "__main__":
|
| 435 |
+
_self_test()
|
hibs_lnn/transition_model.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""transition_model.py — OaK 第③条的第一个缺失件。
|
| 2 |
+
|
| 3 |
+
## 为什么需要它
|
| 4 |
+
|
| 5 |
+
OaK feature #3:
|
| 6 |
+
feature -> subproblem -> solution -> **transition model** -> planning
|
| 7 |
+
|
| 8 |
+
我们已经有 feature / subproblem(6 域 / 60 细区间)/ solution(训练),
|
| 9 |
+
但**没有转移模型也无法规划** —— 这是 leo 那张映射表里真正缺的东西。
|
| 10 |
+
|
| 11 |
+
## 定义 (与 lm4 的数据结构对齐)
|
| 12 |
+
|
| 13 |
+
能力状态 a_t = acc_matrix[t] (在 6 个域上的准确率画像, 6 维)
|
| 14 |
+
动作 u_t = 第 t 步训练了哪个域 (one-hot, 6 维)
|
| 15 |
+
转移 T : (a_t, u_t, ctx) -> Δa, a_{t+1} = a_t + Δa
|
| 16 |
+
|
| 17 |
+
学 **Δa** 而不是 a_{t+1}, 因为这样 "copy 基线" 就是 Δa≡0,
|
| 18 |
+
模型有没有学到东西一目了然。
|
| 19 |
+
|
| 20 |
+
## 输入特征
|
| 21 |
+
|
| 22 |
+
x = [ a_t (6) | onehot(u_t) (6) | n_visits (6) | t/n_rounds (1) ] = 19 维
|
| 23 |
+
|
| 24 |
+
## 基线 (必须打赢才有意义)
|
| 25 |
+
|
| 26 |
+
copy : Δa ≡ 0 实测 MAE 0.2986
|
| 27 |
+
constant : Δa ≡ mean(Δa|u) 实测 MAE 0.2723
|
| 28 |
+
|
| 29 |
+
用 **ridge** 而不是 MLP: 样本量 ~1.8K, 参数少、可复现、无调参空间
|
| 30 |
+
(符合 leo 的"manual > framework"偏好)。
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TransitionModel:
|
| 38 |
+
"""T(a_t, u_t, ctx) -> Δa 的线性(脊)转移模型。
|
| 39 |
+
|
| 40 |
+
参数
|
| 41 |
+
----
|
| 42 |
+
lam : 岭正则强度
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, lam=1e-2):
|
| 46 |
+
self.lam = float(lam)
|
| 47 |
+
self.W = None # (fdim, n_dom)
|
| 48 |
+
self.n_dom = None
|
| 49 |
+
self.fitted = False
|
| 50 |
+
|
| 51 |
+
# ───────── 特征 ─────────
|
| 52 |
+
@staticmethod
|
| 53 |
+
def feat(a, u, n_visits, t_frac):
|
| 54 |
+
oh = np.zeros(len(a))
|
| 55 |
+
if 0 <= u < len(a):
|
| 56 |
+
oh[u] = 1.0
|
| 57 |
+
return np.concatenate([a, oh, n_visits, [t_frac]])
|
| 58 |
+
|
| 59 |
+
def build_xy(self, records):
|
| 60 |
+
"""records: [(acc_matrix, dom_seq)] -> X, Y, groups"""
|
| 61 |
+
X, Y, G = [], [], []
|
| 62 |
+
for gi, (M, seq) in enumerate(records):
|
| 63 |
+
M = np.asarray(M, dtype=float)
|
| 64 |
+
n_dom = M.shape[1]
|
| 65 |
+
nvis = np.zeros(n_dom)
|
| 66 |
+
for t, u in enumerate(seq):
|
| 67 |
+
if t + 1 >= M.shape[0]:
|
| 68 |
+
break
|
| 69 |
+
if np.isnan(M[t]).any() or np.isnan(M[t + 1]).any():
|
| 70 |
+
nvis[u] += 1
|
| 71 |
+
continue
|
| 72 |
+
X.append(self.feat(M[t], int(u), nvis.copy(), t / max(1, len(seq))))
|
| 73 |
+
Y.append(M[t + 1] - M[t])
|
| 74 |
+
G.append(gi)
|
| 75 |
+
nvis[u] += 1
|
| 76 |
+
return (np.array(X), np.array(Y), np.array(G)) if X else (
|
| 77 |
+
np.zeros((0, 19)), np.zeros((0, 6)), np.zeros(0))
|
| 78 |
+
|
| 79 |
+
# ───────── 拟合 ─────────
|
| 80 |
+
def fit(self, records):
|
| 81 |
+
X, Y, _ = self.build_xy(records)
|
| 82 |
+
if len(X) == 0:
|
| 83 |
+
raise ValueError("没有可用样本")
|
| 84 |
+
self.n_dom = int(Y.shape[1])
|
| 85 |
+
# 只对 Δa 做岭回归 (含截距)
|
| 86 |
+
X1 = np.hstack([X, np.ones((len(X), 1))])
|
| 87 |
+
A = X1.T @ X1 + self.lam * np.eye(X1.shape[1])
|
| 88 |
+
self.W = np.linalg.solve(A, X1.T @ Y)
|
| 89 |
+
self.fitted = True
|
| 90 |
+
return self
|
| 91 |
+
|
| 92 |
+
def predict_delta(self, a, u, n_visits, t_frac):
|
| 93 |
+
x = np.append(self.feat(a, u, n_visits, t_frac), 1.0)
|
| 94 |
+
return x @ self.W
|
| 95 |
+
|
| 96 |
+
# ───────── 规划: 用 T 前向搜索选动作序列 ─────────
|
| 97 |
+
def plan(self, a0, horizon, beam=8, n_dom=None):
|
| 98 |
+
"""beam search: 在 T 的预测下最大化末端平均准确率。
|
| 99 |
+
|
| 100 |
+
这是 OaK 的 "planning" —— 用学到的转移模型做前向搜索,
|
| 101 |
+
而不是靠外生给定的 schedule。
|
| 102 |
+
"""
|
| 103 |
+
n_dom = int(n_dom if n_dom is not None else (self.n_dom or 0))
|
| 104 |
+
a0 = np.asarray(a0, dtype=float)
|
| 105 |
+
# beam 元素: (score, a, nvis, seq)
|
| 106 |
+
beams = [(float(np.nanmean(a0)), a0.copy(), np.zeros(n_dom), [])]
|
| 107 |
+
for t in range(horizon):
|
| 108 |
+
cand = []
|
| 109 |
+
for _, a, nvis, seq in beams:
|
| 110 |
+
for u in range(n_dom):
|
| 111 |
+
d = self.predict_delta(a, u, nvis, t / max(1, horizon))
|
| 112 |
+
a2 = a + d
|
| 113 |
+
nv2 = nvis.copy()
|
| 114 |
+
nv2[u] += 1
|
| 115 |
+
cand.append((float(np.nanmean(a2)), a2, nv2, seq + [u]))
|
| 116 |
+
cand.sort(key=lambda r: -r[0])
|
| 117 |
+
beams = cand[:beam]
|
| 118 |
+
return beams[0][3]
|
hibs_lnn/uncertainty_gate.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""uncertainty_gate.py — 覆盖度 + 不确定性门控, 与带不确定性的 rollout。
|
| 2 |
+
|
| 3 |
+
## 为什么需要
|
| 4 |
+
|
| 5 |
+
用户指出: 被动观测数据学出的 transition model **只知道数据覆盖过的 (s,a)**。
|
| 6 |
+
若 policy 提出训练数据里没出现过的动作, `T(s,a_new)` 可能完全是幻觉。
|
| 7 |
+
这正是我们实测到的: 转移模型对 `[2,2,2,2,2]` 预测 **0.9582**, 真实执行 **0.4357**。
|
| 8 |
+
|
| 9 |
+
于是把「覆盖度门控」升级为两个条件:
|
| 10 |
+
|
| 11 |
+
allow(s,a) = [C(s,a) > tau_C] ∧ [U_T(s,a) < tau_U]
|
| 12 |
+
|
| 13 |
+
C(s,a) : 该 (状态, 动作) 有多少数据支持 (访问计数)
|
| 14 |
+
U_T : 转移预测的不确定性
|
| 15 |
+
|
| 16 |
+
## U_T 怎么估 —— 用集成分歧, 不用拍脑袋
|
| 17 |
+
|
| 18 |
+
单个模型无法报出自己有多不确定。标准做法是训练 **K 个 transition model**
|
| 19 |
+
(对数据做 bootstrap), 用它们的**预测分歧**作为 U_T:
|
| 20 |
+
|
| 21 |
+
U_T(s,a) = mean_i || T_i(s,a) − mean_j T_j(s,a) ||
|
| 22 |
+
|
| 23 |
+
分歧大 = 该区域数据稀疏 / 模型没学到 → 不可信。
|
| 24 |
+
这与 coverage 互补: coverage 看「来过没有」, U_T 看「学稳了没有」。
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TransitionEnsemble:
|
| 32 |
+
"""K 个 bootstrap 转移模型的集成, 用预测分歧估计不确定性。
|
| 33 |
+
|
| 34 |
+
T_i: x_{t+1} = x_t + Δ_i, Δ_i = W_i @ φ(x_t, u)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, n_models=5, lam=1e-2, seed=0):
|
| 38 |
+
self.K = int(n_models)
|
| 39 |
+
self.lam = float(lam)
|
| 40 |
+
self.rng = np.random.default_rng(seed)
|
| 41 |
+
self.Ws = None
|
| 42 |
+
self.n_dom = None
|
| 43 |
+
self.dim_x = None
|
| 44 |
+
self.fdim = None
|
| 45 |
+
|
| 46 |
+
# ───────── 特征 ─────────
|
| 47 |
+
@staticmethod
|
| 48 |
+
def feat(x, u, n_dom, visits):
|
| 49 |
+
"""特征 = [x, onehot(u)]。
|
| 50 |
+
|
| 51 |
+
★ **不要加入常数列** (曾写成 concat([x, oh, [mean(visits)]]) 而
|
| 52 |
+
mean(visits)≡1 与截距完全共线): 实测条件数被推到 **1.24e16**(近乎奇异),
|
| 53 |
+
后果是双向的 ——
|
| 54 |
+
① 预测全错: Δ 估计 [35.6, 32.5, 10.3, 5.7], 真值应 ~0.3
|
| 55 |
+
② 不确定性失效: U_T 对所有动作都 ≈3.27, 完全不区分噪声
|
| 56 |
+
去掉后: Δ 恢复 [0.30, 0, 0, 0], U_T 动作0(噪声.02)=0.0018 vs
|
| 57 |
+
动作2(噪声.55)=0.0399 -> **22× 区分**。
|
| 58 |
+
"""
|
| 59 |
+
oh = np.zeros(n_dom)
|
| 60 |
+
if 0 <= u < n_dom:
|
| 61 |
+
oh[u] = 1.0
|
| 62 |
+
return np.concatenate([x, oh])
|
| 63 |
+
|
| 64 |
+
# ───────── 拟合 (bootstrap) ─────────
|
| 65 |
+
def fit(self, X, Y, n_dom, visits_dim=None):
|
| 66 |
+
"""X: (N, dim_x+1) = [x_t, u]; Y: (N, dim_x) = **绝对** x_{t+1}。
|
| 67 |
+
|
| 68 |
+
★ 接口约定: fit 与 predict **都用绝对下一状态**, 内部学 Δ 再加回 x。
|
| 69 |
+
曾因约定不一致出过 bug —— 测试把绝对状态当 Δ 喂进来, 在质心 (x=0)
|
| 70 |
+
处两者巧合相等, 掩盖了问题; 一旦远离质心 (R0=[4.95,4.55,1.36,1.22])
|
| 71 |
+
就露出 `Δ=x+fx(u)` 的错误。现在接口自洽, 无法再误用。
|
| 72 |
+
"""
|
| 73 |
+
self.dim_x = Y.shape[1]
|
| 74 |
+
self.n_dom = int(n_dom)
|
| 75 |
+
self.fdim = self.dim_x + self.n_dom # 无截距列
|
| 76 |
+
N = len(X)
|
| 77 |
+
D = Y - X[:, :self.dim_x] # 内部转成 Δ
|
| 78 |
+
self.Ws = []
|
| 79 |
+
for k in range(self.K):
|
| 80 |
+
idx = self.rng.integers(0, N, size=N) # bootstrap 重采样
|
| 81 |
+
F = np.array([self.feat(X[i, :self.dim_x], int(X[i, -1]),
|
| 82 |
+
self.n_dom, np.ones(self.n_dom))
|
| 83 |
+
for i in idx])
|
| 84 |
+
# ★ 不加截距列。onehot(u) 的各列之和恒等于 1, 与截距**完全共线**
|
| 85 |
+
# (Σ onehot = ones)。加了以后条件数 8.9e15, 解只在数据质心附近
|
| 86 |
+
# 碰巧正确, 远离即炸: 实测在区域中心 (R0=[4.95,4.55,1.36,1.22])
|
| 87 |
+
# 给出 Δ=[92.7, 96.4, 25.8, 23.7], 真值应为 [0.30, 0, 0, 0]。
|
| 88 |
+
# 去掉后设计矩阵满秩 (rank = dim_x + n_dom)。
|
| 89 |
+
A = F.T @ F + self.lam * np.eye(F.shape[1])
|
| 90 |
+
self.Ws.append(np.linalg.solve(A, F.T @ D[idx]))
|
| 91 |
+
return self
|
| 92 |
+
|
| 93 |
+
# ───────── 预测 + 分歧 ─────────
|
| 94 |
+
def predict(self, x, u, visits):
|
| 95 |
+
"""返回 (**绝对**下一状态的集成均值, 分歧 U_T)。"""
|
| 96 |
+
if self.Ws is None:
|
| 97 |
+
return np.asarray(x, dtype=float).copy(), float("inf")
|
| 98 |
+
x = np.asarray(x, dtype=float)
|
| 99 |
+
f = self.feat(x, u, self.n_dom, visits) # 与 fit 一致, 无截距
|
| 100 |
+
preds = np.array([f @ W for W in self.Ws]) # (K, dim_x) = Δ
|
| 101 |
+
mean_d = preds.mean(0)
|
| 102 |
+
unc = float(np.mean(np.linalg.norm(preds - mean_d, axis=1)))
|
| 103 |
+
return x + mean_d, unc # 绝对下一状态
|
| 104 |
+
|
| 105 |
+
def rollout(self, x0, actions, visits, gate=None):
|
| 106 |
+
"""多步 rollout: T^k(x0, a_{t:t+k})。返回 (轨迹, 逐步不确定性)。
|
| 107 |
+
|
| 108 |
+
gate 非 None 时逐步做可信度检查; 一旦某步不可信��**中止并标记**,
|
| 109 |
+
而不是继续外推 (Q8: 无覆盖 → 预测不可靠)。
|
| 110 |
+
"""
|
| 111 |
+
x = np.asarray(x0, dtype=float).copy()
|
| 112 |
+
traj, uncs, aborted_at = [x.copy()], [], None
|
| 113 |
+
for t, u in enumerate(actions):
|
| 114 |
+
if gate is not None:
|
| 115 |
+
ok, why = gate.allow(x, u, visits)
|
| 116 |
+
if not ok:
|
| 117 |
+
aborted_at = (t, why)
|
| 118 |
+
break
|
| 119 |
+
x_next, unc = self.predict(x, u, visits) # 已是绝对状态
|
| 120 |
+
if not np.isfinite(unc):
|
| 121 |
+
aborted_at = (t, "unc_inf")
|
| 122 |
+
break
|
| 123 |
+
x = x_next
|
| 124 |
+
traj.append(x.copy())
|
| 125 |
+
uncs.append(unc)
|
| 126 |
+
return np.array(traj), np.array(uncs), aborted_at
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class TabularTransition:
|
| 130 |
+
"""计数式转移模型 —— 离散状态空间上的**可表达**动力学。
|
| 131 |
+
|
| 132 |
+
## 为什么必须加它 (实测动机)
|
| 133 |
+
|
| 134 |
+
`TransitionEnsemble` 是**线性 ridge 回归** (`[state, onehot(action)] -> next`),
|
| 135 |
+
对**离散、条件性、非线性**的动力学表达能力不足。实测在 KeyDoor 这类
|
| 136 |
+
链式任务上: 从区域中心出发, **四个动作预测到同一个下一状态** ->
|
| 137 |
+
转移图退化成几乎无边 -> `OptionManager` 发现 0 个 option
|
| 138 |
+
(即使任务里有明显的"拿钥匙→开门"技能链)。
|
| 139 |
+
|
| 140 |
+
★ 结论: option 发现失败**不是 option 逻辑的问题, 而是它依赖的世界模型
|
| 141 |
+
表达不了结构化动力学**。这里是那个缺口的补丁。
|
| 142 |
+
|
| 143 |
+
`predict(x, a)` 与 `TransitionEnsemble` **接口一致**, 可直接替换:
|
| 144 |
+
返回 (最可能的下一状态, 不确定性)
|
| 145 |
+
不确定性 = 1 − max_a' P(s'|s,a) —— 即"这个转移有多确定"。
|
| 146 |
+
"""
|
| 147 |
+
def __init__(self, lam=1.0):
|
| 148 |
+
self.counts = {} # (s_key, a) -> {s'_key: n}
|
| 149 |
+
self.state_map = {} # s_key -> 原状态向量
|
| 150 |
+
self.lam = float(lam)
|
| 151 |
+
self.dim_x = None
|
| 152 |
+
self.n_dom = None
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def _key(x):
|
| 156 |
+
return tuple(np.round(np.asarray(x, dtype=float), 6).tolist())
|
| 157 |
+
|
| 158 |
+
def fit(self, X, Y, n_dom, visits_dim=None):
|
| 159 |
+
X = np.asarray(X, dtype=float); Y = np.asarray(Y, dtype=float)
|
| 160 |
+
self.dim_x = Y.shape[1]; self.n_dom = int(n_dom)
|
| 161 |
+
self.counts, self.state_map = {}, {}
|
| 162 |
+
for i in range(len(X)):
|
| 163 |
+
x, u, y = X[i, :self.dim_x], int(X[i, -1]), Y[i]
|
| 164 |
+
sk, yk = self._key(x), self._key(y)
|
| 165 |
+
self.state_map[sk] = x
|
| 166 |
+
self.counts.setdefault((sk, u), {})
|
| 167 |
+
self.counts[(sk, u)][yk] = self.counts[(sk, u)].get(yk, 0) + 1
|
| 168 |
+
return self
|
| 169 |
+
|
| 170 |
+
def predict(self, x, u, visits=None):
|
| 171 |
+
"""返回 (最可能下一状态向量, 不确定性 1−P_max)。未见过的 (s,a) -> 原状态 + unc=inf。"""
|
| 172 |
+
sk = self._key(x)
|
| 173 |
+
d = self.counts.get((sk, int(u)))
|
| 174 |
+
if not d:
|
| 175 |
+
return np.asarray(x, dtype=float).copy(), float("inf")
|
| 176 |
+
tot = sum(d.values())
|
| 177 |
+
yk, c = max(d.items(), key=lambda kv: kv[1])
|
| 178 |
+
return np.array(yk, dtype=float), float(1.0 - c / tot)
|
| 179 |
+
|
| 180 |
+
def rollout(self, x0, actions, visits=None, gate=None):
|
| 181 |
+
x = np.asarray(x0, dtype=float).copy()
|
| 182 |
+
traj, uncs, aborted = [x.copy()], [], None
|
| 183 |
+
for t, u in enumerate(actions):
|
| 184 |
+
if gate is not None:
|
| 185 |
+
ok, why = gate.allow(x, u, visits if visits is not None else np.ones(1))
|
| 186 |
+
if not ok:
|
| 187 |
+
aborted = (t, why); break
|
| 188 |
+
xn, unc = self.predict(x, u, visits)
|
| 189 |
+
if not np.isfinite(unc):
|
| 190 |
+
aborted = (t, "unc_inf"); break
|
| 191 |
+
x = xn; traj.append(x.copy()); uncs.append(unc)
|
| 192 |
+
return np.array(traj), np.array(uncs), aborted
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class UncertaintyGate:
|
| 196 |
+
"""allow(s,a) = [C(s,a) > tau_C] ∧ [U_T(s,a) < tau_U]
|
| 197 |
+
|
| 198 |
+
参数
|
| 199 |
+
----
|
| 200 |
+
tau_C : 覆盖度阈值 (访问计数的分位/绝对阈值)
|
| 201 |
+
tau_U : 不确定性阈值 (集成分歧)
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
def __init__(self, tau_C=1.0, tau_U=None, adaptive=True):
|
| 205 |
+
self.tau_C = float(tau_C)
|
| 206 |
+
self.tau_U = tau_U
|
| 207 |
+
self.adaptive = adaptive
|
| 208 |
+
self.unc_hist = []
|
| 209 |
+
|
| 210 |
+
def calibrate(self, uncs):
|
| 211 |
+
"""用一批**在训练数据上**的集成分歧定阈值 (取 90 分位)。
|
| 212 |
+
|
| 213 |
+
自适应是必要的: 分歧的量纲随任务变化, 硬编码阈值不可移植。
|
| 214 |
+
"""
|
| 215 |
+
u = np.asarray(uncs, dtype=float)
|
| 216 |
+
u = u[np.isfinite(u)]
|
| 217 |
+
if u.size:
|
| 218 |
+
self.tau_U = float(np.quantile(u, 0.90))
|
| 219 |
+
return self.tau_U
|
| 220 |
+
|
| 221 |
+
def allow(self, x, u, visits):
|
| 222 |
+
"""返回 (是否允许, 原因)。"""
|
| 223 |
+
c = float(visits[u]) if u < len(visits) else 0.0
|
| 224 |
+
if c <= self.tau_C:
|
| 225 |
+
return False, "low_coverage"
|
| 226 |
+
return True, "ok" # 不确定性检查在 rollout 内逐步做
|
| 227 |
+
|
| 228 |
+
def allow_with_unc(self, x, u, visits, unc):
|
| 229 |
+
"""含不确定性判定的完整版。"""
|
| 230 |
+
ok, why = self.allow(x, u, visits)
|
| 231 |
+
if not ok:
|
| 232 |
+
return False, why
|
| 233 |
+
if self.tau_U is not None and np.isfinite(unc) and unc > self.tau_U:
|
| 234 |
+
return False, "high_uncertainty"
|
| 235 |
+
return True, "ok"
|
| 236 |
+
|
| 237 |
+
def stats(self):
|
| 238 |
+
return {"tau_C": self.tau_C,
|
| 239 |
+
"tau_U": None if self.tau_U is None else round(self.tau_U, 6)}
|
hibs_lnn/value_proposer.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""value_proposer.py — 价值函数提议器 (从 LMT-twister V35.19 移植, 适配连续物理区间)。
|
| 2 |
+
|
| 3 |
+
## 来源
|
| 4 |
+
|
| 5 |
+
LMT-twister 家族已把这条线验证到生产级 (skill: lmt-twister-experiment):
|
| 6 |
+
V35.19 价值函数提议器 (用户理论): 简约性 + 自洽性 + 覆盖增量
|
| 7 |
+
V35.20 价值函数表示迁移最优 (0.0869 > 外部课程 0.0742)
|
| 8 |
+
V35.21 三 seed 确认 (0.0850±0.002), S5 内直接训练 scratch +2.05pp
|
| 9 |
+
V35.22 价值函数 = 稳定器 (方差缩 3 倍)
|
| 10 |
+
LM1 生产级: 提议器每步自主生成数据
|
| 11 |
+
|
| 12 |
+
原式 (V35.19):
|
| 13 |
+
V(p) = λ_sim·sim(p) + λ_con·con(p) + λ_cov/(1+freq(p))
|
| 14 |
+
sim(p) = 1 - max_{q∈已学} struct_sim(p,q) # 简约性: 不可归约度
|
| 15 |
+
con(p) = 1 - 矛盾度 # 自洽性
|
| 16 |
+
cov(p) = 1/(1+freq(p)) # 覆盖增量
|
| 17 |
+
|
| 18 |
+
## 本移植
|
| 19 |
+
|
| 20 |
+
lm1 的"结构 p"是离散排列; lm4/lm5 的"结构"是**连续的物理区间**,
|
| 21 |
+
所以相似度换成描述子空间里的距离, 并新增一项**真实反馈** (V35 里由
|
| 22 |
+
CausalProposer.feedback_value 承担)。
|
| 23 |
+
|
| 24 |
+
V(p) = λ_sim·sim(p) + λ_con·con(p) + λ_cov·cov(p) + λ_fb·fb(p)
|
| 25 |
+
|
| 26 |
+
sim(p) = 1 - max_{q∈已学} exp(-‖z_p - z_q‖ / σ) # 物理上离已学区间多远
|
| 27 |
+
con(p) = 自洽性 = 该区间与已发现因果结构的吻合度 (由调用方注入)
|
| 28 |
+
cov(p) = 1/(1+freq(p)) # 训练越少越值得学
|
| 29 |
+
fb(p) = 1 - acc(p) # 真实反馈: 越不准越值得学
|
| 30 |
+
|
| 31 |
+
## 关键坑 (V35.19 实测, 必须遵守)
|
| 32 |
+
|
| 33 |
+
1. **候选池必须远大于提议数**, 否则价值函数被抹平 (v1: 池 18 个被全覆盖 ->
|
| 34 |
+
提议顺序无关 -> λ 扫描零效果)。本实现默认要求 len(pool) >> k·轮数。
|
| 35 |
+
2. **λ_cov 倒 U 型**, 平衡点 ≈ 1:1:1。默认 (1,1,1,1)。
|
| 36 |
+
3. **覆盖项必须真的有频率差异**, 否则退化成随机 (v2 教训: 高频重复 ≠ 均匀覆盖)。
|
| 37 |
+
"""
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class RegimeProposer:
|
| 44 |
+
"""按价值函数从候选区间池里采样"下一个学哪个物理区间"。
|
| 45 |
+
|
| 46 |
+
参数
|
| 47 |
+
----
|
| 48 |
+
desc : (N, D) 每个候选区间的物理描述子 (如 [log_dens, logL, absmaglat]).
|
| 49 |
+
内部做 z-score, 保证各维等权。
|
| 50 |
+
groups : (N,) 每个候选区间映射到哪个**评估域** (用于汇总/报告, 不参与打分)。
|
| 51 |
+
lam : (λ_sim, λ_con, λ_cov, λ_fb)
|
| 52 |
+
sigma : 描述子空间里"多远算不可归约"的尺度 (z-score 单位, 默认 0.5)
|
| 53 |
+
tau : softmax 温度, 越小越贪婪
|
| 54 |
+
k : 每次提议几个
|
| 55 |
+
seed : 采样随机种子
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(self, desc, groups=None, lam=(1.0, 1.0, 1.0, 1.0),
|
| 59 |
+
sigma=None, tau=0.5, k=2, seed=0):
|
| 60 |
+
desc = np.asarray(desc, dtype=np.float64)
|
| 61 |
+
self.n = len(desc)
|
| 62 |
+
mu = desc.mean(0)
|
| 63 |
+
sd = desc.std(0)
|
| 64 |
+
sd[sd < 1e-9] = 1.0
|
| 65 |
+
self.z = (desc - mu) / sd
|
| 66 |
+
# ★ sigma 默认由数据决定, 不能用固定 0.5。
|
| 67 |
+
# 低维描述子 (1-3 维) 下候选间距离量级很小, 固定 sigma=0.5 会让
|
| 68 |
+
# exp(-d/sigma) ≈ 0 对所有候选成立 -> sim(p) ≡ 1.0 -> 简约性项死亡
|
| 69 |
+
# (实测 signature: value 与 value-nofb 提议序列逐位相同)。
|
| 70 |
+
# 取"最近邻距离的中位数"当尺度, 保证核在真实分布上有区分度。
|
| 71 |
+
if sigma is None:
|
| 72 |
+
d = np.linalg.norm(self.z[:, None, :] - self.z[None, :, :], axis=-1)
|
| 73 |
+
np.fill_diagonal(d, np.inf)
|
| 74 |
+
sigma = float(np.median(d.min(axis=1)))
|
| 75 |
+
sigma = max(sigma, 1e-3)
|
| 76 |
+
self.sigma = float(sigma)
|
| 77 |
+
self.groups = (np.asarray(groups) if groups is not None
|
| 78 |
+
else np.zeros(self.n, dtype=int))
|
| 79 |
+
self.lam = tuple(float(x) for x in lam)
|
| 80 |
+
self.tau = float(tau)
|
| 81 |
+
self.k = int(k)
|
| 82 |
+
self.rng = np.random.default_rng(seed)
|
| 83 |
+
# 状态
|
| 84 |
+
self._con = None # 自洽性分数 (可注入)
|
| 85 |
+
self.freq = np.zeros(self.n, dtype=np.float64) # 被提议次数
|
| 86 |
+
self.learned = [] # 已学区间索引
|
| 87 |
+
self.acc = np.full(self.n, np.nan) # 各区间的实测准确率
|
| 88 |
+
self.trace = [] # 每轮提议记录
|
| 89 |
+
|
| 90 |
+
# ---------------- 三项 + 反馈 ----------------
|
| 91 |
+
def sim(self, i):
|
| 92 |
+
"""简约性: 1 - 与已学区间的最大相似度 (物理上离得越远越值得学)。"""
|
| 93 |
+
if not self.learned:
|
| 94 |
+
return 1.0
|
| 95 |
+
d = np.linalg.norm(self.z[self.learned] - self.z[i], axis=1)
|
| 96 |
+
return float(1.0 - np.exp(-d.min() / self.sigma))
|
| 97 |
+
|
| 98 |
+
def cov(self, i):
|
| 99 |
+
"""覆盖增量: 越少被训练越值得学。"""
|
| 100 |
+
return 1.0 / (1.0 + self.freq[i])
|
| 101 |
+
|
| 102 |
+
def con(self, i):
|
| 103 |
+
"""自洽性 (V35.19 三项之一) —— 由调用方 set_consistency() 注入。
|
| 104 |
+
|
| 105 |
+
**没注入时返回恒定 1.0, 该维对排序零贡献** ��— 这是本轮踩过的坑:
|
| 106 |
+
忘了注入 -> 自洽性项死亡 -> 价值函数退化成覆盖均匀化采样器。
|
| 107 |
+
诊断: stats() 里的 con_std; 若为 0 说明这一项是死的。
|
| 108 |
+
"""
|
| 109 |
+
return float(self._con[i]) if self._con is not None else 1.0
|
| 110 |
+
|
| 111 |
+
def fb(self, i):
|
| 112 |
+
"""真实反馈: 越不准越值得学 (持续学习那一环)。"""
|
| 113 |
+
a = self.acc[i]
|
| 114 |
+
if not np.isfinite(a):
|
| 115 |
+
return 1.0 # 没测过 = 最有价值
|
| 116 |
+
return float(1.0 - a)
|
| 117 |
+
|
| 118 |
+
def value(self, i):
|
| 119 |
+
ls, lc, lv, lf = self.lam
|
| 120 |
+
return (ls * self.sim(i) + lc * self.con(i)
|
| 121 |
+
+ lv * self.cov(i) + lf * self.fb(i))
|
| 122 |
+
|
| 123 |
+
# ---------------- 外部注入 ----------------
|
| 124 |
+
def set_consistency(self, con):
|
| 125 |
+
"""注入自洽性分数 (长度 N)。对 lm4: 每个区间与已发现因果边的吻合度。"""
|
| 126 |
+
self._con = np.asarray(con, dtype=np.float64)
|
| 127 |
+
|
| 128 |
+
def observe(self, idx, acc, learned=True):
|
| 129 |
+
"""记录一轮的结果: 该区间的实测准确率 (+ 标记为已学)。"""
|
| 130 |
+
self.acc[idx] = float(acc)
|
| 131 |
+
if learned and idx not in self.learned:
|
| 132 |
+
self.learned.append(int(idx))
|
| 133 |
+
|
| 134 |
+
# ---------------- 提议 ----------------
|
| 135 |
+
def propose(self, k=None):
|
| 136 |
+
"""按 V 的 softmax 采样 k 个不重复候选 (采样而非 argmax: 保留探索)。"""
|
| 137 |
+
k = k or self.k
|
| 138 |
+
v = np.array([self.value(i) for i in range(self.n)])
|
| 139 |
+
# 数值稳定 + 温度
|
| 140 |
+
v = v - v.max()
|
| 141 |
+
p = np.exp(v / max(1e-9, self.tau))
|
| 142 |
+
p = p / p.sum()
|
| 143 |
+
kk = min(k, self.n)
|
| 144 |
+
idx = self.rng.choice(self.n, size=kk, replace=False, p=p)
|
| 145 |
+
for i in idx:
|
| 146 |
+
self.freq[i] += 1
|
| 147 |
+
self.trace.append(list(map(int, idx)))
|
| 148 |
+
return list(map(int, idx))
|
| 149 |
+
|
| 150 |
+
def pick_random(self, k=None):
|
| 151 |
+
"""随机对照臂。"""
|
| 152 |
+
k = k or self.k
|
| 153 |
+
idx = self.rng.choice(self.n, size=min(k, self.n), replace=False)
|
| 154 |
+
for i in idx:
|
| 155 |
+
self.freq[i] += 1
|
| 156 |
+
self.trace.append(list(map(int, idx)))
|
| 157 |
+
return list(map(int, idx))
|
| 158 |
+
|
| 159 |
+
# ---------------- 诊断 ----------------
|
| 160 |
+
def stats(self):
|
| 161 |
+
v = np.array([self.value(i) for i in range(self.n)])
|
| 162 |
+
# ★ 各项的**区分度**诊断: 标准差为 0 = 该项是死的 (对排序零贡献)
|
| 163 |
+
_sim = np.array([self.sim(i) for i in range(self.n)])
|
| 164 |
+
_con = np.array([self.con(i) for i in range(self.n)])
|
| 165 |
+
_cov = np.array([self.cov(i) for i in range(self.n)])
|
| 166 |
+
_fb = np.array([self.fb(i) for i in range(self.n)])
|
| 167 |
+
return {
|
| 168 |
+
"n_candidates": int(self.n),
|
| 169 |
+
"n_learned": len(self.learned),
|
| 170 |
+
"n_proposed_total": int(self.freq.sum()),
|
| 171 |
+
"freq_nonzero": int((self.freq > 0).sum()),
|
| 172 |
+
"value_mean": float(v.mean()),
|
| 173 |
+
"value_std": float(v.std()),
|
| 174 |
+
"value_cv": float(v.std() / max(1e-9, abs(v.mean()))),
|
| 175 |
+
"covered_frac": float((self.freq > 0).mean()),
|
| 176 |
+
"sigma": float(self.sigma),
|
| 177 |
+
# 各项区分度: 0 = 该项死了 (本轮踩过的坑)
|
| 178 |
+
"term_std": {"sim": float(_sim.std()), "con": float(_con.std()),
|
| 179 |
+
"cov": float(_cov.std()), "fb": float(_fb.std())},
|
| 180 |
+
}
|
manifests/delta_compile_report.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Delta Compile Report
|
| 2 |
+
|
| 3 |
+
- generated_at: `2026-09-06T05:17:16+00:00`
|
| 4 |
+
- stale_pages: `0`
|
| 5 |
+
- new_raw_sources: `1`
|
| 6 |
+
- drafts_written: `0`
|
| 7 |
+
|
| 8 |
+
> This report suggests recompilation work. It does not auto-overwrite wiki content.
|
| 9 |
+
|
| 10 |
+
## Stale Pages
|
| 11 |
+
|
| 12 |
+
- none
|
| 13 |
+
|
| 14 |
+
## New Raw Sources
|
| 15 |
+
|
| 16 |
+
- `docs/wiki/log.md` -> suggested page `docs/wiki/log.md`
|
| 17 |
+
-
|
| 18 |
+
|
| 19 |
+
## Draft Files
|
| 20 |
+
|
| 21 |
+
- none
|
manifests/knowledge_graph.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": 2,
|
| 3 |
+
"wiki_root": "/Users/apple/Downloads/headless/docs/wiki",
|
| 4 |
+
"nodes": [
|
| 5 |
+
{
|
| 6 |
+
"id": "README.md",
|
| 7 |
+
"type": "meta",
|
| 8 |
+
"label": "README.md",
|
| 9 |
+
"tags": "",
|
| 10 |
+
"stage": "",
|
| 11 |
+
"audience": "",
|
| 12 |
+
"source_file": "docs/wiki/README.md"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "SCHEMA.md",
|
| 16 |
+
"type": "meta",
|
| 17 |
+
"label": "SCHEMA.md",
|
| 18 |
+
"tags": "",
|
| 19 |
+
"stage": "",
|
| 20 |
+
"audience": "",
|
| 21 |
+
"source_file": "docs/wiki/SCHEMA.md"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "causal_lm1_experiment.md",
|
| 25 |
+
"type": "meta",
|
| 26 |
+
"label": "LM1 因果持续学习对照实验",
|
| 27 |
+
"tags": "",
|
| 28 |
+
"stage": "draft",
|
| 29 |
+
"audience": "public",
|
| 30 |
+
"source_file": "docs/wiki/causal_lm1_experiment.md"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "causal_stack.md",
|
| 34 |
+
"type": "meta",
|
| 35 |
+
"label": "causal 因果技术栈落地",
|
| 36 |
+
"tags": "",
|
| 37 |
+
"stage": "draft",
|
| 38 |
+
"audience": "public",
|
| 39 |
+
"source_file": "docs/wiki/causal_stack.md"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"id": "current-status.md",
|
| 43 |
+
"type": "meta",
|
| 44 |
+
"label": "current-status",
|
| 45 |
+
"tags": "",
|
| 46 |
+
"stage": "draft",
|
| 47 |
+
"audience": "public",
|
| 48 |
+
"source_file": "docs/wiki/current-status.md"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "github-and-raw-strategy.md",
|
| 52 |
+
"type": "meta",
|
| 53 |
+
"label": "github-and-raw-strategy",
|
| 54 |
+
"tags": "",
|
| 55 |
+
"stage": "draft",
|
| 56 |
+
"audience": "public",
|
| 57 |
+
"source_file": "docs/wiki/github-and-raw-strategy.md"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "index.md",
|
| 61 |
+
"type": "meta",
|
| 62 |
+
"label": "index.md",
|
| 63 |
+
"tags": "",
|
| 64 |
+
"stage": "",
|
| 65 |
+
"audience": "",
|
| 66 |
+
"source_file": "docs/wiki/index.md"
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"id": "log.md",
|
| 70 |
+
"type": "meta",
|
| 71 |
+
"label": "log.md",
|
| 72 |
+
"tags": "",
|
| 73 |
+
"stage": "",
|
| 74 |
+
"audience": "",
|
| 75 |
+
"source_file": "docs/wiki/log.md"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "project-overview.md",
|
| 79 |
+
"type": "meta",
|
| 80 |
+
"label": "project-overview",
|
| 81 |
+
"tags": "",
|
| 82 |
+
"stage": "draft",
|
| 83 |
+
"audience": "public",
|
| 84 |
+
"source_file": "docs/wiki/project-overview.md"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"id": "sources-and-data.md",
|
| 88 |
+
"type": "meta",
|
| 89 |
+
"label": "sources-and-data",
|
| 90 |
+
"tags": "",
|
| 91 |
+
"stage": "draft",
|
| 92 |
+
"audience": "public",
|
| 93 |
+
"source_file": "docs/wiki/sources-and-data.md"
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"edges": [
|
| 97 |
+
{
|
| 98 |
+
"from": "index.md",
|
| 99 |
+
"to": "log.md",
|
| 100 |
+
"type": "references"
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"from": "index.md",
|
| 104 |
+
"to": "causal_stack.md",
|
| 105 |
+
"type": "references"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"from": "index.md",
|
| 109 |
+
"to": "causal_lm1_experiment.md",
|
| 110 |
+
"type": "references"
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"from": "index.md",
|
| 114 |
+
"to": "current-status.md",
|
| 115 |
+
"type": "references"
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"from": "index.md",
|
| 119 |
+
"to": "github-and-raw-strategy.md",
|
| 120 |
+
"type": "references"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"from": "index.md",
|
| 124 |
+
"to": "project-overview.md",
|
| 125 |
+
"type": "references"
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"from": "index.md",
|
| 129 |
+
"to": "sources-and-data.md",
|
| 130 |
+
"type": "references"
|
| 131 |
+
}
|
| 132 |
+
],
|
| 133 |
+
"stats": {
|
| 134 |
+
"node_count": 10,
|
| 135 |
+
"edge_count": 7,
|
| 136 |
+
"node_types": {
|
| 137 |
+
"meta": 10
|
| 138 |
+
},
|
| 139 |
+
"edge_types": {
|
| 140 |
+
"references": 7
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
}
|
manifests/raw_sources.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
source_id,company,vendor,kind,filename,raw_rel_path,status,compiled_into,filename,content_hash,size_bytes,ingested_at,compiled_at,last_validated_at,confidence,supersedes,lifecycle_stage,crystallized_into,owner,audience,stage,tags,notes,schema_version
|
| 2 |
+
1,headless,,experiment-log,log.md,docs/wiki/log.md,new,,log.md,d8a4a23a1a848bb5,6572,2026-09-06,2026-09-06,high,,,元木,reader,draft,continuous-learning,Primary experiment log,2
|
manifests/raw_sources.meta.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": 2,
|
| 3 |
+
"columns": [
|
| 4 |
+
"source_id",
|
| 5 |
+
"company",
|
| 6 |
+
"vendor",
|
| 7 |
+
"kind",
|
| 8 |
+
"filename",
|
| 9 |
+
"raw_rel_path",
|
| 10 |
+
"status",
|
| 11 |
+
"compiled_into",
|
| 12 |
+
"filename",
|
| 13 |
+
"content_hash",
|
| 14 |
+
"size_bytes",
|
| 15 |
+
"ingested_at",
|
| 16 |
+
"compiled_at",
|
| 17 |
+
"last_validated_at",
|
| 18 |
+
"confidence",
|
| 19 |
+
"supersedes",
|
| 20 |
+
"lifecycle_stage",
|
| 21 |
+
"crystallized_into",
|
| 22 |
+
"owner",
|
| 23 |
+
"audience",
|
| 24 |
+
"stage",
|
| 25 |
+
"tags",
|
| 26 |
+
"notes",
|
| 27 |
+
"schema_version"
|
| 28 |
+
],
|
| 29 |
+
"allowed_status": [
|
| 30 |
+
"new",
|
| 31 |
+
"compiled",
|
| 32 |
+
"archived"
|
| 33 |
+
]
|
| 34 |
+
}
|
manifests/stale_report.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stale Report
|
| 2 |
+
|
| 3 |
+
- generated_at: `2026-09-06T05:10:45+00:00`
|
| 4 |
+
- raw_root: `not configured`
|
| 5 |
+
- fresh_pages: `0`
|
| 6 |
+
- stale_pages: `0`
|
| 7 |
+
- missing_hash: `6`
|
| 8 |
+
- unresolved_sources: `0`
|
| 9 |
+
- archived_refs: `0`
|
| 10 |
+
- manifest_new: `1`
|
| 11 |
+
- session_exempt: `0`
|
| 12 |
+
|
| 13 |
+
## Fresh Pages
|
| 14 |
+
|
| 15 |
+
- none
|
| 16 |
+
|
| 17 |
+
## Pages Needing Recompile (stale)
|
| 18 |
+
|
| 19 |
+
- none
|
| 20 |
+
|
| 21 |
+
## Pages Missing source_hash
|
| 22 |
+
|
| 23 |
+
- `docs/wiki/causal_lm1_experiment.md`
|
| 24 |
+
- `docs/wiki/causal_stack.md`
|
| 25 |
+
- `docs/wiki/current-status.md`
|
| 26 |
+
- `docs/wiki/github-and-raw-strategy.md`
|
| 27 |
+
- `docs/wiki/project-overview.md`
|
| 28 |
+
- `docs/wiki/sources-and-data.md`
|
| 29 |
+
|
| 30 |
+
## Pages With Unresolved source
|
| 31 |
+
|
| 32 |
+
- none
|
| 33 |
+
|
| 34 |
+
## Pages Pointing At Archived Sources
|
| 35 |
+
|
| 36 |
+
- none
|
| 37 |
+
|
| 38 |
+
## Raw Files Still Waiting For First Compile
|
| 39 |
+
|
| 40 |
+
- `docs/wiki/log.md`
|
scripts/bootstrap_knowledge_system.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bootstrap a compile-first knowledge system into a target repo.
|
| 2 |
+
|
| 3 |
+
The actual file contents live as real files under
|
| 4 |
+
`skills/knowledge-system-bootstrap/templates/`. This script copies them
|
| 5 |
+
into the target with sentinel-string substitution. That keeps every
|
| 6 |
+
generated script lintable, testable, and reviewable on its own.
|
| 7 |
+
|
| 8 |
+
Substitution sentinels (plain string.replace, no Template machinery so
|
| 9 |
+
templates can contain literal `$`):
|
| 10 |
+
__PROJECT_NAME__ — human-readable project name passed on the CLI
|
| 11 |
+
__RAW_ROOT_NAME__ — folder name for the local raw root
|
| 12 |
+
__TODAY__ — today's date in YYYY-MM-DD
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import re
|
| 18 |
+
import shutil
|
| 19 |
+
from datetime import date, datetime
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
__version__ = "2.0.0" # bumped for v2 scripts (wiki_lint, supersede_check, crystallize, hybrid_search, graph_export, intake_filter) merged under SKILL_ROOT/scripts/
|
| 23 |
+
|
| 24 |
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
| 25 |
+
TEMPLATES = SKILL_ROOT / "templates"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# (template-relative path, target-relative path)
|
| 29 |
+
TEMPLATE_TO_TARGET: list[tuple[str, str]] = [
|
| 30 |
+
("configs/AGENTS.md", "AGENTS.md"),
|
| 31 |
+
("configs/CLAUDE.md", "CLAUDE.md"),
|
| 32 |
+
("configs/.cursorrules", ".cursorrules"),
|
| 33 |
+
("configs/.windsurfrules", ".windsurfrules"),
|
| 34 |
+
("configs/.gitignore", ".gitignore"),
|
| 35 |
+
("wiki/README.md", "docs/wiki/README.md"),
|
| 36 |
+
("wiki/SCHEMA.md", "docs/wiki/SCHEMA.md"),
|
| 37 |
+
("wiki/index.md", "docs/wiki/index.md"),
|
| 38 |
+
("wiki/log.md", "docs/wiki/log.md"),
|
| 39 |
+
("wiki/project-overview.md", "docs/wiki/project-overview.md"),
|
| 40 |
+
("wiki/current-status.md", "docs/wiki/current-status.md"),
|
| 41 |
+
("wiki/sources-and-data.md", "docs/wiki/sources-and-data.md"),
|
| 42 |
+
("wiki/github-and-raw-strategy.md", "docs/wiki/github-and-raw-strategy.md"),
|
| 43 |
+
("wiki/runtime-profile.md", "docs/wiki/runtime-profile.md"),
|
| 44 |
+
("manifests/README.md", "manifests/README.md"),
|
| 45 |
+
("manifests/raw_sources.csv", "manifests/raw_sources.csv"),
|
| 46 |
+
("manifests/raw_sources.meta.json", "manifests/raw_sources.meta.json"),
|
| 47 |
+
("scripts/wiki_check.py", "scripts/wiki_check.py"),
|
| 48 |
+
("scripts/raw_manifest_check.py", "scripts/raw_manifest_check.py"),
|
| 49 |
+
("scripts/ingest_raw.py", "scripts/ingest_raw.py"),
|
| 50 |
+
("scripts/untracked_raw_check.py", "scripts/untracked_raw_check.py"),
|
| 51 |
+
("scripts/provenance_check.py", "scripts/provenance_check.py"),
|
| 52 |
+
("scripts/stale_report.py", "scripts/stale_report.py"),
|
| 53 |
+
("scripts/delta_compile.py", "scripts/delta_compile.py"),
|
| 54 |
+
("scripts/version_check.py", "scripts/version_check.py"),
|
| 55 |
+
("scripts/wiki_size_report.py", "scripts/wiki_size_report.py"),
|
| 56 |
+
("scripts/init_raw_root.py", "scripts/init_raw_root.py"),
|
| 57 |
+
("scripts/export_memory_repo.py", "scripts/export_memory_repo.py"),
|
| 58 |
+
("scripts/upgrade.sh", "scripts/upgrade.sh"),
|
| 59 |
+
# v2.0.0 — new scripts merged under scripts/, no longer in templates/scripts/
|
| 60 |
+
("scripts/wiki_lint.py", "scripts/wiki_lint.py"),
|
| 61 |
+
("scripts/supersede_check.py", "scripts/supersede_check.py"),
|
| 62 |
+
("scripts/crystallize.py", "scripts/crystallize.py"),
|
| 63 |
+
("scripts/hybrid_search.py", "scripts/hybrid_search.py"),
|
| 64 |
+
("scripts/graph_export.py", "scripts/graph_export.py"),
|
| 65 |
+
("scripts/intake_filter.py", "scripts/intake_filter.py"),
|
| 66 |
+
("claude-commands/wiki-check.md", ".claude/commands/wiki-check.md"),
|
| 67 |
+
("claude-commands/wiki-upgrade.md", ".claude/commands/wiki-upgrade.md"),
|
| 68 |
+
("claude-commands/wiki-status.md", ".claude/commands/wiki-status.md"),
|
| 69 |
+
("github/workflows/wiki-lint.yml", ".github/workflows/wiki-lint.yml"),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def slugify(value: str) -> str:
|
| 74 |
+
value = value.strip().lower()
|
| 75 |
+
value = re.sub(r"[^a-z0-9]+", "_", value)
|
| 76 |
+
return value.strip("_") or "project"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def render(body: str, vars: dict[str, str]) -> str:
|
| 80 |
+
out = body
|
| 81 |
+
for sentinel, value in vars.items():
|
| 82 |
+
out = out.replace(sentinel, value)
|
| 83 |
+
unresolved = re.findall(r"__[A-Z][A-Z0-9_]*__", out)
|
| 84 |
+
expected = {"__main__", "__init__", "__name__", "__file__"}
|
| 85 |
+
real = [s for s in unresolved if s not in expected]
|
| 86 |
+
if real:
|
| 87 |
+
raise RuntimeError(f"unresolved sentinels: {set(real)}")
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def write(path: Path, content: str) -> None:
|
| 92 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
path.write_text(content, encoding="utf-8")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def backup(path: Path) -> Path:
|
| 97 |
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 98 |
+
dest = path.with_suffix(path.suffix + f".bak.{stamp}")
|
| 99 |
+
shutil.copy2(path, dest)
|
| 100 |
+
return dest
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main() -> int:
|
| 104 |
+
parser = argparse.ArgumentParser(description="Bootstrap a compile-first knowledge system into another repo.")
|
| 105 |
+
parser.add_argument("target_dir", help="Target repository root")
|
| 106 |
+
parser.add_argument("project_name", help="Human-readable project name")
|
| 107 |
+
parser.add_argument("--force", action="store_true", help="Overwrite existing files (a .bak.<timestamp> copy is kept unless --no-backup)")
|
| 108 |
+
parser.add_argument("--no-backup", action="store_true", help="When overwriting, skip the .bak file (only effective with --force)")
|
| 109 |
+
parser.add_argument("--dry-run", action="store_true", help="Show what would happen without writing anything")
|
| 110 |
+
parser.add_argument("--raw-root-name", help="Folder name for the local raw root")
|
| 111 |
+
args = parser.parse_args()
|
| 112 |
+
|
| 113 |
+
target = Path(args.target_dir).expanduser().resolve()
|
| 114 |
+
project_name = args.project_name.strip()
|
| 115 |
+
slug = slugify(project_name)
|
| 116 |
+
raw_root_name = args.raw_root_name or f"{slug}_raw"
|
| 117 |
+
today = date.today().isoformat()
|
| 118 |
+
|
| 119 |
+
vars = {
|
| 120 |
+
"__PROJECT_NAME__": project_name,
|
| 121 |
+
"__RAW_ROOT_NAME__": raw_root_name,
|
| 122 |
+
"__TODAY__": today,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
created: list[Path] = []
|
| 126 |
+
overwritten: list[tuple[Path, Path | None]] = []
|
| 127 |
+
skipped: list[Path] = []
|
| 128 |
+
unchanged: list[Path] = []
|
| 129 |
+
|
| 130 |
+
for tmpl_rel, target_rel in TEMPLATE_TO_TARGET:
|
| 131 |
+
# v2.0.0: scripts/ under SKILL_ROOT/scripts/ (not templates/scripts/)
|
| 132 |
+
if tmpl_rel.startswith("scripts/"):
|
| 133 |
+
src = SKILL_ROOT / tmpl_rel
|
| 134 |
+
else:
|
| 135 |
+
src = TEMPLATES / tmpl_rel
|
| 136 |
+
if not src.exists():
|
| 137 |
+
print(f"WARNING: template missing: {tmpl_rel}")
|
| 138 |
+
continue
|
| 139 |
+
body = src.read_text(encoding="utf-8")
|
| 140 |
+
try:
|
| 141 |
+
content = render(body, vars)
|
| 142 |
+
except RuntimeError as exc:
|
| 143 |
+
raise SystemExit(f"template {tmpl_rel}: {exc}")
|
| 144 |
+
|
| 145 |
+
dest = target / target_rel
|
| 146 |
+
if not dest.exists():
|
| 147 |
+
if not args.dry_run:
|
| 148 |
+
write(dest, content)
|
| 149 |
+
created.append(dest)
|
| 150 |
+
elif dest.read_text(encoding="utf-8") == content:
|
| 151 |
+
unchanged.append(dest)
|
| 152 |
+
elif args.force:
|
| 153 |
+
bak_path: Path | None = None
|
| 154 |
+
if not args.dry_run:
|
| 155 |
+
if not args.no_backup:
|
| 156 |
+
bak_path = backup(dest)
|
| 157 |
+
write(dest, content)
|
| 158 |
+
overwritten.append((dest, bak_path))
|
| 159 |
+
else:
|
| 160 |
+
skipped.append(dest)
|
| 161 |
+
|
| 162 |
+
prefix = "[DRY RUN] " if args.dry_run else ""
|
| 163 |
+
print(f"{prefix}Bootstrapped knowledge system for: {project_name}")
|
| 164 |
+
print(f"Target repo: {target}")
|
| 165 |
+
print(f"Default raw root name: {raw_root_name}")
|
| 166 |
+
|
| 167 |
+
if created:
|
| 168 |
+
print(f"\nCreated ({len(created)}):")
|
| 169 |
+
for p in created:
|
| 170 |
+
print(f" + {p.relative_to(target)}")
|
| 171 |
+
if overwritten:
|
| 172 |
+
print(f"\nOverwritten ({len(overwritten)}):")
|
| 173 |
+
for p, bak in overwritten:
|
| 174 |
+
suffix = f" (backup: {bak.name})" if bak else " (no backup)"
|
| 175 |
+
print(f" ! {p.relative_to(target)}{suffix}")
|
| 176 |
+
if unchanged:
|
| 177 |
+
print(f"\nUnchanged ({len(unchanged)})")
|
| 178 |
+
if skipped:
|
| 179 |
+
print(f"\nSkipped — already exists, content differs ({len(skipped)}):")
|
| 180 |
+
print("Use --force to overwrite.")
|
| 181 |
+
for p in skipped:
|
| 182 |
+
print(f" ~ {p.relative_to(target)}")
|
| 183 |
+
|
| 184 |
+
if not args.dry_run and (created or overwritten):
|
| 185 |
+
print("\nNext steps:")
|
| 186 |
+
print(f"1. cd {target}")
|
| 187 |
+
print("2. python3 scripts/init_raw_root.py")
|
| 188 |
+
print("3. python3 scripts/wiki_check.py")
|
| 189 |
+
print("4. python3 scripts/raw_manifest_check.py")
|
| 190 |
+
|
| 191 |
+
return 0
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
raise SystemExit(main())
|