小Cの已经记不起来的博客

机器学习入门:消息队列 数据预处理

最近给一个入门项目补数据管道,一开始我图省事,写了个脚本扫目录直接预处理,结果数据一多就卡在那儿,终端半天不动,我还以为代码写挂了。后来才发现,不是模型的问题,也不是 Python 的问题,是我把“读文件、解码图片、缩放、归一化、落盘”全塞一个循环里了,前面还没处理完,后面又不停往里灌。这个场景其实很适合把消息队列拉进来:生产端只管把“要处理哪张图”丢进队列,消费端慢慢做预处理,谁也别催谁。

先把 RabbitMQ 跑起来

我这里用 RabbitMQ,入门够用,管理界面也直观。首先把这个 docker 镜像拉下来:

docker pull docker.1ms.run/rabbitmq:3-management

我这里配了加速域名,不需要的或者拉的时候报错了,可以把 docker.1ms.run/ 删除,没准儿你用的时候这个加速域名已经不可用了。然后启动:

docker run -d --name mq \
  -p 5672:5672 \
  -p 15672:15672 \
  docker.1ms.run/rabbitmq:3-management

不出问题的话,浏览器打开 http://localhost:15672,用户名密码都是 guest。能看到管理界面就说明队列服务起来了。Python 这边装依赖:

pip install pika pillow numpy

生产者别急着发大文件

我一开始想过直接把图片二进制塞进消息里,后来想了想,这玩意儿不合适。队列不是网盘,消息太大既占内存又拖速度。比较稳的做法是消息里只放路径或者对象存储的 key,消费者拿到路径自己去读。

下面这个生产者会把 raw_images 目录下所有 jpg 扔进 raw_images 队列:

import json
from pathlib import Path
import pika

connection = pika.BlockingConnection(
    pika.ConnectionParameters(host="localhost")
)
channel = connection.channel()

channel.queue_declare(queue="raw_images", durable=True)

for img_path in Path("raw_images").glob("*.jpg"):
    message = json.dumps({"image_path": str(img_path.resolve())})
    channel.basic_publish(
        exchange="",
        routing_key="raw_images",
        body=message,
        properties=pika.BasicProperties(delivery_mode=2),
    )
    print("sent", img_path)

connection.close()

delivery_mode=2 是让消息持久化,队列也声明成 durable=True。这样 RabbitMQ 重启后,至少不会全丢。当然,真正生产环境还得考虑磁盘和确认机制,入门阶段先别把坑挖太大。

消费者里做预处理

消费者这边就干一件事:收消息,打开图片,转 RGB,缩到 224x224,归一化,存成 .npy,然后手动 ack。这里我把 prefetch_count 设成 1,避免一次拉太多消息把内存顶上去。

import json
from pathlib import Path

import numpy as np
import pika
from PIL import Image

connection = pika.BlockingConnection(
    pika.ConnectionParameters(host="localhost")
)
channel = connection.channel()
channel.queue_declare(queue="raw_images", durable=True)
channel.queue_declare(queue="preprocessed", durable=True)
channel.basic_qos(prefetch_count=1)

output_dir = Path("preprocessed")
output_dir.mkdir(exist_ok=True)

def callback(ch, method, properties, body):
    data = json.loads(body)
    src = Path(data["image_path"])
    try:
        img = Image.open(src).convert("RGB")
        img = img.resize((224, 224))
        arr = np.asarray(img, dtype=np.float32) / 255.0

        out_path = output_dir / f"{src.stem}.npy"
        np.save(out_path, arr)

        ch.basic_publish(
            exchange="",
            routing_key="preprocessed",
            body=json.dumps({"npy_path": str(out_path.resolve())}),
            properties=pika.BasicProperties(delivery_mode=2),
        )
        ch.basic_ack(delivery_tag=method.delivery_tag)
        print("done", out_path)
    except Exception as e:
        print("failed", src, e)
        # 这里别无限 requeue,坏文件会一直转圈
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

channel.basic_consume(
    queue="raw_images",
    on_message_callback=callback,
    auto_ack=False,
)

print("waiting...")
channel.start_consuming()

跑的时候先开消费者,再跑生产者。这样你能看到消息被一条条消费,预处理结果落到 preprocessed 目录,同时 preprocessed 队列里也有后续训练脚本可以接的消息。训练脚本想批量取数据,就从 preprocessed 队列里读,或者直接读目录,别和预处理搅在一起。

几个容易翻车的点

第一个是路径。生产者发的是宿主机绝对路径,消费者如果跑在另一个容器里,路径很可能对不上。要么把数据目录挂载到相同路径,要么消息里发相对路径加根目录配置,别硬编码。

第二个是 ack。用 auto_ack=True 看着省事,但消费者处理到一半挂了,消息就没了。老老实实手动 ack,出异常就 nack,但别 requeue=True 无限重试,坏图片会把你队列堵死。

第三个是别把消息队列当万能药。数据量很小、预处理几秒跑完的时候,直接写脚本更简单。消息队列真正有用的地方是解耦、削峰和失败重试,比如一边有多个采集端往里丢任务,另一边预处理速度跟不上,或者你想把预处理和训练拆成两个进程慢慢跑。入门阶段先把这条链路跑通,比一上来堆 Kafka 实在得多。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放