Handle custom data types
Using materializers to pass custom data types through steps.
A ZenML pipeline is built in a data-centric way. The outputs and inputs of steps define how steps are connected and the order in which they are executed. Each step should be considered as its very own process that reads and writes its inputs and outputs from and to the artifact store. This is where materializers come into play.
A materializer dictates how a given artifact can be written to and retrieved from the artifact store and also contains all serialization and deserialization logic. Whenever you pass artifacts as outputs from one pipeline step to other steps as inputs, the corresponding materializer for the respective data type defines how this artifact is first serialized and written to the artifact store, and then deserialized and read in the next step.
Built-In Materializers
ZenML already includes built-in materializers for many common data types. These are always enabled and are used in the background without requiring any user interaction / activation:
Materializer | Handled Data Types | Storage Format |
---|---|---|
|
| |
|
| |
| Directory | |
|
| |
|
| |
|
| |
|
| |
|
|
ZenML provides a built-in CloudpickleMaterializer that can handle any object by saving it with cloudpickle. However, this is not production-ready because the resulting artifacts cannot be loaded when running with a different Python version. In such cases, you should consider building a custom Materializer to save your objects in a more robust and efficient format.
Moreover, using the CloudpickleMaterializer
could allow users to upload of any kind of object. This could be exploited to upload a malicious file, which could execute arbitrary code on the vulnerable system.
Integration Materializers
In addition to the built-in materializers, ZenML also provides several integration-specific materializers that can be activated by installing the respective integration:
Integration | Materializer | Handled Data Types | Storage Format |
---|---|---|---|
bentoml |
|
| |
deepchecks |
|
| |
evidently |
|
| |
great_expectations |
|
| |
huggingface |
| Directory | |
huggingface |
| Directory | |
huggingface |
| Directory | |
huggingface |
| Directory | |
lightgbm |
|
| |
lightgbm |
|
| |
neural_prophet |
|
| |
pillow |
|
| |
polars |
|
| |
pycaret | Any |
| |
pytorch |
|
| |
pytorch |
|
| |
scipy |
|
| |
spark |
|
| |
spark |
|
| |
tensorflow |
| Directory | |
tensorflow |
| Directory | |
whylogs |
|
| |
xgboost |
|
| |
xgboost |
|
|
If you are running pipelines with a Docker-based orchestrator, you need to specify the corresponding integration as required_integrations
in the DockerSettings
of your pipeline in order to have the integration materializer available inside your Docker container. See the pipeline configuration documentation for more information.
Custom materializers
Configuring a step/pipeline to use a custom materializer
Defining which step uses what materializer
ZenML automatically detects if your materializer is imported in your source code and registers them for the corresponding data type (defined in ASSOCIATED_TYPES
). Therefore, just having a custom materializer definition in your code is enough to enable the respective data type to be used in your pipelines.
However, it is best practice to explicitly define which materializer to use for a specific step and not rely on the ASSOCIATED_TYPES
to make that connection:
When there are multiple outputs, a dictionary of type {<OUTPUT_NAME>: <MATERIALIZER_CLASS>}
can be supplied to the decorator or the .configure(...)
method:
Also, as briefly outlined in the configuration docs section, which materializer to use for the output of what step can also be configured within YAML config files.
For each output of your steps, you can define custom materializers to handle the loading and saving. You can configure them like this in the config:
Check out this page for information on your step output names and how to customize them.
Defining a materializer globally
Sometimes, you would like to configure ZenML to use a custom materializer globally for all pipelines, and override the default materializers that come built-in with ZenML. A good example of this would be to build a materializer for a pandas.DataFrame
to handle the reading and writing of that dataframe in a different way than the default mechanism.
An easy way to do that is to use the internal materializer registry of ZenML and override its behavior:
Developing a custom materializer
Now that we know how to configure a pipeline to use a custom materializer, let us briefly discuss how materializers in general are implemented.
Base implementation
In the following, you can see the implementation of the abstract base class BaseMaterializer
, which defines the interface of all materializers:
Handled data types
Each materializer has an ASSOCIATED_TYPES
attribute that contains a list of data types that this materializer can handle. ZenML uses this information to call the right materializer at the right time. I.e., if a ZenML step returns a pd.DataFrame
, ZenML will try to find any materializer that has pd.DataFrame
in its ASSOCIATED_TYPES
. List the data type of your custom object here to link the materializer to that data type.
The type of the generated artifact
Each materializer also has an ASSOCIATED_ARTIFACT_TYPE
attribute, which defines what zenml.enums.ArtifactType
is assigned to this data.
In most cases, you should choose either ArtifactType.DATA
or ArtifactType.MODEL
here. If you are unsure, just use ArtifactType.DATA
. The exact choice is not too important, as the artifact type is only used as a tag in some of ZenML's visualizations.
Target location to store the artifact
Each materializer has a uri
attribute, which is automatically created by ZenML whenever you run a pipeline and points to the directory of a file system where the respective artifact is stored (some location in the artifact store).
Storing and retrieving the artifact
The load()
and save()
methods define the serialization and deserialization of artifacts.
load()
defines how data is read from the artifact store and deserialized,save()
defines how data is serialized and saved to the artifact store.
You will need to override these methods according to how you plan to serialize your objects. E.g., if you have custom PyTorch classes as ASSOCIATED_TYPES
, then you might want to use torch.save()
and torch.load()
here.
If you need a temporary directory in your custom materializer, it is best to use the helper method get_temporary_directory(...)
on the materializer class in order to have the directory cleaned up correctly:
(Optional) How to Visualize the Artifact
Optionally, you can override the save_visualizations()
method to automatically save visualizations for all artifacts saved by your materializer. These visualizations are then shown next to your artifacts in the dashboard:
Currently, artifacts can be visualized either as CSV table, embedded HTML, image or Markdown. For more information, see zenml.enums.VisualizationType.
To create visualizations, you need to:
Compute the visualizations based on the artifact
Save all visualizations to paths inside
self.uri
Return a dictionary mapping visualization paths to visualization types.
As an example, check out the implementation of the zenml.materializers.NumpyMaterializer that use matplotlib to automatically save or plot certain arrays.
Read more about visualizations here.
(Optional) Which Metadata to Extract for the Artifact
Optionally, you can override the extract_metadata()
method to track custom metadata for all artifacts saved by your materializer. Anything you extract here will be displayed in the dashboard next to your artifacts.
To extract metadata, define and return a dictionary of values you want to track. The only requirement is that all your values are built-in types ( like str
, int
, list
, dict
, ...) or among the special types defined in zenml.metadata.metadata_types that are displayed in a dedicated way in the dashboard. See zenml.metadata.metadata_types.MetadataType for more details.
By default, this method will only extract the storage size of an artifact, but you can override it to track anything you wish. E.g., the zenml.materializers.NumpyMaterializer overrides this method to track the shape
, dtype
, and some statistical properties of each np.ndarray
that it saves.
If you would like to disable artifact visualization altogether, you can set enable_artifact_visualization
at either pipeline or step level via @pipeline(enable_artifact_visualization=False)
or @step(enable_artifact_visualization=False)
.
(Optional) Which Metadata to Extract for the Artifact
Optionally, you can override the extract_metadata()
method to track custom metadata for all artifacts saved by your materializer. Anything you extract here will be displayed in the dashboard next to your artifacts.
To extract metadata, define and return a dictionary of values you want to track. The only requirement is that all your values are built-in types ( like str
, int
, list
, dict
, ...) or among the special types defined in src.zenml.metadata.metadata_types that are displayed in a dedicated way in the dashboard. See src.zenml.metadata.metadata_types.MetadataType for more details.
By default, this method will only extract the storage size of an artifact, but you can overwrite it to track anything you wish. E.g., the zenml.materializers.NumpyMaterializer
overwrites this method to track the shape
, dtype
, and some statistical properties of each np.ndarray
that it saves.
If you would like to disable artifact metadata extraction altogether, you can set enable_artifact_metadata
at either pipeline or step level via @pipeline(enable_artifact_metadata=False)
or @step(enable_artifact_metadata=False)
.
Skipping materialization
You can learn more about skipping materialization here.
Interaction with custom artifact stores
When creating a custom artifact store, you may encounter a situation where the default materializers do not function properly. Specifically, the self.artifact_store.open
method used in these materializers may not be compatible with your custom store due to not being implemented properly.
In this case, you can create a modified version of the failing materializer by copying it and modifying it to copy the artifact to a local path, then opening it from there. For example, consider the following implementation of a custom PandasMaterializer that works with a custom artifact store. In this implementation, we copy the artifact to a local path because we want to use the pandas.read_csv
method to read it. If we were to use the self.artifact_store.open
method instead, we would not need to make this copy.
It is worth noting that copying the artifact to a local path may not always be necessary and can potentially be a performance bottleneck.
Code example
Let's see how materialization works with a basic example. Let's say you have a custom class called MyObject
that flows between two steps in a pipeline:
Running the above without a custom materializer will work but print the following warning:
No materializer is registered for type MyObj, so the default Pickle materializer was used. Pickle is not production ready and should only be used for prototyping as the artifacts cannot be loaded when running with a different Python version. Please consider implementing a custom materializer for type MyObj
To get rid of this warning and make our pipeline more robust, we will subclass the BaseMaterializer
class, listing MyObj
in ASSOCIATED_TYPES
, and overwriting load()
and save()
:
Pro-tip: Use the self.artifact_store
property to ensure your materialization logic works across artifact stores (local and remote like S3 buckets).
Now, ZenML can use this materializer to handle the outputs and inputs of your customs object. Edit the pipeline as follows to see this in action:
Due to the typing of the inputs and outputs and the ASSOCIATED_TYPES
attribute of the materializer, you won't necessarily have to add .configure(output_materializers=MyMaterializer)
to the step. It should automatically be detected. It doesn't hurt to be explicit though.
This will now work as expected and yield the following output:
Last updated