自定义离线算子(UDF)
当内置离线算子无法满足您的业务处理规则时,可以使用 Ray Data 编写自定义算子(UDF,用户定义函数),并与内置算子串联在同一条数据处理流水线中。本文介绍 UDF 的接入方式、编写方法、与内置算子的串联方式以及使用约定。
接入方式
将函数或可调用类传给 Ray Data 的 map_batches,即可在数据集流水线中分布式执行自定义逻辑。
|
处理需求 |
Ray Data 入口 |
执行方式 |
|
无状态的批量转换、字段生成 |
|
按批次分布式执行函数。 |
|
需要复用模型、客户端或配置 |
|
在执行实例内初始化并复用状态。 |
|
串联目录中的内置算子 |
|
接收上一步的数据集,继续加工并输出结果。 |
本文示例使用 Ray Data 公开接口。基础示例需要 ray[data] 和 numpy,串联内置算子时还需要 mmops。请确保 Driver 与 Worker 节点使用相同的依赖环境。mmops 的获取和安装方法,请参见mmops SDK 使用指南。mmops 的环境要求和调用约定,请参见离线算子概述。
示例算子:文本标准化
本文以文本标准化算子为例:将连续空白合并为单个空格、去掉首尾空白,并可选转为小写。算子保留原字段,每条输入对应一条输出,并新增清洗后的文本和字符数。
|
字段或参数 |
方向 |
说明 |
|
|
输入,保留 |
原记录的来源标识,便于关联结果。 |
|
|
输入,保留 |
字符串。允许空字符串,不接受 null 或非字符串。 |
|
|
配置 |
是否转为小写,默认为 False,通过 |
|
|
输出 |
合并空白、去掉首尾空白后的文本。 |
|
|
输出 |
清洗后的字符数,包含保留的单个空格。不是 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"}
]
参数与使用约定
|
项目 |
使用约定 |
|
批次与配置 |
|
|
资源与状态 |
通过 |
|
字段与行数 |
保留关联标识,避免覆盖已有字段。一条输入生成多条输出时,为每条新记录维护父记录标识和序号。 |
|
错误处理 |
本例对非法输入直接报错。如需按行容错,请显式输出错误状态,不要把失败伪装为空文本或有效结果。 |
|
文件与依赖 |
确保 Worker 节点可以访问输入与产物的 URI。共享包放入相同的镜像或运行环境,避免依赖 Driver 上的临时路径。 |
|
重试与写出 |
UDF 可能被重试,因此外部写入须保证幂等。结果按记录标识关联,不要依赖跨分区的全局行序。 |
|
共享使用 |
将 UDF 封装为可导入的模块,记录名称、版本、输入输出与依赖,流水线中通过 |