自定义离线算子(UDF)

更新时间:
复制 MD 格式

当内置离线算子无法满足您的业务处理规则时,可以使用 Ray Data 编写自定义算子(UDF,用户定义函数),并与内置算子串联在同一条数据处理流水线中。本文介绍 UDF 的接入方式、编写方法、与内置算子的串联方式以及使用约定。

接入方式

将函数或可调用类传给 Ray Data 的 map_batches,即可在数据集流水线中分布式执行自定义逻辑。

处理需求

Ray Data 入口

执行方式

无状态的批量转换、字段生成

ds.map_batches(fn, ...)

按批次分布式执行函数。

需要复用模型、客户端或配置

ds.map_batches(UDFClass, ...)

在执行实例内初始化并复用状态。

串联目录中的内置算子

ds.mmops.<算子>(...)

接收上一步的数据集,继续加工并输出结果。

本文示例使用 Ray Data 公开接口。基础示例需要 ray[data] 和 numpy,串联内置算子时还需要 mmops。请确保 Driver 与 Worker 节点使用相同的依赖环境。mmops 的获取和安装方法,请参见mmops SDK 使用指南。mmops 的环境要求和调用约定,请参见离线算子概述。

示例算子:文本标准化

本文以文本标准化算子为例:将连续空白合并为单个空格、去掉首尾空白,并可选转为小写。算子保留原字段,每条输入对应一条输出,并新增清洗后的文本和字符数。

字段或参数

方向

说明

source_id

输入,保留

原记录的来源标识,便于关联结果。

text

输入,保留

字符串。允许空字符串,不接受 null 或非字符串。

lowercase

配置

是否转为小写,默认为 False,通过 fn_kwargs 传入。

clean_text

输出

合并空白、去掉首尾空白后的文本。

char_count

输出

清洗后的字符数,包含保留的单个空格。不是 Token 数。

编写并执行函数 UDF

当 batch_format="numpy" 时,每个批次是“列名 → 数组”的字典。函数需返回同样的列式结构,且各列长度必须一致;本例的输出行数与输入相同。函数创建新数组,不修改输入数组。

import os
import re
import numpy as np
import ray


def normalize_text_batch(batch, *, lowercase=False):
    if "text" not in batch or "source_id" not in batch:
        raise ValueError("需要 source_id 和 text 两列")
    if "clean_text" in batch or "char_count" in batch:
        raise ValueError("输出列 clean_text / char_count 已存在")
    if any(not isinstance(text, str) for text in batch["text"]):
        raise TypeError("text 必须是字符串;空字符串允许")

    cleaned = [re.sub(r"\s+", " ", text).strip()
               for text in batch["text"]]
    if lowercase:
        cleaned = [text.lower() for text in cleaned]
    # 保留输入列;新建数组,不修改输入缓冲区。
    out = dict(batch)
    out["clean_text"] = np.asarray(cleaned, dtype=str)
    out["char_count"] = np.asarray([len(t) for t in cleaned], dtype=np.int64)
    return out


# 设置 RAY_ADDRESS=auto 连接现有集群;未设置时启动本地 Ray。
ray.init(address=os.environ.get("RAY_ADDRESS"))
source = ray.data.from_items([
    {"source_id": "doc-001", "text": "  产品\n  使用说明  "},
    {"source_id": "doc-002", "text": "Ray   Data"},
])
result = source.map_batches(
    normalize_text_batch, batch_format="numpy", batch_size=32,
    fn_kwargs={"lowercase": False}, num_cpus=1,
)
# 小样本读取触发执行;大规模任务使用 write_json 等输出接口。
print(result.take_all())

示例输出保留 source_id 和原 text 列,并新增 clean_text 和 char_count 列。部分结果列如下:

source_id

clean_text

char_count

doc-001

产品 使用说明

7

doc-002

Ray Data

8

使用类 UDF 复用状态

类的构造方法在执行实例内初始化,__call__ 方法处理后续批次。将模型权重或客户端放在构造方法中创建,可以避免每个批次重复加载。

import os
import re
import numpy as np
import ray


class NormalizeTextUDF:
    def __init__(self, lowercase=False):
        self.lowercase = lowercase
        # 模型或客户端可在此初始化,由当前执行实例复用。

    def __call__(self, batch):
        if "text" not in batch or "source_id" not in batch:
            raise ValueError("需要 source_id 和 text 两列")
        if "clean_text" in batch or "char_count" in batch:
            raise ValueError("输出列 clean_text / char_count 已存在")
        if any(not isinstance(text, str) for text in batch["text"]):
            raise TypeError("text 必须是字符串;空字符串允许")

        cleaned = [re.sub(r"\s+", " ", text).strip()
                   for text in batch["text"]]
        if self.lowercase:
            cleaned = [text.lower() for text in cleaned]
        out = dict(batch)
        out["clean_text"] = np.asarray(cleaned, dtype=str)
        out["char_count"] = np.asarray([len(t) for t in cleaned], dtype=np.int64)
        return out


# 设置 RAY_ADDRESS=auto 连接现有集群;未设置时启动本地 Ray。
ray.init(address=os.environ.get("RAY_ADDRESS"))
source = ray.data.from_items([
    {"source_id": "doc-001", "text": "  产品\n  使用说明  "},
    {"source_id": "doc-002", "text": "Ray   Data"},
])
result = source.map_batches(
    NormalizeTextUDF,
    fn_constructor_kwargs={"lowercase": False},
    compute=ray.data.ActorPoolStrategy(size=2),
    batch_format="numpy", batch_size=32, num_cpus=1,
)
print(result.take_all())

ActorPoolStrategy(size=2) 表示使用两个执行实例,每个实例分别持有自己的状态。实际并行度还受输入分块和可用资源的限制,因此不能把实例内的计数当作全局计数。使用 GPU 模型时,请为执行实例配置 num_gpus,并在构造方法中加载对应模型。

与内置算子串联

以下示例的处理链为:原始文本 → 自定义文本标准化 UDF → split_text → 展开片段 → 输出数据集。自定义逻辑与内置算子通过数据列衔接,并保留来源标识。

说明

示例中的 /shared 是所有 Worker 节点均可读写的共享挂载目录。keep_columns 仅保留所列的来源字段。

import re
from uuid import uuid4
import numpy as np
import ray
from mmops.ray import enable_accessor

ray.init()
enable_accessor()

def normalize_text_batch(batch):
    if any(not isinstance(t, str) for t in batch["text"]):
        raise TypeError("text 必须是字符串")
    return {
        "source_id": batch["source_id"],
        "clean_text": np.asarray([
            re.sub(r"\s+", " ", t).strip() for t in batch["text"]
        ], dtype=str),
    }

source = ray.data.from_items([
    {"source_id": "doc-001", "text": "  产品  使用说明  "},
    {"source_id": "doc-002", "text": "Ray   Data"},
])
cleaned = source.map_batches(
    normalize_text_batch, batch_format="numpy", batch_size=32, num_cpus=1,
)
chunks = cleaned.mmops.split_text(
    text_column="clean_text", keep_columns=["source_id"],
    config={"strategy": "length", "max_chars": 800, "overlap_chars": 80},
    batch_size=1, concurrency=4, num_cpus=1,
)
result = chunks.flat_map(lambda row: [
    {"source_id": row["source_id"],
     "chunk_index": chunk["chunk_index"], "text": chunk["content"]}
    for chunk in row["chunks"] if chunk["content"].strip()
])
result.select_columns(["source_id", "chunk_index", "text"]).write_parquet(
    "/shared/output/custom-udf/" + uuid4().hex
)

结果为包含 source_id、chunk_index 和 text 列的片段数据集。后续可以继续接入向量化等算子,具体输入列与参数以对应接口文档为准。

示例返回结果

以下结果按上述输入通过 Ray Dataset 执行得到,以 JSON 格式展示取出的行。算子返回 Dataset,take() 仅取少量行到本地查看;多行结果不保证跨分区的顺序。

import json

rows = result.take(2)
print(json.dumps(rows, ensure_ascii=False, indent=2))
[
  {"source_id": "doc-001", "chunk_index": 0, "text": "产品 使用说明"},
  {"source_id": "doc-002", "chunk_index": 0, "text": "Ray Data"}
]

参数与使用约定

项目

使用约定

批次与配置

batch_size 是期望的输入行数,实际批次可能更小。业务参数通过 fn_kwargs 传入,构造参数通过 fn_constructor_kwargs 传入。

资源与状态

通过 num_cpus、num_gpus 配置每个执行单元的资源。模型或客户端在 Worker 或 Actor 内初始化。

字段与行数

保留关联标识,避免覆盖已有字段。一条输入生成多条输出时,为每条新记录维护父记录标识和序号。

错误处理

本例对非法输入直接报错。如需按行容错,请显式输出错误状态,不要把失败伪装为空文本或有效结果。

文件与依赖

确保 Worker 节点可以访问输入与产物的 URI。共享包放入相同的镜像或运行环境,避免依赖 Driver 上的临时路径。

重试与写出

UDF 可能被重试,因此外部写入须保证幂等。结果按记录标识关联,不要依赖跨分区的全局行序。

共享使用

将 UDF 封装为可导入的模块,记录名称、版本、输入输出与依赖,流水线中通过 map_batches 调用。

相关文档