Symbolic Functions (Functors)#

Open in Colab

PyGlove introduces the concept of symbolic functions (or functors). A symbolic function is the symbolic counterpart of Python functions. Implementation-wise, it is a symbolic class with a __call__ method that invokes the function body.

Symbolic function provides the following benefits:

  • It converts functions to classes, thus the use of functors can make a Python program more OO;

  • It adds advanced binding capabilities, from full binding, partial binding, incremental binding, mutable binding to binding cloning, binding serialization, and etc.

  • It can be symbolically programmed, supporting features such as deep manipulation, symbolic query, generation and optimization.

Before we get started, we install and import the pyglove library:

!pip install pyglove
import pyglove as pg

Definition#

A functor can be created by decorating a function definition with pg.symbolize, for example:

@pg.symbolize
def foo(fn, v):
  return v + fn(v)

@pg.symbolize
def bar(x, y):
  return x + y

print(foo)
print(bar)
<class '__main__.foo'>
<class '__main__.bar'>

It can also be created by symbolize an existing function (without modifying it).

def baz(x, y, z):
  return x * y * z

bax = pg.symbolize(baz)
print(bax)
print(baz)
<class '__main__.baz'>
<function baz at 0x7fecf4f4a0d0>

To call a symbolic function, we need to create an instance first:

# `f` is an instance of `foo`,
# whose `fn` argument is a partially bound `bar` object.
f = foo(bar(y=1), 2)
print(f)

# Call the symbolic function.
f()
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 1
  ),
  v = 2
)
5

Advanced Binding Capabilities#

Functor supports various binding scenarios, from partial binding, incremental binding, to mutable binding, and etc.

Partial binding#

As we have seen above (bar(y=1)), a functor object can be created with arguments partially bound. The unbound arguments are marked as pg.MISSING_VALUE.

# `f2` is a partially bound `foo` with `v`.
f2 = foo(v=1)
print(f2)

# Tell whether a functor is fully bound or not.
print(f2.is_fully_bound)
foo(
  fn = MISSING_VALUE(Any()),
  v = 1
)
False

Bound arguments and unbound arguments can be accessed via the bound_args or unbound_args property.

print('bound args', f2.bound_args)
print('unbound args', f2.unbound_args)
bound args {'v'}
unbound args {'fn'}

Incremental binding#

Unbound arguments can be provided at call time. However, a call-time provided argument will not be considered as a bound argument. It’s used for that particular invocation and not assoicated with the functor. For example:

# Passing `y=2` at call time does not cause `y` to bind with the value.
print(f2(fn=bar(y=1)))
print(f2)
print(f2(fn=bar(y=3)))
3
foo(
  fn = MISSING_VALUE(Any()),
  v = 1
)
5

On the other hand, users can also incrementally bind arguments through attribute assignment, which will be treated as bound arguments:

f2.fn = bar(y=1)
print(f2)
print(f2())
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 1
  ),
  v = 1
)
3

Mutable binding#

If users want to modify a preexisting bound argument, attribute assignment can help:

f2.v = 2
print(f2)
f2()
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 1
  ),
  v = 2
)
5

If users only want to override the argument at call time, but not modifying the bound argument, they can specify that argument with a new value during call, and set the override_args flag to True.

f2(v=3, override_args=True)

# f2.v remains bound with value 2.
print(f2)
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 1
  ),
  v = 2
)

Sealed binding#

If users want to prevent the bound arguments from future modification, they can seal such functor. Further update on the functor will raise an error.

f2.seal()
try:
  f2.v = 3
except pg.WritePermissionError as e:
  print(e)
Cannot set attribute 'v': object is sealed. (path=)

Clone a binding#

A functor object can also be cloned:

f3 = f2.clone()
f3
foo(fn=bar(x=MISSING_VALUE, y=1), v=2)

Serialize a binding#

A functor object can also be serialized for later use, or picked up by a different process (given the arguments are serializable by PyGlove).

serialized_form = f2.to_json_str()
serialized_form
'{"_type": "__main__.foo", "fn": {"_type": "__main__.bar", "y": 1}, "v": 2}'

Loading it is simple, just make sure the module that defines the functor class is pre-imported:

f4 = pg.from_json_str(serialized_form)
f2 == f4
True

Late binding (Dynamic binding)#

Therefore, late binding can be implemented as passing the serialized string across processes, and load it for execution.

def main(func_str):
  print(pg.from_json_str(func_str)())

main(serialized_form)
5

Symbolic Programmability#

Functors are symbolic classes, which inherit all the symbolic programmabilities from pg.Object. This section highlight a few most frequent use cases.

Deep manipulation#

rebind allows a nested child to be directly manipulable from the root functor object.

f = foo(bar(y=1), 1)
print(f)
print(f())
f.rebind({
    'fn.y': 2
})
print(f)
f()
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 1
  ),
  v = 1
)
3
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 2
  ),
  v = 1
)
4

Or by rules:

f.rebind(lambda k, v: v + 1 if isinstance(v, int) else v)
print(f)
f()
foo(
  fn = bar(
    x = MISSING_VALUE(Any()),
    y = 3
  ),
  v = 2
)
7

Symbolic query#

pg.query(f, '.*y')
{'fn.y': 3}
pg.query(f, where=lambda v: isinstance(v, int))
{'fn.y': 3, 'v': 2}

Symbolic generation#

foo_space = foo(fn=bar(y=pg.oneof(range(20))), v=pg.oneof(range(20)))
# Random generate 5 examples from the `foo_space`.
print(list(pg.iter(foo_space, 5, algorithm=pg.geno.Random())))
[foo(fn=bar(x=MISSING_VALUE, y=5), v=5), foo(fn=bar(x=MISSING_VALUE, y=17), v=8), foo(fn=bar(x=MISSING_VALUE, y=18), v=17), foo(fn=bar(x=MISSING_VALUE, y=10), v=18), foo(fn=bar(x=MISSING_VALUE, y=5), v=15)]

Symbolic optimization#

history = []

# Optimize the argument values from the `foo_space`, to achieve larger return
# value.
for example, feedback in pg.sample(
    foo_space,
    pg.evolution.regularized_evolution(population_size=20, tournament_size=10),
    num_examples=50):
  assert isinstance(example, foo)
  reward = example()
  feedback(reward)
  history.append(reward)

import matplotlib.pyplot as plt
plt.plot(list(range(len(history))), [a - 0.9 for a in history])
plt.show()
../../../_images/949df95b7a28adaddc8ae9bad2b3acc1f53d2897131076be5736c01669aa51b7.png

Appendix: Various Binding Scenarios with functools.partial#

To allow users to compare functor with existing binding tools, we add this appendix to illustrate how certain binding scenarios are solved using functools.partial.

Partial binding#

def foo(fn, v):
  return v + fn(v)

def bar(x, y):
  return x + y

import functools

# To fit the signature of `foo.fn`,
# we create a partial binding of bar.
foo(functools.partial(bar, y=2), 1)
4

Incremental binding#

# Incremental binding can be achieved with nested `functools.partial`.
b = functools.partial(bar, x=1)
b2 = functools.partial(b, y=2)
print(b2)
print(b2())
functools.partial(<function bar at 0x7fece1932f70>, x=1, y=2)
3

Mutable binding#

# Argument override can be achieved by creating another partial binding
# with overriden arguments.
b3 = functools.partial(b2, x=2)
print(b3)

# Or specifying a different value at call time.
b2(x=2)
functools.partial(<function bar at 0x7fece1932f70>, x=2, y=2)
4