Skip to content

Commit d26ee82

Browse files
vrouletOptaxDev
authored andcommitted
Utility to set value in a pytree (and so in state)
PiperOrigin-RevId: 615524997
1 parent f45b2eb commit d26ee82

4 files changed

Lines changed: 142 additions & 12 deletions

File tree

docs/api/utilities.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Tree
106106
tree_ones_like
107107
tree_random_like
108108
tree_scalar_mul
109+
tree_set
109110
tree_sub
110111
tree_sum
111112
tree_vdot
@@ -155,6 +156,10 @@ Tree scalar multiply
155156
~~~~~~~~~~~~~~~~~~~~
156157
.. autofunction:: tree_scalar_mul
157158

159+
Set values in a tree
160+
~~~~~~~~~~~~~~~~~~~~
161+
.. autofunction:: tree_set
162+
158163
Tree subtract
159164
~~~~~~~~~~~~~
160165
.. autofunction:: tree_sub

optax/tree_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from optax.tree_utils._state_utils import tree_get
1818
from optax.tree_utils._state_utils import tree_get_all_with_path
1919
from optax.tree_utils._state_utils import tree_map_params
20+
from optax.tree_utils._state_utils import tree_set
2021

2122
from optax.tree_utils._tree_math import tree_add
2223
from optax.tree_utils._tree_math import tree_add_scalar_mul

optax/tree_utils/_state_utils.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,33 @@ def tree_get_all_with_path(
175175
(``path_to_value``, ``value``). Here ``value`` is one entry of the state
176176
that corresponds to the ``key``, and ``path_to_value`` is a path returned
177177
by :func:`jax.tree_util.tree_flatten_with_path`.
178+
179+
Raises:
180+
ValueError: If the input tree is flat, i.e., it is not a tuple/list/dict.
178181
"""
179-
values_with_path_found = []
182+
found_values_with_path = []
180183
tree_flatten_with_path, _ = jax.tree_util.tree_flatten_with_path(tree)
184+
if not tree_flatten_with_path or not tree_flatten_with_path[0][0]:
185+
raise ValueError(
186+
"The input tree cannot be flat, i.e., it must be a tuple/list/dict."
187+
)
181188
for path, val in tree_flatten_with_path:
182189
key_leaf = _convert_jax_key_fn(path[-1])
183190
if key_leaf == key:
184-
values_with_path_found.append((path, val))
185-
return values_with_path_found
191+
found_values_with_path.append((path, val))
192+
return found_values_with_path
186193

187194

188195
def tree_get(tree: base.PyTree, key: Any, default: Optional[Any] = None) -> Any:
189196
"""Extract a value from leaves of a pytree matching a given key.
190197
191198
Search in the leaves of a pytree for a specific ``key`` (which can be a key
192199
from a dictionary or a name from a NamedTuple).
200+
193201
If no leaves in the tree have the required ``key`` returns ``default``.
194202
203+
Raises a ``KeyError`` if multiple values of ``key`` are found in ``tree``.
204+
195205
.. seealso:: :func:`optax.tree_utils.tree_get_all_with_path`
196206
197207
Examples:
@@ -217,14 +227,68 @@ def tree_get(tree: base.PyTree, key: Any, default: Optional[Any] = None) -> Any:
217227
218228
Raises:
219229
KeyError: If multiple values of ``key`` are found in ``tree``.
230+
ValueError: If the input tree is flat, i.e., it is not a tuple/list/dict.
220231
"""
221-
values_with_path_found = tree_get_all_with_path(tree, key)
222-
if len(values_with_path_found) > 1:
232+
found_values_with_path = tree_get_all_with_path(tree, key)
233+
if len(found_values_with_path) > 1:
223234
raise KeyError(f"Found multiple values for '{key}' in {tree}.")
224-
elif not values_with_path_found:
235+
elif not found_values_with_path:
225236
return default
226237
else:
227-
return values_with_path_found[0][1]
238+
return found_values_with_path[0][1]
239+
240+
241+
def tree_set(tree: base.PyTree, **kwargs: Any) -> base.PyTree:
242+
"""Creates a copy of tree with some leaves replaced as specified by kwargs.
243+
244+
Raises a ``KeyError`` if some keys in ``kwargs`` are not present in the tree.
245+
246+
Examples:
247+
>>> import jax.numpy as jnp
248+
>>> import optax
249+
>>> params = jnp.array([1., 2., 3.])
250+
>>> opt = optax.inject_hyperparams(optax.adam)(learning_rate=1.)
251+
>>> state = opt.init(params)
252+
>>> new_state = optax.tree_utils.tree_set(state, learning_rate=2.)
253+
>>> lr = optax.tree_utils.tree_get(new_state, 'learning_rate')
254+
>>> print(lr)
255+
2.0
256+
257+
Args:
258+
tree: pytree whose values are to be replaced.
259+
**kwargs: dictionary of keys with values to replace in the tree.
260+
261+
Returns:
262+
new_tree
263+
new pytree with the same structure as tree. For each leaf whose
264+
key/name matches a key in ``**kwargs``, their values are set by the
265+
corresponding value in ``**kwargs``.
266+
267+
Raises:
268+
KeyError: If no values of some key in ``**kwargs`` are found in ``tree``.
269+
ValueError: If the input tree is flat, i.e., it is not a tuple/list/dict.
270+
"""
271+
tree_flatten_with_path, _ = jax.tree_util.tree_flatten_with_path(tree)
272+
if not tree_flatten_with_path or not tree_flatten_with_path[0][0]:
273+
raise ValueError(
274+
"The input tree cannot be flat, i.e., it must be a tuple/list/dict."
275+
)
276+
key_leaves = [
277+
_convert_jax_key_fn(path[-1]) for path, _ in tree_flatten_with_path
278+
]
279+
if (left_keys := set(kwargs) - set(key_leaves)):
280+
left_keys_str = " nor ".join({f"'{key}'" for key in left_keys})
281+
raise KeyError(f"Found no value for {left_keys_str} in {tree}.")
282+
283+
def _replace(path, value):
284+
"""Replace a value in tree if key from path matches some key in kwargs."""
285+
key_leaf = _convert_jax_key_fn(path[-1])
286+
if key_leaf in kwargs:
287+
return kwargs[key_leaf]
288+
else:
289+
return value
290+
291+
return jax.tree_util.tree_map_with_path(_replace, tree)
228292

229293

230294
@jax.tree_util.register_pytree_node_class

optax/tree_utils/_state_utils_test.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ def test_map_non_params_to_none(self):
244244
def test_tree_get_all_with_path(self):
245245
params = jnp.array([1.0, 2.0, 3.0])
246246

247+
with self.subTest('Test with flat tree'):
248+
tree = ()
249+
self.assertRaises(ValueError, _state_utils.tree_get, tree, 'foo')
250+
tree = jnp.array([1.0, 2.0, 3.0])
251+
self.assertRaises(ValueError, _state_utils.tree_get, tree, 'foo')
252+
247253
with self.subTest('Test with single value in state'):
248254
key = 'count'
249255
opt = transform.scale_by_adam()
@@ -253,8 +259,8 @@ def test_tree_get_all_with_path(self):
253259
self.assertEqual(values_found, expected_result)
254260

255261
with self.subTest('Test with no value in state'):
256-
key = 'count'
257-
opt = alias.sgd(learning_rate=1.0)
262+
key = 'apple'
263+
opt = alias.adam(learning_rate=1.0)
258264
state = opt.init(params)
259265
values_found = _state_utils.tree_get_all_with_path(state, key)
260266
self.assertEmpty(values_found)
@@ -318,7 +324,7 @@ def test_tree_get(self):
318324

319325
with self.subTest('Test jitted tree_get'):
320326
opt = _inject.inject_hyperparams(alias.sgd)(
321-
learning_rate=lambda x: 1/(x+1)
327+
learning_rate=lambda x: 1 / (x + 1)
322328
)
323329
state = opt.init(params)
324330

@@ -327,10 +333,64 @@ def get_learning_rate(state):
327333
return _state_utils.tree_get(state, 'learning_rate')
328334

329335
for i in range(4):
330-
# we simply update state, we don't care about updates.
336+
# we simply update state, we don't care about updates.
331337
_, state = opt.update(params, state)
332338
lr = get_learning_rate(state)
333-
self.assertEqual(lr, 1/(i+1))
339+
self.assertEqual(lr, 1 / (i + 1))
340+
341+
def test_tree_set(self):
342+
params = jnp.array([1.0, 2.0, 3.0])
343+
344+
with self.subTest('Test with flat tree'):
345+
tree = ()
346+
self.assertRaises(ValueError, _state_utils.tree_get, tree, 'foo')
347+
tree = jnp.array([1.0, 2.0, 3.0])
348+
self.assertRaises(ValueError, _state_utils.tree_get, tree, 'foo')
349+
350+
with self.subTest('Test modifying an injected hyperparam'):
351+
opt = _inject.inject_hyperparams(alias.adam)(learning_rate=1.0)
352+
state = opt.init(params)
353+
new_state = _state_utils.tree_set(state, learning_rate=2.0, b1=3.0)
354+
lr = _state_utils.tree_get(new_state, 'learning_rate')
355+
self.assertEqual(lr, 2.0)
356+
357+
with self.subTest('Test modifying an attribute of the state'):
358+
opt = _inject.inject_hyperparams(alias.adam)(learning_rate=1.0)
359+
state = opt.init(params)
360+
new_state = _state_utils.tree_set(state, learning_rate=2.0, b1=3.0)
361+
b1 = _state_utils.tree_get(new_state, 'b1')
362+
self.assertEqual(b1, 3.0)
363+
364+
with self.subTest('Test modifying a value not present in the state'):
365+
opt = _inject.inject_hyperparams(alias.adam)(learning_rate=1.0)
366+
state = opt.init(params)
367+
self.assertRaises(KeyError, _state_utils.tree_set, state, ema=2.0)
368+
369+
with self.subTest('Test jitted tree_set'):
370+
371+
@jax.jit
372+
def set_learning_rate(state, lr):
373+
return _state_utils.tree_set(state, learning_rate=lr)
374+
375+
modified_state = state
376+
lr = 1.0
377+
for i in range(4):
378+
modified_state = set_learning_rate(modified_state, lr / (i + 1))
379+
# we simply update state, we don't care about updates.
380+
_, modified_state = opt.update(params, modified_state)
381+
modified_lr = _state_utils.tree_get(modified_state, 'learning_rate')
382+
self.assertEqual(modified_lr, lr / (i + 1))
383+
384+
with self.subTest('Test modifying several values at once'):
385+
opt = combine.chain(
386+
alias.adam(learning_rate=1.0), alias.adam(learning_rate=1.0)
387+
)
388+
state = opt.init(params)
389+
new_state = _state_utils.tree_set(state, count=2.0)
390+
values_found = _state_utils.tree_get_all_with_path(new_state, 'count')
391+
self.assertLen(values_found, 2)
392+
for _, value in values_found:
393+
self.assertEqual(value, 2.0)
334394

335395

336396
def _fake_params():

0 commit comments

Comments
 (0)