class PolygonWorkflowOp(WorkflowOperation, PolygonOp): name = Str(apply=True) xchannel = Str(apply=True) ychannel = Str(apply=True) vertices = List((Float, Float), apply=True) # there's a bit of a subtlety here: if the vertices were # selected on a plot with scaled axes, we need to apply that # scale function to both the vertices and the data before # looking for path membership xscale = util.ScaleEnum(apply=True) yscale = util.ScaleEnum(apply=True) def default_view(self, **kwargs): return PolygonSelectionView(op=self, **kwargs) def get_notebook_code(self, idx): op = PolygonOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1))
class DensityGatePluginOp(PluginOpMixin, DensityGateOp): handler_factory = Callable(DensityGateHandler) # add "estimate" metadata xchannel = Str(estimate=True) ychannel = Str(estimate=True) keep = util.PositiveCFloat(0.9, allow_zero=False, estimate=True) by = List(Str, estimate=True) xscale = util.ScaleEnum(estimate=True) yscale = util.ScaleEnum(estimate=True) # bits to support the subset editor subset_list = List(ISubset, estimate=True) subset = Property(Str, depends_on="subset_list.str") # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) @on_trait_change('subset_list.str') def _subset_changed(self, obj, name, old, new): self.changed = (Changed.ESTIMATE, ('subset_list', self.subset_list)) def default_view(self, **kwargs): return DensityGatePluginView(op=self, **kwargs) def estimate(self, experiment): super().estimate(experiment, subset=self.subset) self.changed = (Changed.ESTIMATE_RESULT, self) def clear_estimate(self): self._xscale = self._yscale = None self._xbins = np.empty(1) self._ybins = np.empty(1) self._keep_xbins = dict() self._keep_ybins = dict() self._histogram = {} self.changed = (Changed.ESTIMATE_RESULT, self) def should_clear_estimate(self, changed, payload): return True def get_notebook_code(self, idx): op = DensityGateOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1, subset=", subset = " + repr(self.subset) if self.subset else ""))
class DensityGateWorkflowOp(WorkflowOperation, DensityGateOp): # add 'estimate' and 'apply' metadata name = Str(apply = True) xchannel = Str(estimate = True) ychannel = Str(estimate = True) keep = util.PositiveCFloat(0.9, allow_zero = False, estimate = True) by = List(Str, estimate = True) xscale = util.ScaleEnum(estimate = True) yscale = util.ScaleEnum(estimate = True) # override the base class's "subset" with one that is dynamically generated / # updated from subset_list subset = Property(Str, observe = "subset_list.items.str") subset_list = List(ISubset, estimate = True) # add 'estimate_result' metadata _histogram = Dict(Any, Array, transient = True, estimate_result = True) # bits to support the subset editor @observe('subset_list:items.str') def _on_subset_changed(self, _): self.changed = 'subset_list' # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join([subset.str for subset in self.subset_list if subset.str]) def default_view(self, **kwargs): return DensityGateWorkflowView(op = self, **kwargs) def clear_estimate(self): self._xscale = self._yscale = None self._xbins = np.empty(1) self._ybins = np.empty(1) self._keep_xbins = dict() self._keep_ybins = dict() self._histogram = {} def apply(self, experiment): if not self._histogram: raise util.CytoflowOpError(None, 'Click "Estimate"!') return DensityGateOp.apply(self, experiment) def get_notebook_code(self, idx): op = DensityGateOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """ .format(repr = repr(op), idx = idx, prev_idx = idx - 1, subset = ", subset = " + repr(self.subset) if self.subset else ""))
class BinningPluginView(PluginViewMixin, BinningView): handler_factory = Callable(BinningViewHandler) op = Instance(IOperation, fixed=True) huefacet = Str(status=True) huescale = util.ScaleEnum(status=True) def plot_wi(self, wi): self.plot(wi.previous_wi.result) def plot(self, experiment, **kwargs): if self.op.name: op = self.op self.huefacet = op.name self.huescale = op.scale legend = True else: op = self.op.clone_traits() op.name = ''.join( random.choice(string.ascii_uppercase + string.digits) for _ in range(6)) self.huefacet = op.name legend = False try: experiment = op.apply(experiment) except util.CytoflowOpError as e: warnings.warn(e.__str__(), util.CytoflowViewWarning) self.huefacet = "" HistogramView.plot(self, experiment, legend=legend, **kwargs)
class GaussianMixture2DPluginOp(PluginOpMixin, GaussianMixture2DOp): handler_factory = Callable(GaussianMixture2DHandler) # add "estimate" metadata num_components = util.PositiveInt(1, estimate=True) sigma = util.PositiveFloat(0.0, allow_zero=True, estimate=True) by = List(Str, estimate=True) xscale = util.ScaleEnum(estimate=True) yscale = util.ScaleEnum(estimate=True) _gmms = Dict(Any, Instance(mixture.GaussianMixture), transient=True) # bits to support the subset editor subset_list = List(ISubset, estimate=True) subset = Property(Str, depends_on="subset_list.str") # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) @on_trait_change('subset_list.str', post_init=True) def _subset_changed(self, obj, name, old, new): self.changed = (Changed.ESTIMATE, ('subset_list', self.subset_list)) def default_view(self, **kwargs): return GaussianMixture2DPluginView(op=self, **kwargs) def estimate(self, experiment): GaussianMixture2DOp.estimate(self, experiment, subset=self.subset) self.changed = (Changed.ESTIMATE_RESULT, self) def clear_estimate(self): self._gmms.clear() self._xscale = None self._yscale = None self.changed = (Changed.ESTIMATE_RESULT, self) def should_clear_estimate(self, changed): if changed == Changed.ESTIMATE: return True return False
class BinningWorkflowOp(WorkflowOperation, BinningOp): name = Str(apply=True) channel = Str(apply=True) bin_width = util.PositiveCFloat(None, allow_zero=False, allow_none=True, apply=True) scale = util.ScaleEnum(apply=True) def default_view(self, **kwargs): return BinningWorkflowView(op=self, **kwargs) def get_notebook_code(self, idx): op = BinningOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1))
class KMeansPluginOp(PluginOpMixin, KMeansOp): handler_factory = Callable(FlowPeaksHandler) # add "estimate" metadata xchannel = Str(estimate=True) ychannel = Str(estimate=True) xscale = util.ScaleEnum(estimate=True) yscale = util.ScaleEnum(estimate=True) num_clusters = util.PositiveCInt(2, allow_zero=False, estimate=True) by = List(Str, estimate=True) # bits to support the subset editor subset_list = List(ISubset, estimate=True) subset = Property(Str, depends_on="subset_list.str") # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) @on_trait_change('subset_list.str') def _subset_changed(self, obj, name, old, new): self.changed = (Changed.ESTIMATE, ('subset_list', self.subset_list)) @on_trait_change('xchannel, ychannel') def _channel_changed(self, obj, name, old, new): self.channels = [] self.scale = {} if self.xchannel: self.channels.append(self.xchannel) if self.xchannel in self.scale: del self.scale[self.xchannel] self.scale[self.xchannel] = self.xscale if self.ychannel: self.channels.append(self.ychannel) if self.ychannel in self.scale: del self.scale[self.ychannel] self.scale[self.ychannel] = self.yscale @on_trait_change('xscale, yscale') def _scale_changed(self, obj, name, old, new): self.scale = {} if self.xchannel: self.scale[self.xchannel] = self.xscale if self.ychannel: self.scale[self.ychannel] = self.yscale def default_view(self, **kwargs): return KMeansPluginView(op=self, **kwargs) def estimate(self, experiment): if not self.xchannel: raise util.CytoflowOpError('xchannel', "Must set X channel") if not self.ychannel: raise util.CytoflowOpError('ychannel', "Must set Y channel") try: super().estimate(experiment, subset=self.subset) except: raise finally: self.changed = (Changed.ESTIMATE_RESULT, self) def clear_estimate(self): self._kmeans.clear() self.changed = (Changed.ESTIMATE_RESULT, self) def get_notebook_code(self, idx): op = KMeansOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1, subset=", subset = " + repr(self.subset) if self.subset else ""))
class PolygonOp(HasStrictTraits): """ Apply a polygon gate to a cytometry experiment. Attributes ---------- name : Str The operation name. Used to name the new metadata field in the experiment that's created by :meth:`apply` xchannel, ychannel : Str The names of the x and y channels to apply the gate. xscale, yscale : {'linear', 'log', 'logicle'} (default = 'linear') The scales applied to the data before drawing the polygon. vertices : List((Float, Float)) The polygon verticies. An ordered list of 2-tuples, representing the x and y coordinates of the vertices. Notes ----- This module uses :meth:`matplotlib.path.Path` to represent the polygon, because membership testing is very fast. You can set the verticies by hand, I suppose, but it's much easier to use the interactive view you get from :meth:`default_view` to do so. Examples -------- .. plot:: :context: close-figs Make a little data set. >>> import cytoflow as flow >>> import_op = flow.ImportOp() >>> import_op.tubes = [flow.Tube(file = "Plate01/RFP_Well_A3.fcs", ... conditions = {'Dox' : 10.0}), ... flow.Tube(file = "Plate01/CFP_Well_A4.fcs", ... conditions = {'Dox' : 1.0})] >>> import_op.conditions = {'Dox' : 'float'} >>> ex = import_op.apply() Create and parameterize the operation. .. plot:: :context: close-figs >>> p = flow.PolygonOp(name = "Polygon", ... xchannel = "V2-A", ... ychannel = "Y2-A") >>> p.vertices = [(23.411982294776319, 5158.7027015021222), ... (102.22182270573683, 23124.058843387455), ... (510.94519955277201, 23124.058843387455), ... (1089.5215641232173, 3800.3424832180476), ... (340.56382570202402, 801.98947404942271), ... (65.42597937575897, 1119.3133482602157)] Show the default view. .. plot:: :context: close-figs >>> df = p.default_view(huefacet = "Dox", ... xscale = 'log', ... yscale = 'log') >>> df.plot(ex) .. note:: If you want to use the interactive default view in a Jupyter notebook, make sure you say ``%matplotlib notebook`` in the first cell (instead of ``%matplotlib inline`` or similar). Then call ``default_view()`` with ``interactive = True``:: df = p.default_view(huefacet = "Dox", xscale = 'log', yscale = 'log', interactive = True) df.plot(ex) Apply the gate, and show the result .. plot:: :context: close-figs >>> ex2 = p.apply(ex) >>> ex2.data.groupby('Polygon').size() Polygon False 15875 True 4125 dtype: int64 """ # traits id = Constant('edu.mit.synbio.cytoflow.operations.polygon') friendly_id = Constant("Polygon") name = CStr() xchannel = Str() ychannel = Str() vertices = List((Float, Float)) xscale = util.ScaleEnum() yscale = util.ScaleEnum() _selection_view = Instance('PolygonSelection', transient=True) def apply(self, experiment): """Applies the threshold to an experiment. Parameters ---------- experiment : Experiment the old :class:`Experiment` to which this op is applied Returns ------- Experiment a new :class:'Experiment`, the same as ``old_experiment`` but with a new column of type `bool` with the same as the operation name. The bool is ``True`` if the event's measurement is within the polygon, and ``False`` otherwise. Raises ------ util.CytoflowOpError if for some reason the operation can't be applied to this experiment. The reason is in :attr:`.CytoflowOpError.args` """ if experiment is None: raise util.CytoflowOpError('experiment', "No experiment specified") if self.name in experiment.data.columns: raise util.CytoflowOpError( 'name', "{} is in the experiment already!".format(self.name)) if not self.xchannel: raise util.CytoflowOpError('xchannel', "Must specify an x channel") if not self.ychannel: raise util.CytoflowOpError('ychannel', "Must specify a y channel") if not self.xchannel in experiment.channels: raise util.CytoflowOpError( 'xchannel', "xchannel {0} is not in the experiment".format(self.xchannel)) if not self.ychannel in experiment.channels: raise util.CytoflowOpError( 'ychannel', "ychannel {0} is not in the experiment".format(self.ychannel)) if len(self.vertices) < 3: raise util.CytoflowOpError('vertices', "Must have at least 3 vertices") if any([len(x) != 2 for x in self.vertices]): return util.CytoflowOpError( 'vertices', "All vertices must be lists or tuples " "of length = 2") # make sure name got set! if not self.name: raise util.CytoflowOpError( 'name', "You have to set the Polygon gate's name " "before applying it!") # make sure old_experiment doesn't already have a column named self.name if (self.name in experiment.data.columns): raise util.CytoflowOpError( 'name', "Experiment already contains a column {0}".format(self.name)) # there's a bit of a subtlety here: if the vertices were # selected with an interactive plot, and that plot had scaled # axes, we need to apply that scale function to both the # vertices and the data before looking for path membership xscale = util.scale_factory(self.xscale, experiment, channel=self.xchannel) yscale = util.scale_factory(self.yscale, experiment, channel=self.ychannel) vertices = [(xscale(x), yscale(y)) for (x, y) in self.vertices] data = experiment.data[[self.xchannel, self.ychannel]].copy() data[self.xchannel] = xscale(data[self.xchannel]) data[self.ychannel] = yscale(data[self.ychannel]) # use a matplotlib Path because testing for membership is a fast C fn. path = mpl.path.Path(np.array(vertices)) xy_data = data[[self.xchannel, self.ychannel]].values new_experiment = experiment.clone() new_experiment.add_condition(self.name, "bool", path.contains_points(xy_data)) new_experiment.history.append( self.clone_traits(transient=lambda _: True)) return new_experiment def default_view(self, **kwargs): self._selection_view = PolygonSelection(op=self) self._selection_view.trait_set(**kwargs) return self._selection_view
class GaussianMixture1DPluginOp(PluginOpMixin, GaussianMixtureOp): id = Constant('edu.mit.synbio.cytoflowgui.operations.gaussian_1d') handler_factory = Callable(GaussianMixture1DHandler) channel = Str channel_scale = util.ScaleEnum(estimate=True) # add "estimate" metadata num_components = util.PositiveCInt(1, estimate=True) sigma = util.PositiveCFloat(0.0, allow_zero=True, estimate=True) by = List(Str, estimate=True) # bits to support the subset editor subset_list = List(ISubset, estimate=True) subset = Property(Str, depends_on="subset_list.str") # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) @on_trait_change('subset_list.str') def _subset_changed(self, obj, name, old, new): self.changed = (Changed.ESTIMATE, ('subset_list', self.subset_list)) _gmms = Dict(Any, Instance(mixture.GaussianMixture), transient=True) @on_trait_change('channel') def _channel_changed(self): self.channels = [self.channel] self.changed = (Changed.ESTIMATE, ('channels', self.channels)) if self.channel_scale: self.scale = {self.channel: self.channel_scale} self.changed = (Changed.ESTIMATE, ('scale', self.scale)) @on_trait_change('channel_scale') def _scale_changed(self): if self.channel: self.scale = {self.channel: self.channel_scale} self.changed = (Changed.ESTIMATE, ('scale', self.scale)) def estimate(self, experiment): super().estimate(experiment, subset=self.subset) self.changed = (Changed.ESTIMATE_RESULT, self) def default_view(self, **kwargs): return GaussianMixture1DPluginView(op=self, **kwargs) def clear_estimate(self): self._gmms = {} self._scale = {} self.changed = (Changed.ESTIMATE_RESULT, self) def get_notebook_code(self, idx): op = GaussianMixtureOp() op.copy_traits(self, op.copyable_trait_names()) return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1, subset=", subset = " + repr(self.subset) if self.subset else ""))
class FlowPeaksWorkflowOp(WorkflowOperation, FlowPeaksOp): # add "apply" and "estimate" metadata name = Str(apply = True) xchannel = Str(estimate = True) ychannel = Str(estimate = True) xscale = util.ScaleEnum(estimate = True) yscale = util.ScaleEnum(estimate = True) h = util.PositiveCFloat(1.5, allow_zero = False, estimate = True) h0 = util.PositiveCFloat(1, allow_zero = False, estimate = True) tol = util.PositiveCFloat(0.5, allow_zero = False, estimate = True) merge_dist = util.PositiveCFloat(5, allow_zero = False, estimate = True) by = List(Str, estimate = True) # add the 'estimate_result' metadata _cluster_peak = Dict(Any, List, transient = True, estimate_result = True) # override the base class's "subset" with one that is dynamically generated / # updated from subset_list subset = Property(Str, observe = "subset_list.items.str") subset_list = List(ISubset, estimate = True) # bits to support the subset editor @observe('subset_list:items.str') def _on_subset_changed(self, _): self.changed = 'subset_list' # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join([subset.str for subset in self.subset_list if subset.str]) # @on_trait_change('xchannel, ychannel') # def _channel_changed(self): # self.channels = [] # self.scale = {} # if self.xchannel: # self.channels.append(self.xchannel) # # if self.xchannel in self.scale: # del self.scale[self.xchannel] # # self.scale[self.xchannel] = self.xscale # # if self.ychannel: # self.channels.append(self.ychannel) # # if self.ychannel in self.scale: # del self.scale[self.ychannel] # # self.scale[self.ychannel] = self.yscale # # # @on_trait_change('xscale, yscale') # def _scale_changed(self): # self.scale = {} # # if self.xchannel: # self.scale[self.xchannel] = self.xscale # # if self.ychannel: # self.scale[self.ychannel] = self.yscale def default_view(self, **kwargs): return FlowPeaksWorkflowView(op = self, **kwargs) def estimate(self, experiment): if not self.xchannel: raise util.CytoflowOpError('xchannel', "Must set X channel") if not self.ychannel: raise util.CytoflowOpError('ychannel', "Must set Y channel") self.channels = [self.xchannel, self.ychannel] self.scale = {self.xchannel : self.xscale, self.ychannel : self.yscale} super().estimate(experiment, subset = self.subset) def apply(self, experiment): if not self._cluster_group: raise util.CytoflowOpError(None, 'Click "Estimate"!') return super().apply(experiment) def clear_estimate(self): self._kmeans = {} self._means = {} self._normals = {} self._density = {} self._peaks = {} self._peak_clusters = {} self._cluster_peak = {} self._cluster_group = {} self._scale = {} def get_notebook_code(self, idx): op = FlowPeaksOp() op.copy_traits(self, op.copyable_trait_names()) op.channels = [self.xchannel, self.ychannel] op.scale = {self.xchannel : self.xscale, self.ychannel : self.yscale} return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """ .format(repr = repr(op), idx = idx, prev_idx = idx - 1, subset = ", subset = " + repr(self.subset) if self.subset else ""))
class KMeansWorkflowOp(WorkflowOperation, KMeansOp): # add "apply", "estimate" metadata name = Str(apply=True) xchannel = Str(estimate=True) ychannel = Str(estimate=True) xscale = util.ScaleEnum(estimate=True) yscale = util.ScaleEnum(estimate=True) num_clusters = util.PositiveCInt(2, allow_zero=False, estimate=True) by = List(Str, estimate=True) # add the 'estimate_result' metadata _kmeans = Dict(Any, Instance(sklearn.cluster.MiniBatchKMeans), transient=True, estimate_result=True) # override the base class's "subset" with one that is dynamically generated / # updated from subset_list subset = Property(Str, observe="subset_list.items.str") subset_list = List(ISubset, estimate=True) # bits to support the subset editor @observe('subset_list:items.str') def _on_subset_changed(self, _): self.changed = 'subset_list' # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) def default_view(self, **kwargs): return KMeansWorkflowView(op=self, **kwargs) def estimate(self, experiment): if not self.xchannel: raise util.CytoflowOpError('xchannel', "Must set X channel") if not self.ychannel: raise util.CytoflowOpError('ychannel', "Must set Y channel") self.channels = [self.xchannel, self.ychannel] self.scale = {self.xchannel: self.xscale, self.ychannel: self.yscale} super().estimate(experiment, subset=self.subset) def apply(self, experiment): if not self._kmeans: raise util.CytoflowOpError(None, 'Click "Estimate"!') return KMeansOp.apply(self, experiment) def clear_estimate(self): self._kmeans = {} self._scale = {} def get_notebook_code(self, idx): op = KMeansOp() op.copy_traits(self, op.copyable_trait_names()) op.channels = [self.xchannel, self.ychannel] op.scale = {self.xchannel: self.xscale, self.ychannel: self.yscale} return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1, subset=", subset = " + repr(self.subset) if self.subset else ""))
class GaussianMixture1DWorkflowOp(WorkflowOperation, GaussianMixtureOp): # override id so we can differentiate the 1D and 2D ops id = Constant('edu.mit.synbio.cytoflowgui.operations.gaussian_1d') # add 'estimate' and 'apply' metadata name = Str(apply=True) channel = Str(estimate=True) channel_scale = util.ScaleEnum(estimate=True) num_components = util.PositiveCInt(1, allow_zero=False, estimate=True) sigma = util.PositiveCFloat(None, allow_zero=True, allow_none=True, estimate=True) by = List(Str, estimate=True) # add the 'estimate_result' metadata _gmms = Dict(Any, Instance(mixture.GaussianMixture), transient=True, estimate_result=True) # override the base class's "subset" with one that is dynamically generated / # updated from subset_list subset = Property(Str, observe="subset_list.items.str") subset_list = List(ISubset, estimate=True) # bits to support the subset editor @observe('subset_list:items.str') def _on_subset_changed(self, _): self.changed = 'subset_list' # MAGIC - returns the value of the "subset" Property, above def _get_subset(self): return " and ".join( [subset.str for subset in self.subset_list if subset.str]) def estimate(self, experiment): self.channels = [self.channel] self.scale = {self.channel: self.channel_scale} super().estimate(experiment, subset=self.subset) def apply(self, experiment): if not self._gmms: raise util.CytoflowOpError(None, 'Click "Estimate"!') return GaussianMixtureOp.apply(self, experiment) def default_view(self, **kwargs): return GaussianMixture1DWorkflowView(op=self, **kwargs) def clear_estimate(self): self._gmms = {} self._scale = {} def get_notebook_code(self, idx): op = GaussianMixtureOp() op.copy_traits(self, op.copyable_trait_names()) op.channels = [self.channel] op.scale = {self.channel: self.channel_scale} return dedent(""" op_{idx} = {repr} op_{idx}.estimate(ex_{prev_idx}{subset}) ex_{idx} = op_{idx}.apply(ex_{prev_idx}) """.format(repr=repr(op), idx=idx, prev_idx=idx - 1, subset=", subset = " + repr(self.subset) if self.subset else ""))