Synthesis object design#
The following describes how synthesis objects are structured. This is probably most useful if you are creating a new synthesis method that you would like to include in or be compliant with plenoptic, rather than using existing ones.
The synthesis methods included in plenoptic generate one or more novel images based on the output of a model. These images can be used to better understand the model or as stimuli for an experiment comparing the model against another system. Beyond this rather vague description, however, there is a good deal of variability. We use inheritance in order to try and keep synthesis methods as similar as possible, to facilitate user interaction with them (and testing), but we want to avoid forcing too much similarity.
In the following description:
must connotes a requirement; any synthesis object not meeting this property will not be merged and is not considered “plenoptic-compliant”.
should connotes a suggestion; a compelling reason is required if the property is not met.
may connotes an option; these properties may make things easier (for developers or users), but are completely optional.
All synthesis methods#
To that end, all synthesis methods must inherit the plenoptic.synthesize.synthesis.Synthesis class. This requires the synthesis method to have a synthesize method, and provides helper functions for save , load , and to , which must be used when implementing them. Furthermore:
the initialization method (
__init__) must accept any images as its first input(s). If only a single image is accepted, it must be namedimage. If more than one, their names must be of the formimage_X, replacingXwith a more descriptive string. These must all have typetorch.Tensorand they must be validated withvalidate_input. This should be stored in an attribute with the same name as the argument.the initialization method’s next argument(s) must be any models or metrics that the synthesis will be based on. Similarly, if a single model / metric is accepted, they must be named
model/metric. If more than one, their names should be of the formX_model/X_metric, replacingXwith a more descriptive string. These must be validated withvalidate_model/validate_metric. This should be stored in an attribute with the same name as the argument.any other arguments to the initialization method may follow.
the object must be able to work on GPU and CPU. Users must be able to use the GPU either by initializing the synthesis object with tensors or models already on the GPU or by calling
to. The easiest way to do this is to usetorch.rand_likeand analogous methods when initializing new tensors where possible, and explicitly callingtoon any other newly-created tensors.ideally, the same for different float and complex data types (e.g., support both
torch.float32andtorch.float64), though this is not a strict requirement if there’s a good reason.if
synthesizeoperates in an iterative fashion, it must accept amax_iter: intargument to specify how long to run synthesis for and astop_criterion: floatargument to allow for early termination if some convergence is reached. What exactly is being checked for convergence (e.g., change in loss, change in pixel values) may vary, but it must be clarified in the docstring. Astop_iters_to_check: intargument may also be included, which specifies how many iterations ago to check. If it is not included, the number of iterations must be clarified in docstring.additionally, if synthesis is iterative, tqdm.auto.tqdm must be used as a progress bar, initialized with
pbar = tqdm(range(max_iter)), which should present information usingpbar.set_postfix(such as the loss or whatever else is checked for convergence, as discussed above).synthesizemust not return anything. The outputs of synthesis must be stored as attributes of the object. The number of large attributes should be minimized in order to reduce overall size in memory.the synthesis output must be stored as an attribute with the same name as the class (e.g.,
metamer).any attribute or method that the user does not need should be hidden (i.e., start with
_).consider using the
@propertydecorator to make important attributes write-only or differentiate between the public and private views. For example,MADCompetitiontracks the loss of the reference metric in a list,_reference_metric_loss, but thereference_metric_lossattribute converts this list to a tensor before returning it, as that’s how the user will most often want to interact with it.All attributes should be initialized at object initialization, though they can be “False-y” (e.g., an empty list,
None). At least one attribute should beNoneor an empty list at initialization, which we use when loading to check if the object has just been initialized. All attributes will be saved using thesavemethod, inherited from theSynthesissuperclass.
The above are the only requirements that all synthesis methods must meet.
Helper / display functions#
It may also be useful to include some functions for investigating the status or output(s) of synthesis. As a general rule, if a function will be called during synthesis (e.g., to compute a loss value), it should be a method of the object. If it is only called afterwards (e.g., to display the synthesis outputs in a useful way), it should be included as a function in the plot module (plenoptic.plot, see synthesis_imshow for an example).
Most display functions should be axes-level, and must accept an axis as an optional argument named ax, which will contain the plot. If no ax is supplied, matplotlib.pyplot.gca must be used to create / grab the axis. If a multi-axis figure is called for (e.g., to display the synthesis output and plot the loss), a function named synthesis_status should be created. This must have an optional fig argument, creating a figure if none is supplied. See synthesis_status for an example. If possible, this plot should be able to be animated to show progress over time. See synthesis_animate for an example.
New synthesis objects should be integrated into the existing synthesis object plotting functions where possible (see Plotting functions for an overview). If a new plotting function makes sense for only the new object, it should be named like {SYNTH_OBJECT}_{PLOT_TYPE} and included in the plot module (e.g., plenoptic.plot.metamer_representation_error).
See our Display and animate functions notebook for description and examples of the included plotting and display code.
Optimized synthesis#
Many synthesis methods will use an optimizer to generate their outputs. If the method makes use of a torch.optim.Optimizer object, it must inherit the plenoptic.synthesize.synthesis.OptimizedSynthesis class (this is a subclass of Synthesis , so the above all still applies).
Currently, the following are required (if not all of these are applicable to new methods, we may modify OptimizedSynthesis ):
the points about iterative synthesis described above all hold:
synthesizemust acceptmax_iter,stop_criterion, may acceptstop_iters_to_check, and must use tqdm.auto.tqdm.the object must have an
objective_functionmethod, which returns a measure of “how bad” the current synthesis output is. Optimization is minimizing this value.the object must have a
_check_convergencemethod, which is used (along withstop_criterionand, optionally,stop_iters_to_check) to determine if synthesis has converged.the object must have an
setupmethod, which initializes the synthesis output (e.g., with an appropriately-shaped sample of noise), optimizer, and scheduler. All of the inputs are optional and should have default behavior. The user can call this method once between initialization andsynthesizeand it should be called insynthesizeif it hasn’t been called yet.the setup method may accept some argument to affect this initialization, which should be named
initial_X(replacingXas appropriate). For example, this could be another image to use for initialization (initial_image) or some property of noise used to generate an initial image (initial_noise).the initialization method must accept
penalty_function: Callable[[torch.Tensor], torch.Tensor]andpenalty_lambda: floatarguments. The former is a function that takes as input the synthesis output and returns a scalar penalty value, used for synthesis regularization. The latter is a scalar weight that determines the strength of this penalty.during synthesis, the object should update the
_losses,_penalties_gradient_norm, and_pixel_change_normattributes on each iteration. This should probably happen in the_optimizer_stepmethod, which gets called once per iteration._closure(see below) andobjective_functionare not suitable because the former may be called multiple times per iteration (e.g., by LBFGS) and the latter may be called by the user outside of synthesis.the object must have a
_closuremethod, which performs the gradient calculation. This (when passed tooptimizer.stepduring the synthesis loop insynthesize) enables optimization algorithms that perform several evaluations of the gradient before taking a step (e.g., second-order methods). SeeMetamer._closurefor the simplest version of this. It must return a single float, the loss, which must match the output of theobjective_functionfunction (this should be explicitly tested). It should also cache any non-loss values computed that should be cached once per iteration in an appropriately named hidden attribute ending in_tmp(e.g.,_penalty_tmp); attributes ending in_tmpare not saved.the
synthesizemethod should accept astore_progressargument, which optionally stores additional information over iteration, such as the synthesis output-in-progress.OptimizedSynthesishas a setter method for this attribute, which will ensure things are correct. This argument can be an integer (in which case, the attributes are updated everystore_progressiterations),True(same behavior as1), orFalse(no updating of attributes). This should probably be done in a method named_store.the
synthesizemethod should be callable multiple times with the same object, in which case progress is resumed. On all subsequent calls,store_progress,stop_criterion, andstop_iters_to_checkmust have the same values.must implement
get_progressmethod, which calls the superclass version of that method, specifying the names of the attributes that are updated every iteration and everystore_progressiterations.
How to order methods#
Python doesn’t care how you order any of the methods or properties of a class, but doing so in a consistent manner will make reading the code easier, so try to follow these guidelines:
The caller should (almost always) be above the callee and related concepts should be close together.
__init__should be first, followed by any methods called within it.After all those initialization-related methods,
setupshould come next.After
setup,synthesizeshould come next. Again, this should be followed by most of the the methods called within it, ordered roughly by importance. Thus, the first methods should probably beobjective_functionand_optimizer_step, followed by_check_convergence. What shouldn’t be included in this section are helper methods that aren’t scientifically interesting (e.g.,_initialize_optimizer,_store).Next, any other content-related methods, such as helper methods that perform useful computations that are not called by
__init__orsynthesize.Next, the helper functions we ignored from earlier, such as
_initialize_optimizerand_store.Next,
save,load,to.Finally, all the properties.