-
-
Notifications
You must be signed in to change notification settings - Fork 151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Wrap Op
s during etuplization
#1036
base: main
Are you sure you want to change the base?
Conversation
Some tests that are unrelated to @dataclass
class MakeRVNodeOp:
op: Op
def __call__(self, *args):
return self.op.make_node(*args)
@etuplize.register(RandomVariable)
def etuplize_random(*args, **kwargs):
return etuple(MakeRVNodeOp, etuplize.funcs[(object,)](*args, **kwargs)) |
d4d10d3
to
440e958
Compare
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## main #1036 +/- ##
==========================================
+ Coverage 79.13% 79.14% +0.01%
==========================================
Files 173 173
Lines 48492 48565 +73
Branches 10966 10327 -639
==========================================
+ Hits 38374 38439 +65
- Misses 7625 7632 +7
- Partials 2493 2494 +1
|
440e958
to
f99aae3
Compare
Codecov complains about a file that is not affected by the changes. @brandonwillard this is ready for review. |
O
ps during etuplization
O
ps during etuplizationOp
s during etuplization
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like to consider some other approaches before fully committing to this one, especially given the way this one complicates the use of Op
s in etuple
form (i.e. by adding an indirection).
In general, it's not good that Op
s can't be used directly. For example, etuple(op, ...)
will no longer unify with the results of a shallow etuplize(op(...))
due to the wrapper now introduced by the latter.
Also, since Op
s themselves can be converted to etuple
s (e.g. in order to unify against configurable Op
properties), these wrapper objects need to be traversed and unified explicitly, which incurs a cost of its own. I might be possible to simplify or altogether avoid some of this by altering the wrapper's __eq__
so that wrapped objects are equal to the Op
s they wrap. This might introduce other complications, though.
Perhaps a better approach would involve the construction of entirely new Op
instance clones during etuplize
; ones that are equivalent to the cloned Op
s aside from their Op.__call__
s. One problem with this approach: if we do this by constructing new type
s, we might need to cache the generated clone type
s in order to avoid producing an abundance of redundant types; however, we're doing something almost exactly the same as that in this PR (e.g. a distinct wrapper instance is produced for each occurence of the same Op
). (It's never been clear to me how type
creation is handled memory-wise in Python, so this distinction could be practically irrelevant for all I know.)
5e4a4fb
to
de5fca0
Compare
We should be able to unify I gave it more thought, and I think that the solution that introduces the least amount of change to In the present case we would only need to add a function in def eval_Op(op, evaled_args, evaled_kwargs):
return op.make_node(*evaled_args, **evaled_kwargs)
_eval.add((Op,X, X), eval_Op) In fact, couldn't we used |
One of the first things I considered was a change to I left that approach with the idea that we could simply subclass |
pythological/etuples#19 illustrates the |
80f71ad
to
53931d3
Compare
@brandonwillard I made the necessary changes following pythological/etuples#21. Provided the tests pass this is ready on my end. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. The only thing I would consider changing is the limited use of a custom ExpressionTuple
. We should always use Op.make_node
.
I considered that, it's just that the apply node created by |
We're always interested in all outputs of a node. We might not be using them in our current rewrites, but they need to be available at some point. Regardless, we really shouldn't be "special casing" |
Nevermind, I agree. |
53931d3
to
f3e8971
Compare
Evaluating |
For ease of use with |
f3e8971
to
b0a5dd1
Compare
It is trickier than I thought because there is an assumption throughout the code that inputs to import aesara
import aesara.tensor as at
from etuples import etuplize, etuple
a = at.scalar('a')
b = at.scalar('b')
c = at.scalar('c')
x_et = etuple(at.mul, etuple(at.add, b, b), etuple(at.add, a, a))
x_val = x_et.evaled_obj
print(x_val)
# [Elemwise{mul,no_inplace}.0] expected
print(x_val[0].owner.inputs)
# [MakeVector{dtype='float64'}.0, MakeVector{dtype='float64'}.0]
print(aesara.dprint(x_val[0].owner.inputs))
# MakeVector{dtype='float64'} [id A]
# |Elemwise{add,no_inplace} [id B]
# |b [id C]
# |b [id C]
# MakeVector{dtype='float64'} [id D] I am not even sure it makes sense to return all the outputs, e.g. for random variables: z_rv = at.random.normal(0, 1)
z_et = etuplize(z_rv)
z_et._evaled_obj = z_et.null
print(z_et.evaled_obj)
# [normal_rv{0, (0, 0), floatX, False}.0, normal_rv{0, (0, 0), floatX, False}.out] there is bound to be some confusion here. I think that in terms of returned value we have to do something similar to what the corresponding But I feel that's a hack and this is touching on a deeper issue with Aesara. |
Following up on a discussion with @brandonwillard This indeed touches on a much deeper issue, that is the fact that Aesara's IR lacks the ability to represent selections of specific multi-outputs outputs. All the logic is hidden in To illustrate the problem, let's look again at the etuplization of import aesara.tensor as at
from etuples import etuple, etuplize
u_rv = at.random.normal(0, 1)
u_et = etuplize(u_rv)
print(u_et)
# : oExpressionTuple((ExpressionTuple((<class 'aesara.tensor.random.ba...ormalRV'>, 'normal', 0, (0, 0), 'floatX', False)), RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA6605E5EE0>), TensorConstant{[]}, TensorConstant{11}, TensorConstant{0}, TensorConstant{1}))
# reset the saved evaled obj
u_et._evaled_obj = u_et.null
print(u_et.evaled_obj)
# [normal_rv{0, (0, 0), floatX, False}.0, normal_rv{0, (0, 0), floatX, False}.out] We get two outputs, and we should only be getting the second one! Of course we can hack our way around this, "special casing" the random variables as was mentioning above in the thread. It's unsatisfactory, but there's worse. Let's look at what happens when we etuplize one of the outputs of a multi-output import aesara.tensor as at
from etuples import etuple, etuplize
a = at.matrix('a')
u, v, w = at.nlinalg.svd(a)
u_et = etuplize(u)
print(u_et)
# oExpressionTuple((ExpressionTuple((<class 'aesara.tensor.nlinalg.SVD'>, 1, 1)), a))
# reset the saved evaled obj
u_et._evaled_obj = u_et.null
print(u_et.evaled_obj)
# [SVD{full_matrices=1, compute_uv=1}.0, SVD{full_matrices=1, compute_uv=1}.1, SVD{full_matrices=1, compute_uv=1}.2] We have lost some precious information in the process of etuplization, which is that What we essentially need is adding an indexing operator when we etuplize the Ops, so that etuplizing the ExpressionTuple(
nth,
1,
oExpressionTuple((ExpressionTuple((<class 'aesara.tensor.random.ba...ormalRV'>, 'normal', 0, (0, 0), 'floatX', False)), RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA6605E5EE0>), TensorConstant{[]}, TensorConstant{11}, TensorConstant{0}, TensorConstant{1}))
) where This also works in the ExpressionTuple(
nth,
0,
oExpressionTuple((ExpressionTuple((<class 'aesara.tensor.nlinalg.SVD'>, 1, 1)), a))
) which evaluates to the first |
3d6969c
to
9c4f2b4
Compare
N.B. This is also why a translation to something like e-graphs isn't entirely straightforward. |
cddd648
to
1be3080
Compare
Adding
|
1be3080
to
33d9511
Compare
This is not immediately relevant to this PR, but let’s consider the single output case and how etuplization, unification and reification currently behave in this case: import aesara.tensor as at
from etuples import etuplize, etuple
from unification import unify, reify, var
x_at = at.scalar('x')
y_at = at.scalar('y')
z_at = at.add(x_at, y_at)
print(etuplize(z_at))
# oExpressionTuple(ExpressionTuple(at.Add), x, y)
x_lv, y_lv = var('x'), var('y')
z_et = etuple(at.add, x_lv, y_lv)
s = unify(z_et, z_at)
print(s)
# {~x: x, ~y: y}
res = reify(z_et, s)
print(res)
# oExpressionTuple((<aesara.tensor.elemwise.Elemwise object at 0x7f04d3989c30>, x, y))
print(res.evaled_obj)
# Elemwise{add,no_inplace}.0 Now let’s imagine that we have added a print(etuplize(z_at))
# ExpressionTuple(nth, 0, oExpressionTuple(ExpressionTuple(at.Add), x, y)) Not knowing about Aesara’s internals, or for convenience, one could try to unify x_lv, y_lv = var('x'), var('y')
z_et = etuple(at.add, x_lv, y_lv)
s = unify(z_at, z_et) Now, we have two choices: we can either change the logic in Anyway, this is a question of boundaries and what part of the library we want to do the heavy lifting. The more I work at the interface between the Python IR and its etuplized counterpart the more I am starting to get aware of this. Convenience has a price, and it's good to keep that in mind. |
Well, what I was discussing above has direct consequences today because of class MyDefaultOutputOp(Op):
default_output = 1
def make_node(self, *inputs):
outputs = [MyType()(), MyType()()]
return Apply(self, list(inputs), outputs)
def perform(self, node, inputs, outputs):
outputs[0] = np.array(np.array(inputs[0]))
outputs[1] = np.array(np.array(inputs[1]))
x_at = at.vector("x")
y_at = at.vector("y")
op1_np = MyDefaultOutputOp()
q_at = op1_np(x_at, y_at)
x_lv, y_lv = var('x'), var('y')
q_et = etuple(op1_np, x_lv, y_lv)
s = unify(q_et, q_at)
assert s[x_lv] == x_at
assert s[y_lv] == y_at Relations in AeMCMC can be written using This PR has been tightly linked to the design of |
I have fixed the unification with the default outputs of a multiple-output from aesara.graph.basic import Apply
from aesara.graph.op import Op
from aesara.graph.type import Type
class MyType(Type):
def filter(self, data):
return data
def __eq__(self, other):
return isinstance(other, MyType)
def __hash__(self):
return hash(MyType)
def __repr__(self):
return "MyType()"
class MyMultiOutOp(Op):
def make_node(self, *inputs):
outputs = [MyType()(), MyType()()]
return Apply(self, list(inputs), outputs)
def perform(self, node, inputs, outputs):
outputs[0] = np.array(inputs[0])
outputs[1] = np.array(inputs[0]) Etuplizing directly will work, but evaluating the resulting import aesara.tensor as at
from etuples import etuplize
x_at = at.vector("x")
y_at = at.vector("y")
op1_np = MyMultiOutOp()
z_at = op1_np()
z_et = etuplize(z_at)
z_et._evaled_obj = z_et.null
try:
z_et.evaled_obj
except Exception as e:
print(e)
# ExpressionTuple does not have a callable operator. This can be solved by etuplizing lists to from cons import cons
from cons.core import _car, _cdr
from etuples import etuplize
def car_List(x):
return cons
_car.add((list,), car_List)
def cdr_List(x):
return (*x, [])
_cdr.add((list,), cdr_List)
print(etuplize(z_at))
# e(<class 'cons.core.ConsPair'>, MyMultiOutOp.0, MyMultiOutOp.1, [])
z_et = etuplize(z_at)
z_et._evaled_obj = z_et.null
print(z_et.evaled_obj)
# [MyMultiOutOp.0, MyMultiOutOp.1] However there is still one issue with the previous snippet: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a related note: this is reminding me that we really need hash-consing and/or caching for these etuple
conversions.
The difficulty comes from the fact that the `Variable` is etuplized as `ExpressionTuple(nth, default_output, oExpressionTuple(...))` but we cannot expect the caller to use `nth` here since this `default_output` mechanism is hidden. We expand the latter before unifying.
6f67809
to
c84149c
Compare
This PR got a little out of hand, and I will leave etuplizing a list of multiple outputs for another PR as it seems more complicated than I originally thought, and is non-blocking for now. Let's come back to our original problem, which was being able to have "true" relations in AeMCMC, in the sense that they can work both ways, and merge this PR when we are able to do that. SummaryThe first issue we ran into was that Then we realized that when we evaluated the expression tuples obtained after etuplizing And then we realized that we could not unify, say Where we are nowUnification works on simple casesLet's confirm that unification works despite the fact that from etuples import etuple, etuplize
from unification import var, unify
Y_rv = at.random.normal(0, 1)
Y_et = etuple(etuplize(at.random.normal), var(), var(), var(), at.as_tensor(0.), at.as_tensor(1.))
print(etuplize(Y_rv))
# e(<function nth at 0x7fa66e32d360>, 1, oExpressionTuple((ExpressionTuple((<class 'aesara.tensor.random.ba...ormalRV'>, 'normal', 0, (0, 0), 'floatX', False)), RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45E500>), TensorConstant{[]}, TensorConstant{11}, TensorConstant{0}, TensorConstant{1})))
print(unify(Y_rv, Y_et))
# {~_95: RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45E500>), ~_96: TensorConstant{[]}, ~_97: TensorConstant{11}}
s = unify(Y_et, Y_rv)
print(s)
# {~_95: RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45E500>), ~_96: TensorConstant{[]}, ~_97: TensorConstant{11}} Reification does not work even on simple casesReification does not give the expected result: res = reify(Y_et, s)
print(res)
# e(e(<class 'aesara.tensor.random.basic.NormalRV'>, normal, 0, (0, 0), floatX, False), RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45C820>), TensorConstant{[]}, TensorConstant{11}, TensorConstant{0.0}, TensorConstant{1.0})
try:
res.evaled_obj
except TypeError as e:
print(e)
# too many positional arguments which is because Unification does not work on more complex casesLet's consider now the case, common in AeMCMC, where we have random variables who parameters depend on other random variables. For instance a Poisson distribution with a Gamma prior: srng = at.random.RandomStream(0)
alpha_tt = at.scalar("alpha")
beta_tt = at.scalar("beta")
z_rv = srng.gamma(alpha_tt, beta_tt)
Y_rv = srng.poisson(z_rv) Which we try to unify with: alpha_lv, beta_lv = var(), var()
z_rng_lv = var()
z_size_lv = var()
z_type_idx_lv = var()
z_et = etuple(
etuplize(at.random.gamma), z_rng_lv, z_size_lv, z_type_idx_lv, alpha_lv, beta_lv
)
Y_et = etuple(etuplize(at.random.poisson), var(), var(), var(), z_et) Unsurprisingly given the previous section, unifying print(unify(z_rv, z_et))
# {~_219: RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45E340>), ~_220: TensorConstant{[]}, ~_221: TensorConstant{11}, ~_217: alpha, ~_218: OpExpressionTuple((ExpressionTuple((<class 'aesara.tensor.elemwise....eDiv object at 0x7fa69b0fef20>, <frozendict {}>)), TensorConstant{1.0}, beta))} But unification fails for print(unify(Y_rv, Y_et))
# False However unification succeeds if we use the z_et = etuple(nth, 1, etuple(
etuplize(at.random.gamma), z_rng_lv, z_size_lv, z_type_idx_lv, alpha_lv, beta_lv
))
print(unify(Y_rv, Y_et))
# {~_334: RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45C4A0>), ~_335: TensorConstant{[]}, ~_336: TensorConstant{4}, ~_331: RandomGeneratorSharedVariable(<Generator(PCG64) at 0x7FA66B45C040>), ~_332: TensorConstant{[]}, ~_333: TensorConstant{11}, ~_329: alpha, ~_330: OpExpressionTuple((ExpressionTuple((<class 'aesara.tensor.elemwise....eDiv object at 0x7fa69b0fef20>, <frozendict {}>)), TensorConstant{1.0}, beta))} Next steps before merging:
|
Evaluating etuplized objects fails for some
RandomVariable
ops whose__call__
function does not defer tomake_node
.In this commit we wrap Ops during etuplization with a class that always
defers
__call__
tomake_node
. We also add a dispatch rule foretuplize
so it also wrapsRandomVariable
with the same class.Closes #1002.
Thank you for opening a PR!
Here are a few important guidelines and requirements to check before your PR can be merged:
pre-commit
is installed and set up.