Updated on 2026-11-06 GMT+08:00

Developing a Custom Operator

If the built-in operators cannot meet your data processing requirements, you can develop a custom operator based on the ModelArts data refinement SDK. This section describes how to set up the project directory, write operator code, configure dependencies and model files, and package the operator.

Prerequisites

You have prepared a local development environment. For details, see Preparing a Local Development Environment.

Operator Project Directory Description

Create an operator project directory locally with the following structure:

my_custom_op/
├── process.py            # [Mandatory] Operator entry file. The file name cannot be modified. It defines the PreProcess, Process, and PostProcess classes.
├── dependency/           # [Optional] Third-party Python packages and requirements.txt required by the operator.
│   ├── requirements.txt
│   ├── some_package-1.0.0-py3-none-any.whl
│   └── another_package-0.5.0.tar.gz
└── weights/              # [Optional] Model files required for operator execution.
    └── model.bin
Table 1 Directory structure description

Directory/File

Mandatory

Description

process.py

Yes

Core logic file of the operator. At least the Process class must be defined.

dependency/

No

Stores additional dependent .whl or .tar.gz packages required by the operator, as well as requirements.txt.

weights/

No

Stores model weight files required during operator runtime.

Operator Execution Workflow Description

Custom operators implement data processing logic by defining three Python classes in process.py: PreProcess, Process, and PostProcess. These three classes correspond to data preprocessing, core operator logic, and data post-processing, respectively. All of them must inherit from the operator base classes in the SDK (Mapper, BatchMapper, Filter, BatchFilter, and Generator). The Process class is mandatory, while PreProcess and PostProcess are optional.

When the system runs a custom operator, it executes them sequentially in the order of PreProcess -> Process -> PostProcess, with the output of the previous step serving as the input for the next step.

The operator execution workflow is shown in the following table.

Step

Processing Phase

Class Name

Mandatory

Inputs

Output

Description

①

Dataset loading

Automatically processed by the system

System execution

Automatically loaded by the system

Raw data

The system automatically loads the dataset and reads the raw data.

②

Data preprocessing

PreProcess

Optional

Raw data

Preprocessed data

Filtering, transformation, deduplication, missing value imputation, etc. The results serve as the input for Process.

③

Core operator logic

Process

Required

Preprocessed data

(or raw data)

Processed data

Executes core processing logic, such as labeling, filtering, and augmentation.

④

Data post-processing

PostProcess

Optional

Processed data

Final processing results

Result filtering, format standardization, redundant column cleanup, etc.

⑤

Output results

Automatically processed by the system

System execution

Final processing results

Automatic output by the system

The system automatically outputs the final processing results.

  1. Loads the dataset and reads the raw data.
  2. If PreProcess is defined, preprocessing is performed on each data entry. Samples returning False are filtered out, and samples returning a dict or pa.Array have new columns added.
  3. Executes Process.process() on the data output by PreProcess to run the core operator logic.
  4. If PostProcess is defined, post-processing is performed on the data output by Process. Samples returning False are filtered out, and samples returning a dict or pa.Array have new columns added.
  5. Outputs the final processing results.

Step 1: Writing Operator Code

Based on your data processing requirements, select the appropriate operator base class and implement the process method. For details about the description and differences of each operator base class, see Operator Base Classes and Differences.

The optional operator base classes and key information for the three processing phases are shown in the following table to help you quickly select the right combination of base classes.

Operator Base Class

Processing Granularity

PreProcess (Optional)

Process (Mandatory)

PostProcess (Optional)

process Method Return Value

Typical Scenario

Filter

Single sample

Supported

Supported

Supported

bool

Row-level filtering

Mapper

Single sample

Supported

Supported

Supported

dict[str, Any]

Row-level labeling/transformation

BatchMapper

Batch samples

Supported

Supported

Not supported

pa.Array

Batch labeling/transformation

BatchFilter

Batch samples

Not supported

Supported

Supported

pa.Array(bool)

Batch filtering

Generator

Single sample

Not supported

Supported

Not supported

list[dict[str, Any]]

One-to-many generation

PreProcess (Data Preprocessing, Optional)

PreProcess is the first step of the data processing workflow, used to preprocess raw data (such as format conversion, deduplication, missing value imputation, and outlier filtering). The execution result serves as the input for Process. PreProcess is defined as needed and must inherit from the Filter, Mapper, or BatchMapper base class. The optional base classes and their applicable scenarios are as follows:

  • Inheriting Filter: Used to filter out samples that do not meet the conditions before core processing (such as null values or abnormal formats). The process method returns a bool value to determine whether the sample is retained.
  • Inheriting Mapper: Used to perform preprocessing transformations on samples before core processing (such as field renaming or encoding conversion). The process method returns a dict representing newly added or modified columns.
  • Inheriting BatchMapper: Used for batch preprocessing scenarios (such as batch feature extraction). The process method returns a pa.Array representing a batch of newly added or modified columns.

Below are code examples for PreProcess:

  • Example 1: Inheriting Filter to filter out empty and overly short texts during the preprocessing stage.
    from typing import Any
    
    from modelarts.data.refiner.dataset.dpt import PreTrainText
    from modelarts.data.refiner.op.base_op import Filter
    
    class PreProcess(Filter):
        """Data preprocessing: Filter out empty texts and samples with insufficient length."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.min_length = op_args.get("min_length", 50)
    
        def process(self, sample: dict[str, Any]) -> bool:
            pre_train_text = PreTrainText.from_dict(sample)
            if not pre_train_text.text or not pre_train_text.text.strip():
                return False
            return len(pre_train_text.text) >= self.min_length
  • Example 2: Inheriting Mapper to add a text length column to samples during the preprocessing stage.
    from typing import Any
    
    import daft
    from modelarts.data.refiner.dataset.sft import Messages
    from modelarts.data.refiner.op.base_op import Mapper
    
    class PreProcess(Mapper):
        """Data preprocessing: Add a total text length column to samples."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
    
        def process(self, sample: dict[str, Any]) -> dict[str, Any]:
            messages = Messages.from_dict(sample)
            total_len = sum(len(msg.content) for msg in messages.messages)
            return {'text_length': total_len}
    
        @staticmethod
        def __return_extend_columns_type__() -> daft.schema.Schema:        return daft.schema.Schema.from_pydict({'text_length': daft.DataType.int64()})

Process (Core Operator Logic, Mandatory)

Process is the second step of the data processing workflow and a mandatory step, carrying the core processing logic of the operator. Process receives the output of PreProcess as its input (or receives the raw data directly if PreProcess is not defined), executes the core processing, and passes the results to PostProcess. Process must inherit from an operator base class in the SDK (for details on base class differences, see Operator Base Classes and Differences). The optional base classes and their applicable scenarios are as follows:

  • Inheriting Mapper: Row-level labeling/transformation. Processes samples one by one and adds new columns. Suitable for scenarios such as text classification labeling, quality scoring, and field transformation. The process method returns dict[str, Any] representing key-value pairs of the newly added columns; it must implement __return_extend_columns_type__() to declare the types of the new columns.
  • Inheriting BatchMapper: Batch labeling/transformation. Processes data in batches at the column level. Suitable for scenarios requiring model batch inference, GPU acceleration, etc. The process method receives dict[str, pa.Array] and returns pa.Array; it must implement __return_extend_columns_type__() to declare the types of the new columns.
  • Inheriting Filter: Row-level filtering. Evaluates samples one by one to determine whether to retain them. Suitable for scenarios where low-quality samples are filtered out based on conditions. The process method returns a bool (True for retention/False for filtering); it does not need to implement __return_extend_columns_type__().
  • Inheriting BatchFilter: Batch filtering. Evaluates samples in batches at the column level to determine whether to retain them. Suitable for batch quality inspection and filtering scenarios. The process method receives dict[str, pa.Array] and returns pa.Array(bool); it does not need to implement __return_extend_columns_type__().
  • Inheriting Generator: Generates multiple new samples from one sample. Suitable for scenarios such as video segmentation and data augmentation. The process method returns list[dict[str, Any]], where each element is a complete new sample dictionary; it does not need to implement __return_extend_columns_type__().

Below are code examples for Process:

  • Example 1: Inheriting Mapper to assign quality score labels to SFT dialogue data.
    from typing import Any
    
    import daft
    from modelarts.data.refiner.dataset.sft import Messages
    from modelarts.data.refiner.op.base_op import Mapper
    
    class Process(Mapper):
        """Core operator logic: Assign quality score labels to SFT dialogue data."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.high_threshold = op_args.get("high_threshold", 5000)
            self.medium_threshold = op_args.get("medium_threshold", 1000)
    
        def process(self, sample: dict[str, Any]) -> dict[str, Any]:
            messages = Messages.from_dict(sample)
            total_len = sum(len(msg.content) for msg in messages.messages)
            if total_len > self.high_threshold:
                return {'quality': 'high'}
            elif total_len > self.medium_threshold:
                return {'quality': 'medium'}
            else:
                return {'quality': 'low'}
    
        @staticmethod
        def __return_extend_columns_type__() -> daft.schema.Schema:
            return daft.schema.Schema.from_pydict({'quality': daft.DataType.string()})
  • Example 2: Inheriting Filter to filter out pre-training data with text length below the threshold.
    from typing import Any
    
    from modelarts.data.refiner.dataset.dpt import PreTrainText
    from modelarts.data.refiner.op.base_op import Filter
    
    class Process(Filter):
        """Core operator logic: Filter out pre-training samples whose text length does not exceed the threshold."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.text_len = op_args.get("text_len", 1000)
    
        def process(self, sample: dict[str, Any]) -> bool:
            pre_train_text = PreTrainText.from_dict(sample)
            return len(pre_train_text.text) > self.text_len
  • Example 3: Inheriting Generator to split a video into multiple segments.
    from typing import Any
    
    from modelarts.data.refiner.dataset.video import Video
    from modelarts.data.refiner.op.base_op import Generator
    
    class Process(Generator):
        """Core operator logic: Split a video into multiple small clips."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.clip_duration = op_args.get("clip_duration", 10)
    
        def process(self, sample: dict[str, Any]) -> list[dict[str, Any]]:
            video = Video.from_dict(sample)
            video_clips = self.cut_video(video.file_path)
            return [Video.to_dict(clip) for clip in video_clips]
    
        # Implement video segmentation logic
        def cut_video(self, video_path: str) -> list[Video]:
            ...

PostProcess (Operator Post-Processing, Optional)

PostProcess is the third step of the data processing workflow, used to perform post-processing on the results generated by Process (such as result filtering, format standardization, and redundant column cleanup). PostProcess is defined as needed and must inherit from the Filter, BatchFilter, or Mapper base class. The optional base classes and their applicable scenarios are as follows:

  • Inheriting Filter: Used to perform secondary filtering on the results output by Process (such as filtering out low-quality labeling results). The process method returns a bool value to determine whether the sample is retained.
  • Inheriting BatchFilter: Used to perform batch secondary filtering on the results output by Process. The process method returns a pa.Array(bool) to determine in batches whether the samples are retained.
  • Inheriting Mapper: Used to perform post-processing transformations on the results output by Process (such as field renaming or format standardization). The process method returns a dict representing newly added or modified columns.

Below are code examples for PostProcess:

  • Example 1: Inheriting Filter to filter out samples with a quality score of low during the post-processing stage.
    from typing import Any
    
    from modelarts.data.refiner.op.base_op import Filter
    
    class PostProcess(Filter):
        """Operator post-processing: Filter out samples with a 'low' quality score."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
    
        def process(self, sample: dict[str, Any]) -> bool:
            quality = sample.get('quality', 'low')
            return quality != 'low'
  • Example 2: Inheriting BatchFilter to perform batch post-processing and filter out short text samples.
    from typing import Any
    
    import pyarrow as pa
    from modelarts.data.refiner.dataset.dpt import TEXT
    from modelarts.data.refiner.op.base_op import BatchFilter
    
    class PostProcess(BatchFilter):
        """Operator post-processing: Batch filter samples with insufficient text length."""
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.text_len = op_args.get("text_len", 100)
    
        def process(self, sample: dict[str, pa.Array]) -> pa.Array:
            texts = sample.get(TEXT)
            results = []
            for text in texts:
                text_value = text.as_py()
                results.append(len(text_value) > self.text_len)
                return pa.array(results, type=pa.bool_())

Complete process.py Example

Below is a complete process.py example containing all three stages (PreProcess, Process, and PostProcess), demonstrating a "filtering -> labeling -> filtering" pipeline for pre-training text data: PreProcess filters out empty and overly short texts, Process assigns length-level labels to the filtered samples, and PostProcess batch-filters out samples categorized as short.

from typing import Any

import daft
import pyarrow as pa
from modelarts.data.refiner.dataset.dpt import PreTrainText
from modelarts.data.refiner.op.base_op import Filter, Mapper, BatchFilter

class PreProcess(Filter):
    """Step 1 Data preprocessing: Filter out empty texts and overly short texts."""

    def __init__(self, op_args: dict[str, Any], **kwargs):
        super().__init__(op_args, **kwargs)
        self.min_length = op_args.get("min_length", 50)

    def process(self, sample: dict[str, Any]) -> bool:
        pre_train_text = PreTrainText.from_dict(sample)
        if not pre_train_text.text or not pre_train_text.text.strip():
            return False
        return len(pre_train_text.text) >= self.min_length

class Process(Mapper):
    """Step 2 Core operator logic: Assign length-level labels to texts."""

    def __init__(self, op_args: dict[str, Any], **kwargs):
        super().__init__(op_args, **kwargs)

    def process(self, sample: dict[str, Any]) -> dict[str, Any]:
        pre_train_text = PreTrainText.from_dict(sample)
        text_len = len(pre_train_text.text)
        if text_len > 5000:
            level = "long"
        elif text_len > 500:
            level = "medium"
        else:
            level = "short"
        return {"length_level": level}

    @staticmethod
    def __return_extend_columns_type__() -> daft.schema.Schema:
        return daft.schema.Schema.from_pydict({"length_level": daft.DataType.string()})

class PostProcess(BatchFilter):
    """Step 3 Operator post-processing: Batch filter out samples with 'short' level."""

    def __init__(self, op_args: dict[str, Any], **kwargs):
        super().__init__(op_args, **kwargs)

    def process(self, sample: dict[str, pa.Array]) -> pa.Array:
        levels = sample.get("length_level")
        results = []
        for level in levels:
            results.append(level.as_py() != "short")
            return pa.array(results, type=pa.bool_())

Step 2: Configuring Dependencies and Model Files

After completing the operator code, you need to configure runtime dependencies and model files to ensure the operator runs properly across different environments.

  • Operator Input Arguments (op_args)

    The operator constructor receives an op_args parameter, which is a dictionary used to pass user-defined runtime arguments. In the classes within process.py, you can retrieve runtime parameters via op_args. Usage is as follows:

    class Process(Mapper):
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.threshold = op_args.get("threshold", 1000)
            self.mode = op_args.get("mode", "strict")
            self.model_path = op_args.get("model_path", "./weights/model.bin")

    You are advised to provide reasonable default values for all parameters to ensure the operator can run properly even when parameters are not explicitly specified. System parameters (such as content_type and input_columns) are injected via kwargs; do not define parameters with the same names in op_args.

  • Third-Party Dependencies

    If the operator depends on Python packages outside of the SDK, you need to place the dependencies in the dependency/ directory. For any packages listed in requirements.txt, their corresponding .whl or .tar.gz files must also exist in the dependency/ directory. Dependencies already included in the SDK itself (such as daft, pyarrow, pandas, opencv-python, and numpy) do not need to be added again. During installation, local installation from the dependency/ directory is prioritized, eliminating the need to download from the public network.

  • Model Files

    If the operator needs to load models at runtime (such as inference models or NLP models), place the model files in the weights/ directory. You can pass the model path through op_args in your operator code or load it using a relative path:

    import os
    class Process(BatchMapper):
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)
            self.model_path = op_args.get("model_path", os.path.join(os.path.dirname(__file__), "weights", "model.bin"))
  • Accelerator Configuration

    If the operator requires NPU acceleration, you can override the _accelerator attribute in the class:

    class Process(BatchMapper):
        _accelerator = "npu"  # Uses NPU acceleration. Optional values: "cpu", "npu"
        _max_concurrency = 4  # Lowers concurrency to prevent out-of-memory (OOM) errors
    
        def __init__(self, op_args: dict[str, Any], **kwargs):
            super().__init__(op_args, **kwargs)

Step 3: Packaging the Operator and Dependencies

Once development is complete, you need to package the operator project into a .tar format. For details about the operator project directory, see Operator Project Directory Description.

Notes:

  • The process.py file must be included, and it must define at least the Process class.
  • The dependency/ and weights/ directories can be omitted if they are empty, but if they are included, their directory names must be exact.
  • If the operator only depends on libraries already included in the SDK (daft, pyarrow, pandas, numpy, opencv-python, etc.), there is no need to create the dependency/ directory.