Provide terminal obs data, add truncation support - #65
Conversation
…action repeat setting both for training and for inference
|
This fix should now be concise and complete. I tested it successfully with RingPong. Environments suffering from above described issues as well as environments that work around this issue (e.g. by calling call_deferred("reset_game"), where in reset_game the game-reset happens between the physics frames) work with this fix. @Ivan-267: I would be happy when this fix would be reviewed. In case you need more tests, please tell me. |
Thanks for the PR. I might not have the time to review this one properly soon, research a bit and check for edge cases, but I wanted to at least let you know I saw the PR. Some APIs might have changed slightly since the base behavior was implemented by @edbeeching - so I think the author's feedback would be valuable on this. Some related docs:
https://farama.org/Vector-Autoreset-Mode
On action repeat: In multiple example envs this was not very harmful, it just slightly randomized the starting state each episode (unless the action repeat is large enough to cause issues). In some envs where this is harmful (e.g. repeating the old action leads the agent to move to a wall), one option is to zero the repeated action for that agent, and wait for the next In envs where an action right after the env resets is needed, it might be possible to pause the env associated with that AIController until the first action is received ( The easiest solution/implementation might depend on the environment. All of these options are compromises with some downsides, but they might be able to resolve some of the cases. Ideally we would get a new action for each env from the server right after the restart, but as this is a vectorized env, when stepping with SB3 we get an action for all envs at once, and manual/async stepping is not directly supported, so it's worth considering workarounds when action repeat is used. |
|
Thank you @Ivan-267, for pointing out that godot-rl-agents is using the VecEnvAPI and not the (old) Gym API. I should have known but was not aware of this. However, I am not sure that the auto-reset mechanism does fix all of what I have seen. Let me elaborate more on what I have seen and debugged. In the RingPong example, as soon as the ball (a RigidBody3D) is entering the ring (an Area3D), following function is called (see game.gd): Now in game_over(), both done and needs_reset of the ai_controller are set to true (see player.gd): The last two lines were added by me for debug purposes. Afterwards _physics_process() of the player is called: The first two prints were added by me as well for debug purposes. However, those prints showed that game_over() and then _physics_process() of the player is called in the same physics frame. Why this is problematic: That means that any later call of get_obs() / ai-controller will not return the obs of the terminal state but the obs of the resetted-game. Then in the same physics frame follows the _physics_process() of the sync node. There, _training_process() is called when training is configured (excerpt of sync.gd): So within this code snippet I would have expected 'obs'-terminal-state representations of the envs where 'done' is true. But because _get_obs_from_agents() is calling get_obs() of the respective ai-controllers, this is returning 'obs'-resetted-game representations instead. This information is then sent to the Python server and end up in step_recv() of a GodotEnv object (see godot_env.py): So in the end, response["obs"] and response["done"] does also not fit as described above. And this is the case even without using action repeats. With action repeats the problem is getting worse. Before moving on with my proposal (e.g. closing or changing it) I would like you, @Ivan-267 or @edbeeching, to confirm this. Maybe I am overlooking something? If this is truly a problem then it is not limited to RingPong Godot example only as other examples behave similar (the game reset is issued in the same physics frame after done has been set to true) |
|
Yes, let's keep the PR until more discussion and/or a closer review by someone. I also have to learn a bit more about this, as I mentioned I won't be able to do a full review soon: Env specification/terminal obs: In context of SB3 at least with the SB3 wrapper, it is a custom VecEnv, just not fully implemented (i.e. we don't have truncation yet). There is also support for other frameworks, and we should ideally double check how they handle this as well. So, for SB3, the documented behavior (I previously linked the docs):
In Gymnasium, this is not the default mode, there are two other modes in the linked doc, this one just seems the most similar one:
If I understand it correctly, strictly considering the obs (not info dictionary), the terminal obs is not always returned in this mode, especially under the SB3's definition. Also if I understood the doc right (it needs a bit more research from my side), it becomes a larger issue with truncation. When we work on adding truncation (I considered implementing this a while ago and we can at some point, so it's worth reading up on it), the terminal obs must be included in the info dictionary, and then it's up to the training code to extract the terminal obs from the info dictionary for bootstrapping. Here's what SB3's on policy algorithm implementation does when truncated and when terminal obs is included in the info: https://github.com/DLR-RM/stable-baselines3/blob/10dda8678759b1b42b3afd9baf442addc19aabcd/stable_baselines3/common/on_policy_algorithm.py#L236-L245 Action repeat E.g., per env:
Different envs might have different issues and some solutions might work better than others. |
Thank you again, @Ivan-267, for taking your time to have a look at this PR. Actually after you showed to me the documentation I have read above passage. Furthermore, I have seen a note below: "When defining a custom VecEnv (for instance, using gym3 ProcgenEnv), you should provide terminal_observation keys in the info dicts returned by the VecEnv (cf. note above)." However, StableBaselinesGodotEnv(VecEnv), see stable_baselines_wrapper.py / SB3 wrapper, does not provide the terminal_observation keys in info dicts. The reason is that the godot-rl-plugin on the Godot side does not capture the correct terminal-state-obs (and it cannot because it is immediately overwritten by the resetted-game in the same physics frame) and does not set this in the info which is then sent to the SB3 wrapper. So I now hope that I have described what I feel is an issue (maybe it isn't) clearly enough. |
|
I am not a friend of LLMs (except for some boring tasks and sometimes to challenge myself) but I "gambled" around with Claude. Given that StableBaselinesGodotEnv is derived from VecEnv (so it is a custom env) and my question "must I set info["terminal_observation"]?" Claude said: "Great question! info["terminal_observation"] must be set when an episode ends (done=True), and it should contain the actual observation at the moment the episode terminated. Why It's Important When an episode ends, VecEnv automatically resets the environment and returns a fresh initial observation in the next step. However, the learning algorithm needs access to the true final observation to properly compute value estimates and advantages (especially important for algorithms like PPO and A2C that use bootstrapping). Without terminal_observation, the algorithm might incorrectly use the reset observation for value calculations, which would be wrong." I "interacted" a bit more with Claude and asked what would be the issue with PPO and got: "The Actual Problem When done=True:
Why info["terminal_observation"] Matters PPO needs the actual terminal observation for proper value bootstrapping. When computing the advantage: When done=True, this becomes: But PPO's value network still needs to process something. It uses info["terminal_observation"] to get the true final state and compute the correct value estimate. Yes, a custom VecEnv absolutely must set info["terminal_observation"] when done=True, because:
At least this would match what I was on my mind. But I (and Claude) still could be wrong... |
…d the obs of the resetted-game
|
Given all infos I now have, I posted another commit. The overall changes to main repo are now minuscule but I think important. As I would like to keep the good discussions in this PR, I am not editing / changing my original ideas in my first posts. Instead I describe here what the fix does:
Now I am patient and will keep this PR without any additions until this has been confirmed / reviewed thoroughly. |
|
Thanks for the update. For now I'll also leave my current understanding, not guaranteed to be correct, as I haven't researched it fully yet. Yes, based on the SB3 docs that is needed to implement the VecEnv completely. For PPO training specifically: Termination: In case of an episode termination, there is no action to take from the terminal state, Also, let's look at the Godot env side, if it sends the next episode initial obs with done = true. Let's take a very simple environment example, only 1 step. There's a starting state 0 (middle position), Step by step:
Now, because the server has obs = 0, it can send the next action to take, and the cycle can repeat. We didn't send the terminal obs there at all. The one step example doesn't really lend itself to truncation (will mention it more below), but it focuses on why in this loop we send the next episode obs together with done. As mentioned with the Gymnasium API, there might be different ways to handle the terminal obs and reset, Truncation: If we always want to terminate it after the set timesteps (the task has a time limit), However, in the previous case, truncation is used. At that point, future returns are predicted from the terminal state, as it's terminal only during training, but not for the final task/inference. Here, since we haven't provided the terminal state, it cannot bootstrap from it in case of truncation. In conclusion: It's been interesting to take a bit of a deeper look at this, although I don't have the time to check it fully. I think it should just be a matter of adding the terminal obs to the info per env (your latest changes address this) and truncation support (also in info), at least for SB3 support. We also have some docs written related to adding info for success rate reporting here, it might need to be updated so the info is not overwritten. The action repeat is a separate issue, as discussed previously. P.S. |
Although I started differently (I originally thought that the obs representation needs to be that of a terminal state and not of the next state / resetted-game) I converged eventually to the same conclusion. In addition to this, I found how SB3 PPO is using this info. Excerpt of collect_rollouts (see on_policy_algorithm.py): And PPO, which is an On-Policy algorithm is derived from this. So the original idea of this PR is invalid (spoken openly it is a mess except for the good discussions we had). However, this PR could still be useful by adding support of SB3-style truncation instead. The changes to this would be minimal. How I would implement it:
I will only move on if this is fine for you (@Ivan-267 and @edbeeching ). |
|
Yes, I also checked the same:
We reached the same conclusions, the code confirms it's used with truncation. The base idea from your PR (to provide the terminal obs data) is useful, we just iterated on the specs so that the final implementation should be compatible. The only question left is the implementation, we can iterate on that. I grouped some of your points together or out of order for commenting. All of my comments below are open for feedback, consideration, and iteration. But the main points are in agreement, it's mostly the details.
Yes, a switch like this is useful. A small thing to consider is the name. Based on your other point about always sending the terminal obs, I'm considering something like Then, if this is enabled, truncation can be used. If not, an assert could be raised (or a warning printed) if I agree about setting it to I also agree on sending the terminal obs info on
Yes, but in addition to using it the built-in timeout code in AIController, I think it we can have Then, when making an env, if we want to choose when to truncate, the usage is for example: (just an example with some duplicated code to highlight the difference, it can be used in the extended ai_controller script, or with needs_reset like in the tutorial, etc.) Both the Then, as you proposed, we can also add E.g. in the base AIController script, you could invoke reading/storing the terminal obs when
Agreed, keep the previous action repeat behavior, and the action repeat solution can be discussed separately. All of this is also open to more feedback by @edbeeching and/or another contributor. I might be able to give more feedback later, but as mentioned before, not in the next few days. |
|
As another quick iteration on my previous proposed changes, truncated can automatically set done to true, so the user only has to set truncated to true. Optional, but if it doesn't cause any issues, it will be slightly easier to use. |
|
Thank you, @Ivan-267 , for your kind support to this PR. This is highly appreciated. I have implemented almost all of above and found out during debugging that info["terminal_observation"]["obs"] must be set to the observation of the terminal state (and not just info["terminal_observation"]) so PPO (or other On-Policy algorithms in SB3 lib) can use it. When I have tested the implementation with varying number of timeout-steps successfully, I will commit my changes to this branch. |
…minal_obs_info export variable to configure sending terminal states via info, added truncation support
|
I debugged the implementation from Godot up to the collect_rollouts (see on_policy_algorithm.py) and committed my changes to this branch. Now, when Truncation happens automatically when an AI-controller reaches a timeout (see _physics_process), but this could be overwritten by a user when necessary. Further, truncation can be triggered manually by setting Last, the forced learning on reaching a terminal state has been removed. I did not implement to check if truncation is triggered manually when Test results with RingPong The results are... interesting. The ep_len_mean and ep_rew_mean values are worse with truncation than without truncation. However, I think that this is to be expected as the end of an episode is now when the ball leaves the ring (terminal state) and in addition to this when an agent has done more than 1000 steps. So the episode lengths are now limited with truncation enabled. Actually, with a maximum of 1000 steps and an action repeat of 8, the ep_len_mean could be maximum 1000 / 8 = 125 (I have seen this value during a test). With truncation disabled, the episode lengths are theoretically not limited, as the ball could never leave the ring. However, from a visual inspection, the performance of RingPong with truncation is better to me as the agents actually learn quicker to rotate the paddle where it should be. Maybe because the agents have seen more often different starting positions of the ball. So in case of RingPing comparing ep_len_mean / ep_rew_mean with or without truncation is not fair. Maybe other metrics, e.g. average episode reward per 100 steps are more fair. Btw. also different values for action repeat have an impact on ep_len_mean (higher values of action repeat means lower values for ep_len_mean). In SB3, custom evaluations via a callback could be added. While I am still thinking about the evaluation part, I feel that this PR that is adding terminal observation support to the Godot side is now ready for review. |
|
I changed the title to a more meaningful title. However, for archiving reasons, I added the original title to my first comment to this PR. So it can be seen how this developed. |
|
Thanks, that sounds good. Difference in ep length and reward is definitely expected if the env didn't terminate on timeout before. One interesting test could be to try with done = true instead (even though not intended for timeout without adding an obs with normalized timestep progress as mentioned before), then ep lenght should match the truncated one, and reward could be compared directly. Still, it's a relatively quick to train env and I'm not sure how large the effect would be. I still haven't considered a perfect test case to compare the training impact of this vs termination in an env, but if it functionally works (sb3 receives the data of the terminal obs and uses it), then it should match the spec. I won't be able to make the final checks soon as mentioned on this or other PRs (I'm aware the raycast debug one should be checked too, it's a useful thing to have), but it's good to know this one is ready for review now. |
|
Just a quick feedback on this, thanks for adding the results. I agree run to run variance can have an effect. To clarify something about the I assume during training the episodes are always truncated after n steps in this setup. If the episodes are always truncated after n steps, and if the I would recommend this check also (optionally, I can also try this on an env when I have more time to test this), as previously described, with an addition to make it 3 test cases:
Those would be 3 possible scenarios to benchmark against each other, and the ep length/reward progress during training can be directly compared. When making envs, I often use |
I'll look into this. Currently, the second figure in my post above matches your first scenario. The first figure in my post above is under the non-working default-truncation condition where just I`ll try the other two scenarios you mentioned above later. PS: Both figures are under training |
Thx, appreciated. Now that the results of the test runs are here and they at least do not show anything out of order, I think that the PR submission should be ready for review. So this PR certainly can wait a few weeks :) |
You are correct. But (Scenario 0 is what is currently happening on godot-rl-agents-plugin main: Ending the episode on n_steps > reset_after without setting done, so done = false. For this case, a neural network learns that a very late state is connected with an initial state but it isn't. And this scenario 0 is reflected in my very first figure now visible way above.) So it was my intention to implement However, the results for scenario 1 to 3 also include the standard |
|
Thanks for the full explanation, you're correct it is useful to compare to the base implementation. After this PR, it likely wouldn't be needed (except for such tests, and at least in the current form, we can of course still consider it and alternatives) as envs will either truncate or terminate. I also understand the usefulness of measuring real non-truncated performance, but that's a different matter, and can lead to unbounded length evaluations on infinite horizon episodes without truncation. It would also be possible to measure episodes that terminated only and reset the accumulated rewards on either truncated or terminated with a small change, those however cannot outperform the truncated episodes significantly in cases such as this env and wouldn't show the maximum potential performance either (that one is tricky to measure with truncation). I've also heard of average reward formulations as alternative for episodic possibly used for infinite horizon tasks (I mean for training algorithms, not just stat reporting), but haven't looked into that in any detail and it's not related to this PR. Truncation can also be useful for infinite horizon tasks (some envs might never terminate episodes). It sounds good now. Thanks for those tests and I'll take a further look at the implementation when possible. |
This is certainly worth investigating (outside this PR as you said). However, when it comes to stat reporting, I think that Edit: I implemented
Thanks and take your time 👍 |
Co-authored-by: Ivan-267 <61947090+Ivan-267@users.noreply.github.com>
…t extracting obs from dictionary and rebuilding the dictionary
|
Thank you for the review @Ivan-267. Hopefully all should be resolved by now. As a reminder, also for myself, when (if) this PR is being accepted then the documentation should also be updated in order to explain why and how truncation and / or terminal observations can be utilized. However, the documentation of godot_rl_agents_plugin is unfortunately in the godot_rl_agents repo (and as far as I know this PR cannot immediately propose documentation changes to another repo so it must be done in another PR in the godot_rl_agents repo). Luckily, the changes proposed in this PR will by default not change the behaviour of godot-rl-agent-plugin. They will only apply when send_terminal_obs_info of the sync node is set to true, so the documentation can wait. |
|
I made a small PR to your branch (stefanfausser#1). Not well tested yet, will add some results when I check it a bit. I wanted to resolve an error I got (more on the error below), but also made some changes such as:
Error that this resolves:I ran a short test on SimpleReachGoal (gdscript) and ran into some errors even when The cause:
It's worth noting that setting done to true initially is not necessarily useful here and the env could be modified to not set done initially, but it serves as a nice edge case to resolve. Potentially it would happen in other example envs and/or edge cases with resetting the env from Python. My first idea was to check for and send terminal obs only during action steps (ignore reset steps), but the above alternative covers both this and the edge case of setting done before sync is ready. Asserts:I think you've included the raycast PR in the assert message commit. You can consider my PR after an undo of that commit (easier than removing the raycast changes after my commit), then re-implement the assert messages on top. |
This reverts commit 218fc9b.
Thank you @Ivan-267 for the great additions and the compatibility fix 👍 I tested your PR and have found no additional issues. However, I have two minor suggestions / improvements, see stefanfausser#1
Right, I did commit something completely unrelated to this PR :( Thank you for noticing, I have undone that commit. After you have had a look at my two minor suggestions, I will accept your PR so this will then appear in this PR (this sounds so illogical but that is actually the way it is) |
While we could theoretically push changes to the same branch, I think the PR approach is much better in this case as for example both of us might be working on some changes at the same time, you might have been working on some changes while I was working on others. |
Optimizations and compatibility fixes
Your PR was merged and I re-implemented the assert messages on top and committed it |
|
Quick tests: Rollout plot colors (smoothing
Extended AIController modified to: func _physics_process(_delta):
n_steps += 1
if n_steps > reset_after:
needs_reset = true
#done = true
truncated = true
if needs_reset:
_player.game_over()
#needs_reset = false
#reset()Note Done is uncommented and truncated is commented for testing termination
Similar results on reward (perhaps slightly faster improvement for truncated, but run to run variance can have an effect here), a more noticeable difference on explained variance, which was better for truncated runs. Extended AIController modified to: func _physics_process(delta):
n_steps += 1
if n_steps > reset_after:
truncated = true
player.game_scene_manager.reset()Note
And reset after set to 150 for more frequent termination/truncation.
Similar results. More complex envs and further testing might show more difference. @stefanfausser Thanks for your work on this. I think we resolved all of the major points and it looks ready, only the docs remain, which can be written as another PR on the main repository. @edbeeching This one looks good now, I'll approve, feel free to take a final look and give a go ahead and we can merge. |
|
Thanks @Ivan-267, I highly appreciated your review, the joint work and the testing |
|
I am looking forward for a review @edbeeching . Please let me know If you need additional tests |










Update: What this PR does
When send_terminal_obs_info of the sync node is set to true (default is false), then the terminal observations are set in
info[idx]["terminal_observation"][key]either on reaching a terminal state or on truncation.Truncation happens automatically when an AI-controller reaches a timeout (see
_physics_process), but this can be overwritten by a user when necessary. Further, truncation can be triggered manually by settingcontroller.truncated = true, followed bycontroller.needs_reset = true. On truncation,info[idx]["TimeLimit.truncated"] = Trueis set.How the PR started (not relevant anymore)
Only read the following when interested in the development of this PR. Otherwise: Ignore the rest of this comment.
Original title of this PR was: "Fixes: Have done + observation reported in the 'step'-reply to the server aligned, get new actions after a game-reset"
What is the issue with the observations:
How this is fixed: