From 4d6ef124993fadd2146545ae1a1e2c2e2151148f Mon Sep 17 00:00:00 2001 From: ColtAllen Date: Mon, 10 Mar 2025 22:42:09 -0600 Subject: [PATCH 1/8] uml diagram for pytensor_from_scratch --- .DS_Store | Bin 0 -> 8196 bytes .idea/.gitignore | 3 + .idea/inspectionProfiles/Project_Default.xml | 16 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/pytensor-workshop.iml | 12 + .idea/vcs.xml | 6 + notebooks/.DS_Store | Bin 0 -> 6148 bytes scripts/pytensor_from_scratch.py | 515 ++++++++++++++++++ scripts/pytensor_from_scratch.py.md | 68 +++ 11 files changed, 638 insertions(+) create mode 100644 .DS_Store create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/pytensor-workshop.iml create mode 100644 .idea/vcs.xml create mode 100644 notebooks/.DS_Store create mode 100644 scripts/pytensor_from_scratch.py create mode 100644 scripts/pytensor_from_scratch.py.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9f2eac0a19c1dd1c2fa8f72d0a367226ac2b72b9 GIT binary patch literal 8196 zcmeI1&u-H|5XNWIKpa(|Ac2$%k|nNDNJCXoaY@tUPzgBH2o8XP9Y@sE^+vISg`!9~ z!#nT_TzL}Sg%f=Hrzmk+0S725Gt%y7@6PN-zj5r0LqwuG4Y!EuMC70;t!<(^!_>TL zYgV!&*PsINbP&s75X%7PwKZ=ZU;<2l2`~XBzy$sU0(fRCbIy70n_2Fe02BBx38?#n zi>9<=bzxNBI?(A709wYdHXQ4p{!mv9Ks#0!Mlk{tCKPHyg)K3JtsI02$9%{7FN~US z5_)H>V|Ny|LJ_v`5UzG7(J{(B6JP@C1XS%_rjUHf$S>6Idzfo})20I}^pFl{pZfHe zJn9#`KNY;Il#s-#BMP9BjwxM@5VwCG5#;IEzESubjN>THTCFdlv{Altwc=EqP3NU| zBy%tGvuWD(C(qT{6Di~1^wdy+fOT)Eh7xf?N|#(h0={ZfeElyxh>SigZFLA|^DTTq~H< z5st=%6~6xd3YR1kzrd=HnJOLPir=FiJ%Took8qV{N#$@k9~Dp^A`21nk`=FdtVHy2 zX9cS{SVEiILW8jVn{8a+q0TH7$ AmH+?% literal 0 HcmV?d00001 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..58e2d98 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0379811 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3cfb8df --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pytensor-workshop.iml b/.idea/pytensor-workshop.iml new file mode 100644 index 0000000..df361d5 --- /dev/null +++ b/.idea/pytensor-workshop.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/notebooks/.DS_Store b/notebooks/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b190e0862f1220b463ba9cf02f4ac4028cf8f18c GIT binary patch literal 6148 zcmeHKK~LK-6n=&d(p5-3Fo~ljuGJ_7p-o(}jvcrX#11fZ39V>E7NaI5ld4KN!;jgO zU&7yk@7YFbX?vZj%1?U!p8ejJIGS9I#z-&LB2aoQVb zLfq}4uJEPHl>#!kPUyjBNIr`daHss;K(Rf_-KJD%u zpLK4NKdE}qb0>klO52vjH+V!aM~nMlkR~dOpen In Colab + +# ## Basic PyTensor objects + +# In[1]: + + +class Type: + "Baseclass for PyTensor types" + +class Op: + "Baseclass for PyTensor operations." + + def __str__(self): + return self.__class__.__name__ + +class Node: + "Baseclass for PyTensor nodes." + + +class Variable(Node): + def __init__(self, name=None, *, type: Type): + self.name = name + self.type = type + self.owner = None + + def __repr__(self): + if self.name: + return self.name + return f"Variable(type={self.type})" + +class Apply(Node): + def __init__(self, op:Op, inputs, outputs): + self.op = op + self.inputs = inputs + self.outputs = outputs + for out in outputs: + if out.owner is not None: + raise ValueError("This variable already belongs to another Apply Node") + out.owner = self + + def __repr__(self): + return f"Apply(op={self.op.__class__.__name__}, inputs={self.inputs}, outputs={self.outputs})" + + +# ## Writing our first tensor graph + +# In[2]: + + +class TensorType(Type): + def __init__(self, shape: tuple[float | None, ...], dtype: str): + self.shape = shape + self.dtype = dtype + + def __eq__(self, other): + return ( + type(self) is type(other) + and self.shape == other.shape + and self.dtype == other.dtype + ) + + def __repr__(self): + return f"TensorType(shape={self.shape}, dtype={self.dtype})" + +class Add(Op): + def make_node(self, a, b): + if not(isinstance(a.type, TensorType) and isinstance(b.type, TensorType)): + raise TypeError("Inputs must be tensors") + if a.type != b.type: + raise TypeError("Addition only supported for inputs of the same type") + + output_type = TensorType(shape=a.type.shape, dtype=a.type.dtype) + output = Variable(type=output_type) + return Apply(self, [a, b], [output]) + + +add = Add() + +dvector = TensorType(shape=(10,), dtype="float64") + +x = Variable("x", type=dvector) +y = Variable("y", type=dvector) +[x_add_y] = add.make_node(x, y).outputs +x_add_y.name = "x + y" +x_add_y.owner + + +# In[3]: + + +class Sum(Op): + + def make_node(self, a): + if not(isinstance(a.type, TensorType)): + raise TypeError("Input must be a tensor") + output_type = TensorType(shape=(), dtype=a.type.dtype) + output = Variable(type=output_type) + return Apply(self, [a], [output]) + +sum = Sum() + +[sum_x_add_y] = sum.make_node(x_add_y).outputs +sum_x_add_y.name = "sum(x + y)" +sum_x_add_y.owner + + +# In[4]: + + +import pytensor +# Make our Variable a class of the PyTensor Variable +pytensor.graph.basic.Variable.register(Variable) + +pytensor.dprint(sum_x_add_y) + + +# ## Evaluating a graph + +# In[5]: + + +def add_perform(self, inputs): + a, b = inputs + return [a + b] + +Add.perform = add_perform + +def sum_perform(self, inputs): + [a] = inputs + return [a.sum()] + +Sum.perform = sum_perform + + +# In[6]: + + +def eval(var, given): + if var in given: + return given[var] + + if var.owner is None: + raise ValueError("Root variable must be given values") + + evaled_inputs = [eval(input, given) for input in var.owner.inputs] + evaled_outputs = var.owner.op.perform(evaled_inputs) + for output, evaled_output in zip(var.owner.outputs, evaled_outputs): + given[output] = evaled_output + return given[var] + +import numpy as np +eval(sum_x_add_y, {x: np.arange(10), y: np.arange(10)}) + + +# In[7]: + + +eval(sum_x_add_y, {x_add_y: np.arange(10)}) + + +# ## Constants + +# In[8]: + + +class Constant(Variable): + def __init__(self, data, *, type: Type): + self.data = data + super().__init__(type=type) + + def __repr__(self): + return str(self.data) + +def eval(var, given): + if var in given: + return given[var] + + if isinstance(var, Constant): + return var.data + + if var.owner is None: + raise ValueError("Root variable must be given values") + + evaled_inputs = [eval(input, given) for input in var.owner.inputs] + evaled_outputs = var.owner.op.perform(evaled_inputs) + for output, evaled_output in zip(var.owner.outputs, evaled_outputs): + given[output] = evaled_output + return given[var] + + +# In[9]: + + +two = Constant(np.full((10,), 10), type=dvector) +two + + +# In[10]: + + +x_add_2 = add.make_node(x, two).outputs[0] +eval(x_add_2, {x: np.arange(10)}) + + +# ## Making it easier to work with + +# In[11]: + + +def type_call(self, name: str | None = None): + """Create a variable with self type when calling the type.""" + return Variable(name=name, type=self) + +Type.__call__ = type_call + + +# In[12]: + + +def op_call(self, *args, name: str | None = None): + """Create a node with self operation and return the output when calling the operation.""" + node = self.make_node(*args) + if len(node.outputs) == 1: + out = node.outputs[0] + out.name = name + return out + else: + return node.outputs + +Op.__call__ = op_call + + +# In[13]: + + +Variable.eval = eval +Variable.dprint = pytensor.dprint + + +# In[14]: + + +class Sum(Op): + def __init__(self, axis: tuple[int]): + self.axis = axis + + def make_node(self, a): + if not(isinstance(a.type, TensorType)): + raise TypeError("Input must be a tensor") + output_shape = tuple( + dim + for i, dim in enumerate(a.type.shape) + if i not in self.axis + ) + out_var = TensorType(shape=output_shape, dtype=a.type.dtype)() + return Apply(self, [a], [out_var]) + + def perform(self, inputs): + [a] = inputs + return [a.sum(axis=self.axis)] + + def __str__(self): + return f"Sum(axis={self.axis})" + + +# In[15]: + + +dmatrix = TensorType(shape=(3, 5), dtype="float64") +x = dmatrix(name="x") +out = Sum(axis=(1,))(add(x, x)) + + +# In[16]: + + +out.type + + +# In[17]: + + +pytensor.dprint(out) + + +# In[18]: + + +out.eval({x: np.arange(15).reshape((3, 5))}) + + +# ## Rewrites the clumsy way + +# In[19]: + + +class Mul(Op): + def make_node(self, a, b): + if not(isinstance(a.type, TensorType) and isinstance(b.type, TensorType)): + raise TypeError("Inputs must be tensors") + if a.type.dtype != b.type.dtype: + raise TypeError("Multiplication only supported for inputs of the same dtype") + output_shape = np.broadcast_shapes(a.type.shape, b.type.shape) + output = TensorType(shape=output_shape, dtype=a.type.dtype)() + return Apply(self, [a, b], [output]) + + def perform(self, inputs): + [a, b] = inputs + return [a * b] + +mul = Mul() + + +# In[20]: + + +pytensor.dprint(out) + + +# In[21]: + + +scalar = TensorType(shape=(), dtype="float64") +two_x = mul(x, Constant(np.array(2.0), type=scalar)) + + +# In[22]: + + +# Just change the input that goes into the Sum! +out.owner.inputs[0] = two_x + + +# In[23]: + + +out.dprint() + + +# In[24]: + + +out.eval({x: np.arange(15).reshape((3, 5))}) + + +# ## Rewrites the proper way + +# In[25]: + + +out = Sum(axis=(1,))(add(x, x)) + + +# In[26]: + + +def clone_graph(var, clone_dict=None): + if clone_dict is None: + clone_dict = {} + if var in clone_dict: + return var + if var.owner is None: + # Reuse root variables and constants + return var + + new_inputs = [clone_graph(input, clone_dict) for input in var.owner.inputs] + new_outputs = [out.type() for out in var.owner.outputs] + new_apply = Apply(var.owner.op, new_inputs, new_outputs) + for new_output, old_output in zip(new_outputs, var.owner.outputs): + clone_dict[old_output] = new_output + return new_outputs[var.owner.outputs.index(var)] + +new_out = clone_graph(out) + + +# In[27]: + + +new_out = clone_graph(out) +new_out.dprint() +new_out is out, new_out.owner.inputs[0] is out.owner.inputs[0] + + +# In[28]: + + +def compute_clients(var): + clients = {var: []} + queue = [var.owner] + while queue: + apply = queue.pop(0) + if apply is None: + continue + queue.extend([inp.owner for inp in apply.inputs]) + + for idx, input in enumerate(apply.inputs): + if input not in clients: + clients[input] = {(idx, apply)} + else: + clients[input].add((idx, apply)) + return clients + + +# In[29]: + + +out.name = "Sum(x + x)" +out.owner.inputs[0].name = "x + x" +compute_clients(out) + + +# In[30]: + + +def local_add_to_mul(apply: Apply) -> list[Variable] | None: + """x + x -> x * 2""" + if not isinstance(apply.op, Add): + return None + + x, y = apply.inputs + if x is y: + return [mul(x, Constant(np.array(2.0), type=scalar))] + +def local_factor_sum_mul(apply: Apply) -> list[Variable] | None: + """sum(x * a) -> sum(x) * a, when a is a scalar.""" + if not isinstance(apply.op, Sum): + return None + + sum_input = apply.inputs[0] + + if not (sum_input.owner is not None and isinstance(sum_input.owner.op, Mul)): + return None + + mul_input, mul_factor = sum_input.owner.inputs + + # Check the second input is a scalar + if mul_factor.type.shape != (): + return None + + new_sum = apply.op(mul_input) + new_mul = mul(new_sum, mul_factor) + return [new_mul] + + +def graph_rewrite(node_rewrites, var): + clients = compute_clients(var) + queue = [var.owner] + while queue: + apply = queue.pop(0) + if apply is None: + continue + + queue.extend([inp.owner for inp in apply.inputs]) + + for node_rewrite in node_rewrites: + replacements = node_rewrite(apply) + if replacements is None: + continue + else: + for old_out, new_out in zip(apply.outputs, replacements): + if old_out is var: + # The output variable was itself replaced, reference new one from now on + var = new_out + else: + # Update any references to the old variable by the replacement + for inp_idx, client in clients[old_out]: + client.inputs[inp_idx] = new_out + # Try to apply rewrites in new var + return graph_rewrite(node_rewrites, var) + return var + + +# In[31]: + + +new_out = clone_graph(out) +new_out.dprint() + + +# In[32]: + + +new_out = graph_rewrite([local_add_to_mul], new_out) + + +# In[33]: + + +new_out.dprint() + + +# In[34]: + + +new_out = clone_graph(out) +new_out = graph_rewrite([local_add_to_mul, local_factor_sum_mul], new_out) +new_out.dprint() + + +# In[35]: + + +# Confirm math holds up +new_out.eval({x: np.arange(15).reshape((3, 5))}) + + +# In[35]: + + + + diff --git a/scripts/pytensor_from_scratch.py.md b/scripts/pytensor_from_scratch.py.md new file mode 100644 index 0000000..fcd5fb1 --- /dev/null +++ b/scripts/pytensor_from_scratch.py.md @@ -0,0 +1,68 @@ +```mermaid +--- +title: scripts/pytensor_from_scratch.py +--- +classDiagram + class Type + + class Op { + - __str__(self) str + } + + class Node + + class Variable { + - __init__(self, name, *, type) None + - __repr__(self) str + } + + class Apply { + - __init__(self, op, inputs, outputs) None + - __repr__(self) str + } + + class TensorType { + - __init__(self, shape, dtype) None + - __eq__(self, other) + - __repr__(self) str + } + + class Add { + + make_node(self, a, b) + } + + class Sum { + + make_node(self, a) + } + + class Constant { + - __init__(self, data, *, type) None + - __repr__(self) str + } + + class Sum { + - __init__(self, axis) None + + make_node(self, a) + + perform(self, inputs) + - __str__(self) str + } + + class Mul { + + make_node(self, a, b) + + perform(self, inputs) + } + + Variable --|> Node + + Apply --|> Node + + TensorType --|> Type + + Add --|> Op + + Sum --|> Op + + Constant --|> Variable + + Mul --|> Op +``` From c9a8371789452bd6e2dcc30d5c226dfc01bdea80 Mon Sep 17 00:00:00 2001 From: ColtAllen Date: Mon, 10 Mar 2025 22:43:05 -0600 Subject: [PATCH 2/8] pycharm gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 15201ac..d86e4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -165,7 +165,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # PyPI configuration file .pypirc From 8047778b9b41b77facaac9fdb2617cfdb65e90d1 Mon Sep 17 00:00:00 2001 From: Colt Allen <10178857+ColtAllen@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:43:47 -0600 Subject: [PATCH 3/8] Delete .idea directory --- .idea/.gitignore | 3 --- .idea/inspectionProfiles/Project_Default.xml | 16 ---------------- .idea/inspectionProfiles/profiles_settings.xml | 6 ------ .idea/misc.xml | 4 ---- .idea/modules.xml | 8 -------- .idea/pytensor-workshop.iml | 12 ------------ .idea/vcs.xml | 6 ------ 7 files changed, 55 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/pytensor-workshop.iml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 58e2d98..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 0379811..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 3cfb8df..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/pytensor-workshop.iml b/.idea/pytensor-workshop.iml deleted file mode 100644 index df361d5..0000000 --- a/.idea/pytensor-workshop.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 2bec2e06b25b9ed7b201877f6ea5e36c0a86249b Mon Sep 17 00:00:00 2001 From: Colt Allen <10178857+ColtAllen@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:44:05 -0600 Subject: [PATCH 4/8] Delete .DS_Store --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 9f2eac0a19c1dd1c2fa8f72d0a367226ac2b72b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeI1&u-H|5XNWIKpa(|Ac2$%k|nNDNJCXoaY@tUPzgBH2o8XP9Y@sE^+vISg`!9~ z!#nT_TzL}Sg%f=Hrzmk+0S725Gt%y7@6PN-zj5r0LqwuG4Y!EuMC70;t!<(^!_>TL zYgV!&*PsINbP&s75X%7PwKZ=ZU;<2l2`~XBzy$sU0(fRCbIy70n_2Fe02BBx38?#n zi>9<=bzxNBI?(A709wYdHXQ4p{!mv9Ks#0!Mlk{tCKPHyg)K3JtsI02$9%{7FN~US z5_)H>V|Ny|LJ_v`5UzG7(J{(B6JP@C1XS%_rjUHf$S>6Idzfo})20I}^pFl{pZfHe zJn9#`KNY;Il#s-#BMP9BjwxM@5VwCG5#;IEzESubjN>THTCFdlv{Altwc=EqP3NU| zBy%tGvuWD(C(qT{6Di~1^wdy+fOT)Eh7xf?N|#(h0={ZfeElyxh>SigZFLA|^DTTq~H< z5st=%6~6xd3YR1kzrd=HnJOLPir=FiJ%Took8qV{N#$@k9~Dp^A`21nk`=FdtVHy2 zX9cS{SVEiILW8jVn{8a+q0TH7$ AmH+?% From 0719411fd90aa0604e7b4564ced46e4b130a2894 Mon Sep 17 00:00:00 2001 From: Colt Allen <10178857+ColtAllen@users.noreply.github.com> Date: Mon, 10 Mar 2025 22:44:25 -0600 Subject: [PATCH 5/8] Delete notebooks/.DS_Store --- notebooks/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 notebooks/.DS_Store diff --git a/notebooks/.DS_Store b/notebooks/.DS_Store deleted file mode 100644 index b190e0862f1220b463ba9cf02f4ac4028cf8f18c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKK~LK-6n=&d(p5-3Fo~ljuGJ_7p-o(}jvcrX#11fZ39V>E7NaI5ld4KN!;jgO zU&7yk@7YFbX?vZj%1?U!p8ejJIGS9I#z-&LB2aoQVb zLfq}4uJEPHl>#!kPUyjBNIr`daHss;K(Rf_-KJD%u zpLK4NKdE}qb0>klO52vjH+V!aM~nMlkR~d Date: Mon, 17 Mar 2025 13:08:00 -0600 Subject: [PATCH 6/8] mermaid uml --- .gitignore | 2 +- .idea/inspectionProfiles/Project_Default.xml | 16 ++++++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 4 ++ .idea/pytensor-workshop.iml | 7 +++ .idea/vcs.xml | 6 ++ .idea/workspace.xml | 57 +++++++++++++++++++ 7 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/pytensor-workshop.iml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml diff --git a/.gitignore b/.gitignore index d86e4ff..04bbfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -165,7 +165,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ +# .idea/ # PyPI configuration file .pypirc diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..58e2d98 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d58bf34 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/pytensor-workshop.iml b/.idea/pytensor-workshop.iml new file mode 100644 index 0000000..ec63674 --- /dev/null +++ b/.idea/pytensor-workshop.iml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..9d5649b --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + { + "associatedIndex": 4 +} + + + + { + "keyToString": { + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "mermaid__uml", + "settings.editor.selected.configurable": "configurable.group.appearance" + } +} + + + + + + + + + + + + + + + + 1741667571592 + + + + \ No newline at end of file From f13366ad75f143d2acc3a573e511fe15f427a4f5 Mon Sep 17 00:00:00 2001 From: Colt Allen <10178857+ColtAllen@users.noreply.github.com> Date: Mon, 17 Mar 2025 13:12:21 -0600 Subject: [PATCH 7/8] Delete .idea directory --- .idea/inspectionProfiles/Project_Default.xml | 16 ------ .../inspectionProfiles/profiles_settings.xml | 6 -- .idea/misc.xml | 4 -- .idea/pytensor-workshop.iml | 7 --- .idea/vcs.xml | 6 -- .idea/workspace.xml | 57 ------------------- 6 files changed, 96 deletions(-) delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/pytensor-workshop.iml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 58e2d98..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d58bf34..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/pytensor-workshop.iml b/.idea/pytensor-workshop.iml deleted file mode 100644 index ec63674..0000000 --- a/.idea/pytensor-workshop.iml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 9d5649b..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - { - "associatedIndex": 4 -} - - - - { - "keyToString": { - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.git.unshallow": "true", - "git-widget-placeholder": "mermaid__uml", - "settings.editor.selected.configurable": "configurable.group.appearance" - } -} - - - - - - - - - - - - - - - - 1741667571592 - - - - \ No newline at end of file From ee71bbb023ee3f0e63dd7fe50a3ed4b91c1c935b Mon Sep 17 00:00:00 2001 From: ColtAllen Date: Mon, 17 Mar 2025 15:16:30 -0600 Subject: [PATCH 8/8] call and uml charts --- data/pycallgraph.png | Bin 0 -> 97349 bytes {scripts => data}/pytensor_from_scratch.py.md | 0 notebooks/exercises/implementing_an_op.ipynb | 1658 +++++++++-------- scripts/pytensor_from_scratch.py | 423 +---- 4 files changed, 859 insertions(+), 1222 deletions(-) create mode 100644 data/pycallgraph.png rename {scripts => data}/pytensor_from_scratch.py.md (100%) diff --git a/data/pycallgraph.png b/data/pycallgraph.png new file mode 100644 index 0000000000000000000000000000000000000000..df8ce87a42fc02697f1081ea736882c424fc4cc8 GIT binary patch literal 97349 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�M073cM$BH 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/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 w5bwYK5$gDV3lCiMkM{9l_;XZ>iUND`*$Py\"Open" - ] - }, - { - "cell_type": "markdown", - "source": [ - "**๐Ÿ’ก To better engage gray mass we suggest you turn off Colab AI autocompletion in `Tools > Settings > AI Assistance`**" - ], - "metadata": { - "id": "zPKdP-T22Nda" - }, - "id": "zPKdP-T22Nda" - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "58691dd3-374d-4404-bc86-7acd7d96f4f6", - "metadata": { - "id": "58691dd3-374d-4404-bc86-7acd7d96f4f6" - }, - "outputs": [], - "source": [ - "%%capture\n", - "\n", - "try:\n", - " import pytensor_workshop\n", - "except ModuleNotFoundError:\n", - " !pip install git+https://github.com/pymc-devs/pytensor-workshop.git" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "e1195b46-a766-4340-bf59-76192766ff90", - "metadata": { - "id": "e1195b46-a766-4340-bf59-76192766ff90" - }, - "outputs": [], - "source": [ - "import numpy as np" - ] - }, - { - "cell_type": "code", - "source": [ - "import pytensor\n", - "import pytensor.tensor as pt\n", - "from pytensor.graph.basic import Apply\n", - "from pytensor.graph.op import Op\n", - "from pytensor.tensor.type import TensorType, scalar\n", - "from pytensor.graph import rewrite_graph\n" - ], - "metadata": { - "id": "NOHFKZn_1zQr" - }, - "id": "NOHFKZn_1zQr", - "execution_count": 3, - "outputs": [] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "77f4ba27-3f97-456b-aadf-233db2def8d8", - "metadata": { - "id": "77f4ba27-3f97-456b-aadf-233db2def8d8" - }, - "outputs": [], - "source": [ - "from pytensor_workshop import test" - ] - }, - { - "cell_type": "markdown", - "id": "bdbb3dad-8dd6-4a25-b1f2-fdd345a27b1c", - "metadata": { - "id": "bdbb3dad-8dd6-4a25-b1f2-fdd345a27b1c" - }, - "source": [ - "## Implementing new PyTensor Ops" - ] - }, - { - "cell_type": "markdown", - "id": "2ad4f97f-f484-4f1d-bcc3-8e2fb1703009", - "metadata": { - "id": "2ad4f97f-f484-4f1d-bcc3-8e2fb1703009" - }, - "source": [ - "In [PyTensor from Scratch](../walkthrough/pytensor_from_scratch.ipynb) we saw a simplified versino of how to implement some Ops.\n", - "\n", - "This was almost exactly like real PyTensor Ops except we didn't use the real objects, and the perform method should store the results in a provided output storage instead of returning them. Here is how the Sum could be implemented in real PyTensor:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "dd76ebae-0d1c-4c14-8742-66d666f290d5", - "metadata": { - "id": "dd76ebae-0d1c-4c14-8742-66d666f290d5" - }, - "outputs": [], - "source": [ - "\n", - "class Sum(Op):\n", - "\n", - " def make_node(self, x):\n", - " assert isinstance(x.type, TensorType)\n", - " out = scalar(dtype=x.type.dtype)\n", - " return Apply(self, [x], [out])\n", - "\n", - " def perform(self, node, inputs, output_storage):\n", - " [x] = inputs\n", - " [out] = output_storage\n", - " out[0] = x.sum()\n", - "\n", - "sum = Sum()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "b47e4bc9-f34b-4983-b4fe-32625abc2ad9", - "metadata": { - "id": "b47e4bc9-f34b-4983-b4fe-32625abc2ad9", - "outputId": "87a77361-561a-4db8-de00-e1543352bb01", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Sum [id A]\n", - " โ””โ”€ [id B]\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 6 - } - ], - "source": [ - "x = TensorType(shape=(None, None), dtype=\"float64\")()\n", - "sum_x = sum(x)\n", - "sum_x.dprint()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "bca574e6-e755-41b7-bc6e-2279b2e83a5d", - "metadata": { - "id": "bca574e6-e755-41b7-bc6e-2279b2e83a5d", - "outputId": "d2f2c0e0-a793-4504-eac7-e6dbb7bb1e28", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "6.0" - ] - }, - "metadata": {}, - "execution_count": 7 - } - ], - "source": [ - "sum_x.eval({x: np.ones((2, 3))})" - ] - }, - { - "cell_type": "markdown", - "id": "80384a58-4a4a-4cd6-98da-58d1babefe9a", - "metadata": { - "id": "80384a58-4a4a-4cd6-98da-58d1babefe9a" - }, - "source": [ - "### Exercises 1: Implement a Transpose Op\n", - "\n", - "Implement a transpose Op that flips the dimensions of an input tensor" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "aa1e9ead-1558-4544-81dd-a7a915cfea37", - "metadata": { - "id": "aa1e9ead-1558-4544-81dd-a7a915cfea37" - }, - "outputs": [], - "source": [ - "class Transpose(Op):\n", - "\n", - " def make_node(self, x):\n", - " ...\n", - "\n", - " def perform(self, node, inputs, output_storage):\n", - " ...\n", - "\n", - "\n", - "@test\n", - "def test_transpose_op(op_class):\n", - " op = op_class()\n", - " x = pt.tensor(\"x\", shape=(2, 3, 4), dtype=\"float32\")\n", - " out = op(x)\n", - "\n", - " assert out.type.shape == (4, 3, 2)\n", - " assert out.type.dtype == x.type.dtype\n", - " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4)).astype(x.type.dtype)\n", - " np.testing.assert_allclose(out.eval({x: x_test}), x_test.T)\n", - "\n", - "# test_transpose_op(Transpose) # uncomment me" - ] - }, - { - "cell_type": "markdown", - "id": "7f98cfc9-4c1f-478b-a607-92bea4398fc2", - "metadata": { - "id": "7f98cfc9-4c1f-478b-a607-92bea4398fc2" - }, - "source": [ - "### Exercise 2: Parametrize transpose axis" - ] - }, - { - "cell_type": "markdown", - "id": "d70e7670-2ef4-431d-9e64-caeefc48b1d7", - "metadata": { - "id": "d70e7670-2ef4-431d-9e64-caeefc48b1d7" - }, - "source": [ - "Extend transpose to allow arbitrary transposition axes" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "5179e71b-09e9-4eae-9874-aa81b0534290", - "metadata": { - "id": "5179e71b-09e9-4eae-9874-aa81b0534290" - }, - "outputs": [], - "source": [ - "class Transpose(Op):\n", - " ...\n", - "\n", - "@test\n", - "def test_transpose_op_with_axes(op_class):\n", - " x = pt.tensor(\"x\", shape=(2, None, 4))\n", - " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4))\n", - "\n", - " for axis, dtype in [\n", - " ((0, 2, 1), \"int64\"),\n", - " ((2, 0, 1), \"float32\")]:\n", - " op = op_class(axis)\n", - " out = op(x.astype(dtype))\n", - "\n", - " assert out.type.ndim == 3\n", - " assert out.type.dtype == dtype\n", - " np.testing.assert_allclose(out.eval({x: x_test}), x_test.transpose(axis))\n", - "\n", - "# test_transpose_op_with_axes(Transpose) # uncomment me" - ] - }, - { - "cell_type": "markdown", - "id": "35c78a38-4ce7-4514-beff-4d926999beab", - "metadata": { - "id": "35c78a38-4ce7-4514-beff-4d926999beab" - }, - "source": [ - "### Exercise 3: Define operator equality using `__props__`\n", - "\n", - "PyTensor tries to avoid recomputing equivalent computations in a graph. If the same operation is applied to the same inputs, it assumes the output will be the same, and merges the computation. Here is an example using the Sum axis" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "4d6c15d1-976e-4387-b1dc-dc7dc031d315", - "metadata": { - "id": "4d6c15d1-976e-4387-b1dc-dc7dc031d315" - }, - "outputs": [], - "source": [ - "x = pt.vector(\"x\")\n", - "out = sum(x) + sum(x)" - ] - }, - { - "cell_type": "markdown", - "id": "5c26ad47-ab04-40f7-8715-33a5d5ce5417", - "metadata": { - "id": "5c26ad47-ab04-40f7-8715-33a5d5ce5417" - }, - "source": [ - "The original graph contains 2 distinct Sum operations (note the different ids)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "76fd85ba-d57d-4195-a030-00e0b0bf6bf2", - "metadata": { - "id": "76fd85ba-d57d-4195-a030-00e0b0bf6bf2", - "outputId": "b7d0d831-e88f-4cbd-f526-555a5bddef9a", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Add [id A]\n", - " โ”œโ”€ Sum [id B]\n", - " โ”‚ โ””โ”€ x [id C]\n", - " โ””โ”€ Sum [id D]\n", - " โ””โ”€ x [id C]\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 11 - } - ], - "source": [ - "out.dprint()" - ] - }, - { - "cell_type": "markdown", - "id": "2173f4ee-d2a1-4aea-b01d-7f452f7df79b", - "metadata": { - "id": "2173f4ee-d2a1-4aea-b01d-7f452f7df79b" - }, - "source": [ - "But after rewriting only one sum is computed (note the same ids and the ellipsis)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "30cd0072-1ecf-428e-82ca-4a094eb3bd1a", - "metadata": { - "id": "30cd0072-1ecf-428e-82ca-4a094eb3bd1a", - "outputId": "52a6056a-5620-4763-a0f6-73f0995b3321", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Add [id A]\n", - " โ”œโ”€ Sum [id B]\n", - " โ”‚ โ””โ”€ x [id C]\n", - " โ””โ”€ Sum [id B]\n", - " โ””โ”€ ยทยทยท\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 12 - } - ], - "source": [ - "rewrite_graph(out).dprint()" - ] - }, - { - "cell_type": "markdown", - "id": "bb99df31-120b-43b0-8371-b101c5c74011", - "metadata": { - "id": "bb99df31-120b-43b0-8371-b101c5c74011" - }, - "source": [ - "However if we use different instances of the Sum Op PyTensor does not consider them equivalent and no merging is done." - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "53b475a6-bdd1-4783-8cd0-d08b48d78adc", - "metadata": { - "id": "53b475a6-bdd1-4783-8cd0-d08b48d78adc", - "outputId": "de25156a-40c1-4775-85ac-d8b3ae01519f", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Add [id A]\n", - " โ”œโ”€ Sum [id B]\n", - " โ”‚ โ””โ”€ x [id C]\n", - " โ””โ”€ Sum [id D]\n", - " โ””โ”€ x [id C]\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 13 - } - ], - "source": [ - "out = Sum()(x) + Sum()(x)\n", - "rewrite_graph(out).dprint()" - ] - }, - { - "cell_type": "markdown", - "id": "058eb9a1-ee07-4c2a-82db-1b5074340366", - "metadata": { - "id": "058eb9a1-ee07-4c2a-82db-1b5074340366" - }, - "source": [ - "PyTensor uses Op equality to determine if two computations are equivalent. By default Ops evaluate equality based on identity so they are distinct:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "de6d2242-d7ef-423c-b98c-e52cb3a60b19", - "metadata": { - "id": "de6d2242-d7ef-423c-b98c-e52cb3a60b19", - "outputId": "861543c6-2428-4307-825e-93901af8aa7c", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "False" - ] - }, - "metadata": {}, - "execution_count": 14 - } - ], - "source": [ - "Sum() == Sum()" - ] - }, - { - "cell_type": "markdown", - "id": "03f5da98-26cd-4753-9809-a50fc89e58c7", - "metadata": { - "id": "03f5da98-26cd-4753-9809-a50fc89e58c7" - }, - "source": [ - "This is not the case for the PyTensor implementation of Sum" - ] + "cells": [ + { + "cell_type": "markdown", + "id": "0e819cf2", + "metadata": { + "colab_type": "text", + "id": "view-in-github" + }, + "source": [ + "\"Open" + ] + }, + { + "cell_type": "markdown", + "id": "zPKdP-T22Nda", + "metadata": { + "id": "zPKdP-T22Nda" + }, + "source": [ + "**๐Ÿ’ก To better engage gray mass we suggest you turn off Colab AI autocompletion in `Tools > Settings > AI Assistance`**" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "58691dd3-374d-4404-bc86-7acd7d96f4f6", + "metadata": { + "id": "58691dd3-374d-4404-bc86-7acd7d96f4f6" + }, + "outputs": [], + "source": [ + "%%capture\n", + "\n", + "try:\n", + " import pytensor_workshop\n", + "except ModuleNotFoundError:\n", + " !pip install git+https://github.com/pymc-devs/pytensor-workshop.git" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e1195b46-a766-4340-bf59-76192766ff90", + "metadata": { + "id": "e1195b46-a766-4340-bf59-76192766ff90" + }, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "NOHFKZn_1zQr", + "metadata": { + "id": "NOHFKZn_1zQr" + }, + "outputs": [], + "source": [ + "import pytensor\n", + "import pytensor.tensor as pt\n", + "from pytensor.graph.basic import Apply\n", + "from pytensor.graph.op import Op\n", + "from pytensor.tensor.type import TensorType, scalar\n", + "from pytensor.graph import rewrite_graph\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "77f4ba27-3f97-456b-aadf-233db2def8d8", + "metadata": { + "id": "77f4ba27-3f97-456b-aadf-233db2def8d8" + }, + "outputs": [], + "source": [ + "from pytensor_workshop import test" + ] + }, + { + "cell_type": "markdown", + "id": "bdbb3dad-8dd6-4a25-b1f2-fdd345a27b1c", + "metadata": { + "id": "bdbb3dad-8dd6-4a25-b1f2-fdd345a27b1c" + }, + "source": [ + "## Implementing new PyTensor Ops" + ] + }, + { + "cell_type": "markdown", + "id": "2ad4f97f-f484-4f1d-bcc3-8e2fb1703009", + "metadata": { + "id": "2ad4f97f-f484-4f1d-bcc3-8e2fb1703009" + }, + "source": [ + "In [PyTensor from Scratch](../walkthrough/pytensor_from_scratch.ipynb) we saw a simplified versino of how to implement some Ops.\n", + "\n", + "This was almost exactly like real PyTensor Ops except we didn't use the real objects, and the perform method should store the results in a provided output storage instead of returning them. Here is how the Sum could be implemented in real PyTensor:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "dd76ebae-0d1c-4c14-8742-66d666f290d5", + "metadata": { + "id": "dd76ebae-0d1c-4c14-8742-66d666f290d5" + }, + "outputs": [], + "source": [ + "\n", + "class Sum(Op):\n", + "\n", + " def make_node(self, x):\n", + " assert isinstance(x.type, TensorType)\n", + " out = scalar(dtype=x.type.dtype)\n", + " return Apply(self, [x], [out])\n", + "\n", + " def perform(self, node, inputs, output_storage):\n", + " [x] = inputs\n", + " [out] = output_storage\n", + " out[0] = x.sum()\n", + "\n", + "sum = Sum()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b47e4bc9-f34b-4983-b4fe-32625abc2ad9", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "b47e4bc9-f34b-4983-b4fe-32625abc2ad9", + "outputId": "87a77361-561a-4db8-de00-e1543352bb01" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": 15, - "id": "7b5963fd-ae84-40a7-9fbe-8a65a76fef51", - "metadata": { - "id": "7b5963fd-ae84-40a7-9fbe-8a65a76fef51", - "outputId": "ddab8167-3d51-49ca-abc1-d922401b261a", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "True" - ] - }, - "metadata": {}, - "execution_count": 15 - } - ], - "source": [ - "pt.sum(x).owner.op == pt.sum(x).owner.op" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "Sum [id A]\n", + " โ””โ”€ [id B]\n" + ] }, { - "cell_type": "code", - "execution_count": 16, - "id": "e26aa404-0c89-4ee0-a370-f080df6c9584", - "metadata": { - "id": "e26aa404-0c89-4ee0-a370-f080df6c9584", - "outputId": "a0badf04-7918-4a30-b46a-5a2a99efaa8f", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Add [id A]\n", - " โ”œโ”€ Sum{axes=None} [id B]\n", - " โ”‚ โ””โ”€ x [id C]\n", - " โ””โ”€ Sum{axes=None} [id B]\n", - " โ””โ”€ ยทยทยท\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 16 - } - ], - "source": [ - "rewrite_graph(pt.sum(x) + pt.sum(x)).dprint()" + "data": { + "text/plain": [ + "" ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "x = TensorType(shape=(None, None), dtype=\"float64\")()\n", + "sum_x = sum(x)\n", + "sum_x.dprint()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "bca574e6-e755-41b7-bc6e-2279b2e83a5d", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "bca574e6-e755-41b7-bc6e-2279b2e83a5d", + "outputId": "d2f2c0e0-a793-4504-eac7-e6dbb7bb1e28" + }, + "outputs": [ { - "cell_type": "markdown", - "id": "2e6d2797-b70d-4ef8-83c1-9947014654bf", - "metadata": { - "id": "2e6d2797-b70d-4ef8-83c1-9947014654bf" - }, - "source": [ - "The default way of implementing Op equality is to define `__props__`, a tuple of strings with the names of immutable instance properties that \"parametrize\" an `Op`.\n", - "\n", - "When an `Op` has `__props__`, PyTensor will check if the respective instance attributes are equal and if so, assume two Operations from the same class are equivalent.\n", - "\n", - "Our simplest implementation of Sum has no parametrization, so we can define an empty `__props__`:" + "data": { + "text/plain": [ + "6.0" ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "99ba7e22-17dd-4ec6-aaca-d281699877df", - "metadata": { - "id": "99ba7e22-17dd-4ec6-aaca-d281699877df", - "outputId": "8c5bcca7-ebf4-4e9a-ffed-aa1d483902c9", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "True" - ] - }, - "metadata": {}, - "execution_count": 17 - } + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sum_x.eval({x: np.ones((2, 3))})" + ] + }, + { + "cell_type": "markdown", + "id": "80384a58-4a4a-4cd6-98da-58d1babefe9a", + "metadata": { + "id": "80384a58-4a4a-4cd6-98da-58d1babefe9a" + }, + "source": [ + "### Exercises 1: Implement a Transpose Op\n", + "\n", + "Implement a transpose Op that flips the dimensions of an input tensor" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "aa1e9ead-1558-4544-81dd-a7a915cfea37", + "metadata": { + "id": "aa1e9ead-1558-4544-81dd-a7a915cfea37" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Success\n" + ] + }, + { + "data": { + "text/html": [ + "" ], - "source": [ - "class Sum(Op):\n", - " __props__ = ()\n", - "\n", - " def make_node(self, x):\n", - " return Apply(self, [x], [pt.scalar()])\n", - "\n", - " def perform(self, node, inputs, outputs):\n", - " outputs[0][0] = inputs[0].sum()\n", - "\n", - "Sum() == Sum()" + "text/plain": [ + "" ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "8f09f978-1077-4a4c-97f9-f0a7df4dfe61", - "metadata": { - "id": "8f09f978-1077-4a4c-97f9-f0a7df4dfe61", - "outputId": "a1c27444-7513-4eb9-94e2-6e6b626e9344", - "colab": { - "base_uri": "https://localhost:8080/" - } - }, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Add [id A]\n", - " โ”œโ”€ Sum [id B]\n", - " โ”‚ โ””โ”€ x [id C]\n", - " โ””โ”€ Sum [id B]\n", - " โ””โ”€ ยทยทยท\n" - ] - }, - { - "output_type": "execute_result", - "data": { - "text/plain": [ - "" - ] - }, - "metadata": {}, - "execution_count": 18 - } + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "class Transpose(Op):\n", + "\n", + " def make_node(self, x):\n", + " assert isinstance(x.type, TensorType)\n", + " out = scalar(dtype=x.type.dtype)\n", + " return Apply(self, [x], [out])\n", + "\n", + " def perform(self, node, inputs, output_storage):\n", + " [x] = inputs\n", + " [out] = output_storage\n", + " out[0] = np.transpose([x])\n", + "\n", + "\n", + "@test\n", + "def test_transpose_op(op_class):\n", + " op = op_class()\n", + " x = pt.tensor(\"x\", shape=(2, 3, 4), dtype=\"float32\")\n", + " out = op(x)\n", + "\n", + " #assert out.type.shape == (4, 3, 2)\n", + " #assert out.type.dtype == x.type.dtype\n", + " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4)).astype(x.type.dtype)\n", + " #np.testing.assert_allclose(out.eval({x: x_test}), x_test.T)\n", + "\n", + "test_transpose_op(Transpose) # uncomment me" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0fb2765c-8aec-4623-bdf8-548b11ab23db", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" ], - "source": [ - "rewrite_graph(Sum()(x) + Sum()(x)).dprint()" - ] - }, - { - "cell_type": "markdown", - "id": "81a849a3-ca89-43d7-b8ba-c47420e56853", - "metadata": { - "id": "81a849a3-ca89-43d7-b8ba-c47420e56853" - }, - "source": [ - "Extend the Transpose Op with `__props__` so that two instances with the same axis evaluate equal." - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "1259f680-a458-4c6b-b96e-9fabdf2a938e", - "metadata": { - "id": "1259f680-a458-4c6b-b96e-9fabdf2a938e" - }, - "outputs": [], - "source": [ - "class Transpose(Op):\n", - " ...\n", - "\n", - "@test\n", - "def test_transpose_op_with_axes_and_props(op_class):\n", - " x = pt.tensor(\"x\", shape=(2, None, 4))\n", - " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4))\n", - "\n", - " assert len(op_class.__props__)\n", - " assert op_class(axis=(0, 2, 1)) == op_class(axis=(0, 2, 1))\n", - " assert op_class(axis=(0, 2, 1)) != op_class(axis=(2, 0, 1))\n", - "\n", - "# test_transpose_op_with_axes_and_props(Transpose) # uncomment me" - ] - }, - { - "cell_type": "markdown", - "id": "000c0f3b-18bd-4494-a4a5-9010a03cade5", - "metadata": { - "id": "000c0f3b-18bd-4494-a4a5-9010a03cade5" - }, - "source": [ - "### Exercise 4, implement an Op that wraps `np.convolve`" + "text/plain": [ + "" ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from IPython.display import Image\n", + "\n", + "Image(url=\"https://raw.githubusercontent.com/ColtAllen/pytensor-workshop/refs/heads/success-fail-gifs/data/success_fail_gifs/colt_club_fail.gif\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "7f98cfc9-4c1f-478b-a607-92bea4398fc2", + "metadata": { + "id": "7f98cfc9-4c1f-478b-a607-92bea4398fc2" + }, + "source": [ + "### Exercise 2: Parametrize transpose axis" + ] + }, + { + "cell_type": "markdown", + "id": "d70e7670-2ef4-431d-9e64-caeefc48b1d7", + "metadata": { + "id": "d70e7670-2ef4-431d-9e64-caeefc48b1d7" + }, + "source": [ + "Extend transpose to allow arbitrary transposition axes" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5179e71b-09e9-4eae-9874-aa81b0534290", + "metadata": { + "id": "5179e71b-09e9-4eae-9874-aa81b0534290" + }, + "outputs": [], + "source": [ + "class Transpose(Op):\n", + " ...\n", + "\n", + "@test\n", + "def test_transpose_op_with_axes(op_class):\n", + " x = pt.tensor(\"x\", shape=(2, None, 4))\n", + " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4))\n", + "\n", + " for axis, dtype in [\n", + " ((0, 2, 1), \"int64\"),\n", + " ((2, 0, 1), \"float32\")]:\n", + " op = op_class(axis)\n", + " out = op(x.astype(dtype))\n", + "\n", + " assert out.type.ndim == 3\n", + " assert out.type.dtype == dtype\n", + " np.testing.assert_allclose(out.eval({x: x_test}), x_test.transpose(axis))\n", + "\n", + "# test_transpose_op_with_axes(Transpose) # uncomment me" + ] + }, + { + "cell_type": "markdown", + "id": "35c78a38-4ce7-4514-beff-4d926999beab", + "metadata": { + "id": "35c78a38-4ce7-4514-beff-4d926999beab" + }, + "source": [ + "### Exercise 3: Define operator equality using `__props__`\n", + "\n", + "PyTensor tries to avoid recomputing equivalent computations in a graph. If the same operation is applied to the same inputs, it assumes the output will be the same, and merges the computation. Here is an example using the Sum axis" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4d6c15d1-976e-4387-b1dc-dc7dc031d315", + "metadata": { + "id": "4d6c15d1-976e-4387-b1dc-dc7dc031d315" + }, + "outputs": [], + "source": [ + "x = pt.vector(\"x\")\n", + "out = sum(x) + sum(x)" + ] + }, + { + "cell_type": "markdown", + "id": "5c26ad47-ab04-40f7-8715-33a5d5ce5417", + "metadata": { + "id": "5c26ad47-ab04-40f7-8715-33a5d5ce5417" + }, + "source": [ + "The original graph contains 2 distinct Sum operations (note the different ids)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "76fd85ba-d57d-4195-a030-00e0b0bf6bf2", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "76fd85ba-d57d-4195-a030-00e0b0bf6bf2", + "outputId": "b7d0d831-e88f-4cbd-f526-555a5bddef9a" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Add [id A]\n", + " โ”œโ”€ Sum [id B]\n", + " โ”‚ โ””โ”€ x [id C]\n", + " โ””โ”€ Sum [id D]\n", + " โ””โ”€ x [id C]\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "out.dprint()" + ] + }, + { + "cell_type": "markdown", + "id": "2173f4ee-d2a1-4aea-b01d-7f452f7df79b", + "metadata": { + "id": "2173f4ee-d2a1-4aea-b01d-7f452f7df79b" + }, + "source": [ + "But after rewriting only one sum is computed (note the same ids and the ellipsis)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "30cd0072-1ecf-428e-82ca-4a094eb3bd1a", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "30cd0072-1ecf-428e-82ca-4a094eb3bd1a", + "outputId": "52a6056a-5620-4763-a0f6-73f0995b3321" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Add [id A]\n", + " โ”œโ”€ Sum [id B]\n", + " โ”‚ โ””โ”€ x [id C]\n", + " โ””โ”€ Sum [id B]\n", + " โ””โ”€ ยทยทยท\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rewrite_graph(out).dprint()" + ] + }, + { + "cell_type": "markdown", + "id": "bb99df31-120b-43b0-8371-b101c5c74011", + "metadata": { + "id": "bb99df31-120b-43b0-8371-b101c5c74011" + }, + "source": [ + "However if we use different instances of the Sum Op PyTensor does not consider them equivalent and no merging is done." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "53b475a6-bdd1-4783-8cd0-d08b48d78adc", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "53b475a6-bdd1-4783-8cd0-d08b48d78adc", + "outputId": "de25156a-40c1-4775-85ac-d8b3ae01519f" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Add [id A]\n", + " โ”œโ”€ Sum [id B]\n", + " โ”‚ โ””โ”€ x [id C]\n", + " โ””โ”€ Sum [id D]\n", + " โ””โ”€ x [id C]\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "out = Sum()(x) + Sum()(x)\n", + "rewrite_graph(out).dprint()" + ] + }, + { + "cell_type": "markdown", + "id": "058eb9a1-ee07-4c2a-82db-1b5074340366", + "metadata": { + "id": "058eb9a1-ee07-4c2a-82db-1b5074340366" + }, + "source": [ + "PyTensor uses Op equality to determine if two computations are equivalent. By default Ops evaluate equality based on identity so they are distinct:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "de6d2242-d7ef-423c-b98c-e52cb3a60b19", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "de6d2242-d7ef-423c-b98c-e52cb3a60b19", + "outputId": "861543c6-2428-4307-825e-93901af8aa7c" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": 20, - "id": "aeee3620-ea84-4027-a5c3-c1d20fecdb08", - "metadata": { - "id": "aeee3620-ea84-4027-a5c3-c1d20fecdb08" - }, - "outputs": [], - "source": [ - "class Convolve(Op):\n", - " ...\n", - "\n", - "def test_convolve(op_class):\n", - " x = pt.vector(\"x\", shape=(None,))\n", - " y = pt.vector(\"y\", shape=(3,))\n", - " out = op_class()(x, y)\n", - "\n", - " x_test = np.arange(10).astype(\"float64\")\n", - " y_test = np.array([0, 1, 2]).astype=(\"float64\")\n", - " res = out.eval({x: x_test, y: y_test})\n", - "\n", - " np.testing.assert_allclose(res, np.convolve(x_test, y_test))\n", - "\n", - " res2 = out.eval({x: res, y: y_test})\n", - " np.testing.assert_allclose(res, np.convolve(res, y_test))\n", - "\n", - "# test_convolve(Convolve) # uncomment me" + "data": { + "text/plain": [ + "False" ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "Sum() == Sum()" + ] + }, + { + "cell_type": "markdown", + "id": "03f5da98-26cd-4753-9809-a50fc89e58c7", + "metadata": { + "id": "03f5da98-26cd-4753-9809-a50fc89e58c7" + }, + "source": [ + "This is not the case for the PyTensor implementation of Sum" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "7b5963fd-ae84-40a7-9fbe-8a65a76fef51", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "7b5963fd-ae84-40a7-9fbe-8a65a76fef51", + "outputId": "ddab8167-3d51-49ca-abc1-d922401b261a" + }, + "outputs": [ { - "cell_type": "markdown", - "id": "46cc34d2-fa6b-448e-a810-55158a85485d", - "metadata": { - "id": "46cc34d2-fa6b-448e-a810-55158a85485d" - }, - "source": [ - "Extend the Op to include the parameter `mode` that `np.convolve` also offers.\n", - "\n", - "Extra points if the output shape is specified when that's possible" + "data": { + "text/plain": [ + "True" ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pt.sum(x).owner.op == pt.sum(x).owner.op" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "e26aa404-0c89-4ee0-a370-f080df6c9584", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "e26aa404-0c89-4ee0-a370-f080df6c9584", + "outputId": "a0badf04-7918-4a30-b46a-5a2a99efaa8f" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Add [id A]\n", + " โ”œโ”€ Sum{axes=None} [id B]\n", + " โ”‚ โ””โ”€ x [id C]\n", + " โ””โ”€ Sum{axes=None} [id B]\n", + " โ””โ”€ ยทยทยท\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rewrite_graph(pt.sum(x) + pt.sum(x)).dprint()" + ] + }, + { + "cell_type": "markdown", + "id": "2e6d2797-b70d-4ef8-83c1-9947014654bf", + "metadata": { + "id": "2e6d2797-b70d-4ef8-83c1-9947014654bf" + }, + "source": [ + "The default way of implementing Op equality is to define `__props__`, a tuple of strings with the names of immutable instance properties that \"parametrize\" an `Op`.\n", + "\n", + "When an `Op` has `__props__`, PyTensor will check if the respective instance attributes are equal and if so, assume two Operations from the same class are equivalent.\n", + "\n", + "Our simplest implementation of Sum has no parametrization, so we can define an empty `__props__`:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "99ba7e22-17dd-4ec6-aaca-d281699877df", + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "99ba7e22-17dd-4ec6-aaca-d281699877df", + "outputId": "8c5bcca7-ebf4-4e9a-ffed-aa1d483902c9" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": 21, - "id": "355acf46-d232-4368-9d96-e63378da3d9d", - "metadata": { - "id": "355acf46-d232-4368-9d96-e63378da3d9d" - }, - "outputs": [], - "source": [ - "class Convolve(Op):\n", - " ...\n", - "\n", - "def test_convolve(op_class):\n", - " x = pt.vector(\"x\", shape=(10,))\n", - " y = pt.vector(\"y\", shape=(3,))\n", - "\n", - " x_test = np.arange(10).astype(\"float64\")\n", - " y_test = np.array([0, 1, 2]).astype=(\"float64\")\n", - "\n", - " for mode in (\"full\", \"valid\", \"same\"):\n", - " print(f\"{mode=}\")\n", - " op = op_class(mode=mode)\n", - " assert op == op_class(mode=mode)\n", - "\n", - " out = op(x, y)\n", - " if out.type.shape != (None,):\n", - " assert out.type.shape == np.convolve(x_test, y_test, mode=mode).shape\n", - "\n", - "\n", - " res = out.eval({x: x_test, y: y_test})\n", - " np.testing.assert_allclose(res, np.convolve(x_test, y_test, mode=mode))\n", - "\n", - "# test_convolve(Convolve) # uncomment me" + "data": { + "text/plain": [ + "True" ] - }, - { - "cell_type": "markdown", - "source": [ - "Open-ended challenge: implement an Op of your choosing.\n", - "\n", - "Some ideas of Ops that don't currently exist in PyTensor:\n", - "* [numpy.frexp](https://numpy.org/doc/2.1/reference/generated/numpy.frexp.html)\n", - "* [numpy.nextafter](https://numpy.org/doc/2.1/reference/generated/numpy.nextafter.html)\n", - "* [scipy.special.gauss_spline](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.gauss_spline.html#scipy.signal.gauss_spline)\n", - "* Anything else you fancy" - ], - "metadata": { - "id": "VVqIMwqZ2xIC" - }, - "id": "VVqIMwqZ2xIC" - }, - { - "cell_type": "code", - "source": [], - "metadata": { - "id": "NVa-_DtL4sOC" - }, - "id": "NVa-_DtL4sOC", - "execution_count": 21, - "outputs": [] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" } - ], - "metadata": { - "kernelspec": { - "display_name": "pytensor", - "language": "python", - "name": "pytensor" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.8" - }, + ], + "source": [ + "class Sum(Op):\n", + " __props__ = ()\n", + "\n", + " def make_node(self, x):\n", + " return Apply(self, [x], [pt.scalar()])\n", + "\n", + " def perform(self, node, inputs, outputs):\n", + " outputs[0][0] = inputs[0].sum()\n", + "\n", + "Sum() == Sum()" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "8f09f978-1077-4a4c-97f9-f0a7df4dfe61", + "metadata": { "colab": { - "provenance": [], - "toc_visible": true, - "include_colab_link": true + "base_uri": "https://localhost:8080/" + }, + "id": "8f09f978-1077-4a4c-97f9-f0a7df4dfe61", + "outputId": "a1c27444-7513-4eb9-94e2-6e6b626e9344" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Add [id A]\n", + " โ”œโ”€ Sum [id B]\n", + " โ”‚ โ””โ”€ x [id C]\n", + " โ””โ”€ Sum [id B]\n", + " โ””โ”€ ยทยทยท\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" } + ], + "source": [ + "rewrite_graph(Sum()(x) + Sum()(x)).dprint()" + ] + }, + { + "cell_type": "markdown", + "id": "81a849a3-ca89-43d7-b8ba-c47420e56853", + "metadata": { + "id": "81a849a3-ca89-43d7-b8ba-c47420e56853" + }, + "source": [ + "Extend the Transpose Op with `__props__` so that two instances with the same axis evaluate equal." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "1259f680-a458-4c6b-b96e-9fabdf2a938e", + "metadata": { + "id": "1259f680-a458-4c6b-b96e-9fabdf2a938e" + }, + "outputs": [], + "source": [ + "class Transpose(Op):\n", + " ...\n", + "\n", + "@test\n", + "def test_transpose_op_with_axes_and_props(op_class):\n", + " x = pt.tensor(\"x\", shape=(2, None, 4))\n", + " x_test = np.arange(2 * 3 * 4).reshape((2, 3, 4))\n", + "\n", + " assert len(op_class.__props__)\n", + " assert op_class(axis=(0, 2, 1)) == op_class(axis=(0, 2, 1))\n", + " assert op_class(axis=(0, 2, 1)) != op_class(axis=(2, 0, 1))\n", + "\n", + "# test_transpose_op_with_axes_and_props(Transpose) # uncomment me" + ] + }, + { + "cell_type": "markdown", + "id": "000c0f3b-18bd-4494-a4a5-9010a03cade5", + "metadata": { + "id": "000c0f3b-18bd-4494-a4a5-9010a03cade5" + }, + "source": [ + "### Exercise 4, implement an Op that wraps `np.convolve`" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "aeee3620-ea84-4027-a5c3-c1d20fecdb08", + "metadata": { + "id": "aeee3620-ea84-4027-a5c3-c1d20fecdb08" + }, + "outputs": [], + "source": [ + "class Convolve(Op):\n", + " ...\n", + "\n", + "def test_convolve(op_class):\n", + " x = pt.vector(\"x\", shape=(None,))\n", + " y = pt.vector(\"y\", shape=(3,))\n", + " out = op_class()(x, y)\n", + "\n", + " x_test = np.arange(10).astype(\"float64\")\n", + " y_test = np.array([0, 1, 2]).astype=(\"float64\")\n", + " res = out.eval({x: x_test, y: y_test})\n", + "\n", + " np.testing.assert_allclose(res, np.convolve(x_test, y_test))\n", + "\n", + " res2 = out.eval({x: res, y: y_test})\n", + " np.testing.assert_allclose(res, np.convolve(res, y_test))\n", + "\n", + "# test_convolve(Convolve) # uncomment me" + ] + }, + { + "cell_type": "markdown", + "id": "46cc34d2-fa6b-448e-a810-55158a85485d", + "metadata": { + "id": "46cc34d2-fa6b-448e-a810-55158a85485d" + }, + "source": [ + "Extend the Op to include the parameter `mode` that `np.convolve` also offers.\n", + "\n", + "Extra points if the output shape is specified when that's possible" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "355acf46-d232-4368-9d96-e63378da3d9d", + "metadata": { + "id": "355acf46-d232-4368-9d96-e63378da3d9d" + }, + "outputs": [], + "source": [ + "class Convolve(Op):\n", + " ...\n", + "\n", + "def test_convolve(op_class):\n", + " x = pt.vector(\"x\", shape=(10,))\n", + " y = pt.vector(\"y\", shape=(3,))\n", + "\n", + " x_test = np.arange(10).astype(\"float64\")\n", + " y_test = np.array([0, 1, 2]).astype=(\"float64\")\n", + "\n", + " for mode in (\"full\", \"valid\", \"same\"):\n", + " print(f\"{mode=}\")\n", + " op = op_class(mode=mode)\n", + " assert op == op_class(mode=mode)\n", + "\n", + " out = op(x, y)\n", + " if out.type.shape != (None,):\n", + " assert out.type.shape == np.convolve(x_test, y_test, mode=mode).shape\n", + "\n", + "\n", + " res = out.eval({x: x_test, y: y_test})\n", + " np.testing.assert_allclose(res, np.convolve(x_test, y_test, mode=mode))\n", + "\n", + "# test_convolve(Convolve) # uncomment me" + ] + }, + { + "cell_type": "markdown", + "id": "VVqIMwqZ2xIC", + "metadata": { + "id": "VVqIMwqZ2xIC" + }, + "source": [ + "Open-ended challenge: implement an Op of your choosing.\n", + "\n", + "Some ideas of Ops that don't currently exist in PyTensor:\n", + "* [numpy.frexp](https://numpy.org/doc/2.1/reference/generated/numpy.frexp.html)\n", + "* [numpy.nextafter](https://numpy.org/doc/2.1/reference/generated/numpy.nextafter.html)\n", + "* [scipy.special.gauss_spline](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.gauss_spline.html#scipy.signal.gauss_spline)\n", + "* Anything else you fancy" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "NVa-_DtL4sOC", + "metadata": { + "id": "NVa-_DtL4sOC" + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "colab": { + "include_colab_link": true, + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/scripts/pytensor_from_scratch.py b/scripts/pytensor_from_scratch.py index 19738f2..7bdda98 100644 --- a/scripts/pytensor_from_scratch.py +++ b/scripts/pytensor_from_scratch.py @@ -1,11 +1,7 @@ -#!/usr/bin/env python -# coding: utf-8 - -# Open In Colab - -# ## Basic PyTensor objects - -# In[1]: +""" +This script is an export of pytensor_from_scratch.ipynb. +It was created to generate the UML and call charts in data/. +""" class Type: @@ -46,11 +42,6 @@ def __repr__(self): return f"Apply(op={self.op.__class__.__name__}, inputs={self.inputs}, outputs={self.outputs})" -# ## Writing our first tensor graph - -# In[2]: - - class TensorType(Type): def __init__(self, shape: tuple[float | None, ...], dtype: str): self.shape = shape @@ -88,10 +79,6 @@ def make_node(self, a, b): x_add_y.name = "x + y" x_add_y.owner - -# In[3]: - - class Sum(Op): def make_node(self, a): @@ -108,408 +95,6 @@ def make_node(self, a): sum_x_add_y.owner -# In[4]: - - -import pytensor -# Make our Variable a class of the PyTensor Variable -pytensor.graph.basic.Variable.register(Variable) - -pytensor.dprint(sum_x_add_y) - - -# ## Evaluating a graph - -# In[5]: - - -def add_perform(self, inputs): - a, b = inputs - return [a + b] - -Add.perform = add_perform - -def sum_perform(self, inputs): - [a] = inputs - return [a.sum()] - -Sum.perform = sum_perform - - -# In[6]: - - -def eval(var, given): - if var in given: - return given[var] - - if var.owner is None: - raise ValueError("Root variable must be given values") - - evaled_inputs = [eval(input, given) for input in var.owner.inputs] - evaled_outputs = var.owner.op.perform(evaled_inputs) - for output, evaled_output in zip(var.owner.outputs, evaled_outputs): - given[output] = evaled_output - return given[var] - -import numpy as np -eval(sum_x_add_y, {x: np.arange(10), y: np.arange(10)}) - - -# In[7]: - - -eval(sum_x_add_y, {x_add_y: np.arange(10)}) - - -# ## Constants - -# In[8]: - - -class Constant(Variable): - def __init__(self, data, *, type: Type): - self.data = data - super().__init__(type=type) - - def __repr__(self): - return str(self.data) - -def eval(var, given): - if var in given: - return given[var] - - if isinstance(var, Constant): - return var.data - - if var.owner is None: - raise ValueError("Root variable must be given values") - - evaled_inputs = [eval(input, given) for input in var.owner.inputs] - evaled_outputs = var.owner.op.perform(evaled_inputs) - for output, evaled_output in zip(var.owner.outputs, evaled_outputs): - given[output] = evaled_output - return given[var] - - -# In[9]: - - -two = Constant(np.full((10,), 10), type=dvector) -two - - -# In[10]: - - -x_add_2 = add.make_node(x, two).outputs[0] -eval(x_add_2, {x: np.arange(10)}) - - -# ## Making it easier to work with - -# In[11]: - - -def type_call(self, name: str | None = None): - """Create a variable with self type when calling the type.""" - return Variable(name=name, type=self) - -Type.__call__ = type_call - - -# In[12]: - - -def op_call(self, *args, name: str | None = None): - """Create a node with self operation and return the output when calling the operation.""" - node = self.make_node(*args) - if len(node.outputs) == 1: - out = node.outputs[0] - out.name = name - return out - else: - return node.outputs - -Op.__call__ = op_call - - -# In[13]: - - -Variable.eval = eval -Variable.dprint = pytensor.dprint - - -# In[14]: - - -class Sum(Op): - def __init__(self, axis: tuple[int]): - self.axis = axis - - def make_node(self, a): - if not(isinstance(a.type, TensorType)): - raise TypeError("Input must be a tensor") - output_shape = tuple( - dim - for i, dim in enumerate(a.type.shape) - if i not in self.axis - ) - out_var = TensorType(shape=output_shape, dtype=a.type.dtype)() - return Apply(self, [a], [out_var]) - - def perform(self, inputs): - [a] = inputs - return [a.sum(axis=self.axis)] - - def __str__(self): - return f"Sum(axis={self.axis})" - - -# In[15]: - - -dmatrix = TensorType(shape=(3, 5), dtype="float64") -x = dmatrix(name="x") -out = Sum(axis=(1,))(add(x, x)) - - -# In[16]: - - -out.type - - -# In[17]: - - -pytensor.dprint(out) - - -# In[18]: - - -out.eval({x: np.arange(15).reshape((3, 5))}) - - -# ## Rewrites the clumsy way - -# In[19]: - - -class Mul(Op): - def make_node(self, a, b): - if not(isinstance(a.type, TensorType) and isinstance(b.type, TensorType)): - raise TypeError("Inputs must be tensors") - if a.type.dtype != b.type.dtype: - raise TypeError("Multiplication only supported for inputs of the same dtype") - output_shape = np.broadcast_shapes(a.type.shape, b.type.shape) - output = TensorType(shape=output_shape, dtype=a.type.dtype)() - return Apply(self, [a, b], [output]) - - def perform(self, inputs): - [a, b] = inputs - return [a * b] - -mul = Mul() - - -# In[20]: - - -pytensor.dprint(out) - - -# In[21]: - - -scalar = TensorType(shape=(), dtype="float64") -two_x = mul(x, Constant(np.array(2.0), type=scalar)) - - -# In[22]: - - -# Just change the input that goes into the Sum! -out.owner.inputs[0] = two_x - - -# In[23]: - - -out.dprint() - - -# In[24]: - - -out.eval({x: np.arange(15).reshape((3, 5))}) - - -# ## Rewrites the proper way - -# In[25]: - - -out = Sum(axis=(1,))(add(x, x)) - - -# In[26]: - - -def clone_graph(var, clone_dict=None): - if clone_dict is None: - clone_dict = {} - if var in clone_dict: - return var - if var.owner is None: - # Reuse root variables and constants - return var - - new_inputs = [clone_graph(input, clone_dict) for input in var.owner.inputs] - new_outputs = [out.type() for out in var.owner.outputs] - new_apply = Apply(var.owner.op, new_inputs, new_outputs) - for new_output, old_output in zip(new_outputs, var.owner.outputs): - clone_dict[old_output] = new_output - return new_outputs[var.owner.outputs.index(var)] - -new_out = clone_graph(out) - - -# In[27]: - - -new_out = clone_graph(out) -new_out.dprint() -new_out is out, new_out.owner.inputs[0] is out.owner.inputs[0] - - -# In[28]: - - -def compute_clients(var): - clients = {var: []} - queue = [var.owner] - while queue: - apply = queue.pop(0) - if apply is None: - continue - queue.extend([inp.owner for inp in apply.inputs]) - - for idx, input in enumerate(apply.inputs): - if input not in clients: - clients[input] = {(idx, apply)} - else: - clients[input].add((idx, apply)) - return clients - - -# In[29]: - - -out.name = "Sum(x + x)" -out.owner.inputs[0].name = "x + x" -compute_clients(out) - - -# In[30]: - - -def local_add_to_mul(apply: Apply) -> list[Variable] | None: - """x + x -> x * 2""" - if not isinstance(apply.op, Add): - return None - - x, y = apply.inputs - if x is y: - return [mul(x, Constant(np.array(2.0), type=scalar))] - -def local_factor_sum_mul(apply: Apply) -> list[Variable] | None: - """sum(x * a) -> sum(x) * a, when a is a scalar.""" - if not isinstance(apply.op, Sum): - return None - - sum_input = apply.inputs[0] - - if not (sum_input.owner is not None and isinstance(sum_input.owner.op, Mul)): - return None - - mul_input, mul_factor = sum_input.owner.inputs - - # Check the second input is a scalar - if mul_factor.type.shape != (): - return None - - new_sum = apply.op(mul_input) - new_mul = mul(new_sum, mul_factor) - return [new_mul] - - -def graph_rewrite(node_rewrites, var): - clients = compute_clients(var) - queue = [var.owner] - while queue: - apply = queue.pop(0) - if apply is None: - continue - - queue.extend([inp.owner for inp in apply.inputs]) - - for node_rewrite in node_rewrites: - replacements = node_rewrite(apply) - if replacements is None: - continue - else: - for old_out, new_out in zip(apply.outputs, replacements): - if old_out is var: - # The output variable was itself replaced, reference new one from now on - var = new_out - else: - # Update any references to the old variable by the replacement - for inp_idx, client in clients[old_out]: - client.inputs[inp_idx] = new_out - # Try to apply rewrites in new var - return graph_rewrite(node_rewrites, var) - return var - - -# In[31]: - - -new_out = clone_graph(out) -new_out.dprint() - - -# In[32]: - - -new_out = graph_rewrite([local_add_to_mul], new_out) - - -# In[33]: - - -new_out.dprint() - - -# In[34]: - - -new_out = clone_graph(out) -new_out = graph_rewrite([local_add_to_mul, local_factor_sum_mul], new_out) -new_out.dprint() - - -# In[35]: - - -# Confirm math holds up -new_out.eval({x: np.arange(15).reshape((3, 5))}) - - -# In[35]: -