WWDC Quick Look 💓 By SwiftGGTeam
Get to know Create ML Components

Get to know Create ML Components

观看原视频

Highlight

Create ML Components 把 Create ML 的任务拆成 Transformer 和 Estimator,开发者可以用 appending 组合 ImageFeaturePrintLinearRegressorColumnSelector 等组件,训练图像回归和表格回归这类预定义任务之外的模型。

核心内容

Create ML 原来适合处理一组预定义任务:图像分类、声音分类、动作分类、表格回归等。问题出在边界之外。你可能不想判断一张图是猫还是狗,而是想给香蕉成熟度打 1 到 10 分。你也可能想把 CSV 里的类别列和数值列先做不同预处理,再预测牛油果价格。

这场 session 给出的答案是 Create ML Components。它把机器学习任务拆成两个基本概念:Transformer 负责把输入变成输出,Estimator 负责从训练数据中学习并产生一个 Transformer03:58)。组件之间用 appending 串起来,前一个组件的输出类型必须匹配后一个组件的输入类型(04:54)。

这个拆分让开发者可以改造任务内部结构。图像分类器可以把分类器换成回归器,得到图像回归器;图像输入可以先经过显著性裁剪,再进入特征提取;表格数据可以先用 ColumnSelector 只处理某些列,再交给回归器训练。

session 演示了两个完整任务。第一个任务读取文件名里的香蕉成熟度分数,用 ImageFeaturePrint 提取图像特征,再用 LinearRegressor 学出一个分数模型。第二个任务读取牛油果价格 CSV,用 ColumnSelector 选择 volume 列,经过 StandardScaler 标准化后,再接 BoostedTreeRegressor 预测价格。

详细内容

1. 用组件组合一个图像回归器

08:59)图像分类器通常由特征提取器和分类器组成。要把它改成图像回归器,session 只替换第二段:保留 ImageFeaturePrint,把分类器换成 LinearRegressor。训练数据的标注也从字符串标签变成浮点分数。

import CoreImage
import CreateMLComponents

struct ImageRegressor {
    static let trainingDataURL = URL(fileURLWithPath: "~/Desktop/bananas")
    static let parametersURL = URL(fileURLWithPath: "~/Desktop/parameters")

    static func train() async throws -> some Transformer<CIImage, Float> {
        let estimator = ImageFeaturePrint()
            .appending(LinearRegressor())

        // File name example: banana-5.jpg
        let data = try AnnotatedFiles(labeledByNamesAt: trainingDataURL, separator: "-", index: 1, type: .image)
            .mapFeatures(ImageReader.read)
            .mapAnnotations({ Float($0)! })

        let (training, validation) = data.randomSplit(by: 0.8)
        let transformer = try await estimator.fitted(to: training, validateOn: validation)
        try estimator.write(transformer, to: parametersURL)

        return transformer
    }
}

关键点:

  • ImageFeaturePrint().appending(LinearRegressor()) 把图像特征提取器和线性回归器串成一个 estimator。
  • AnnotatedFiles(labeledByNamesAt:separator:index:type:) 从文件名提取标注。示例文件名是 banana-5.jpg,分隔符是 -,分数位于第 1 个片段。
  • mapFeatures(ImageReader.read) 把文件 URL 读成 CIImage
  • mapAnnotations({ Float($0)! }) 把文件名中的字符串分数转换成 Float
  • randomSplit(by: 0.8) 留出 20% 数据用于验证。
  • fitted(to:validateOn:) 从训练集学习参数,并用验证集检查效果。
  • estimator.write(transformer, to:) 保存训练得到的参数,供后续加载或部署。

这里的返回类型是 some Transformer<CIImage, Float>。调用方只关心它能把一张 CIImage 变成一个 Float 分数,不需要知道内部组合了哪些组件。

2. 观察误差、扩充数据、加入显著性裁剪

12:18)第一版模型误差较高,因为香蕉图片数量不够。session 做了三件事:在 fitted 事件里打印训练和验证最大误差;用旋转、缩放扩充训练数据;在特征提取前加入自定义 SaliencyCropper,把图片裁到显著对象区域。

import CoreImage
import CreateMLComponents

struct ImageRegressor {
    static let trainingDataURL = URL(fileURLWithPath: "~/Desktop/bananas")
    static let parametersURL = URL(fileURLWithPath: "~/Desktop/parameters")

    static func train() async throws -> some Transformer<CIImage, Float> {
        let estimator = SaliencyCropper()
            .appending(ImageFeaturePrint())
            .appending(LinearRegressor())

        // File name example: banana-5.jpg
        let data = try AnnotatedFiles(labeledByNamesAt: trainingDataURL, separator: "-", index: 1, type: .image)
            .mapFeatures(ImageReader.read)
            .mapAnnotations({ Float($0)! })
            .flatMap(augment)

        let (training, validation) = data.randomSplit(by: 0.8)
        let transformer = try await estimator.fitted(to: training, validateOn: validation) { event in
            guard let trainingMaxError = event.metrics[.trainingMaximumError] else {
                return
            }
            guard let validationMaxError = event.metrics[.validationMaximumError] else {
                return
            }
            print("Training max error: \(trainingMaxError), Validation max error: \(validationMaxError)")
        }

        let validationError = try await meanAbsoluteError(
            transformer.applied(to: validation.map(\.feature)),
            validation.map(\.annotation)
        )
        print("Mean absolute error: \(validationError)")

        try estimator.write(transformer, to: parametersURL)

        return transformer
    }

    static func augment(_ original: AnnotatedFeature<CIImage, Float>) -> [AnnotatedFeature<CIImage, Float>] {
        let angle = CGFloat.random(in: -.pi ... .pi)
        let rotated = original.feature.transformed(by: .init(rotationAngle: angle))

        let scale = CGFloat.random(in: 0.8 ... 1.2)
        let scaled = original.feature.transformed(by: .init(scaleX: scale, y: scale))

        return [
            original,
            AnnotatedFeature(feature: rotated, annotation: original.annotation),
            AnnotatedFeature(feature: scaled, annotation: original.annotation),
        ]
    }
}

关键点:

  • SaliencyCropper().appending(...) 把自定义 transformer 放在特征提取之前。训练和预测都会走同一个裁剪步骤。
  • .flatMap(augment) 把每个训练样本扩展成原图、旋转图、缩放图三个样本。
  • fitted(... ) { event in ... } 通过事件回调读取训练过程中的 metrics。
  • .trainingMaximumError.validationMaximumError 分别反映训练集和验证集上的最大误差。
  • transformer.applied(to: validation.map(\.feature)) 用训练好的模型对验证集特征做预测。
  • meanAbsoluteError(...) 把预测值和真实分数放在一起计算平均绝对误差。
  • augment(_:) 保留原始标注。旋转和缩放改变的是图片,成熟度分数不变。

session 特别说明,数据增强只在 fitting 时使用,不在预测时使用(13:59)。显著性裁剪不同,它被放进任务定义里,所以训练和推理都会执行(15:28)。

3. 用 ColumnSelector 处理表格列

20:23)表格数据的特点是列类型不同。数值列可能需要标准化,类别列可能需要编码。Create ML Components 用 ColumnSelector 对指定列做处理,再把处理后的表格交给后续 estimator。

import CreateMLComponents
import Foundation
import TabularData

struct TabularRegressor {
    static let dataURL = URL(fileURLWithPath: "~/Downloads/avocado.csv")
    static let parametersURL = URL(fileURLWithPath: "~/Downloads/parameters.pkg")

    static let priceColumnID = ColumnID("price", Double.self)

    static var task: some SupervisedTabularEstimator {
        let numeric = ColumnSelector(
            columns: ["volume"],
            estimator: OptionalUnwrapper()
                .appending(StandardScaler<Double>())
        )
        let regression = BoostedTreeRegressor<String>(
            annotationColumnName: priceColumnID.name,
            featureColumnNames: ["type", "region", "volume"]
        )

        return numeric.appending(regression)
    }

    static func train() async throws -> some TabularTransformer {
        let dataFrame = try DataFrame(contentsOfCSVFile: dataURL)
        let (training, validation) = dataFrame.randomSplit(by: 0.8)
        let transformer = try await task.fitted(to: DataFrame(training), validateOn: DataFrame(validation)) { event in
            guard let validationError = event.metrics[.validationError] as? Double else {
                return
            }
            print("Validation error: \(validationError)")
        }
        try task.write(transformer, to: parametersURL)
        return transformer
    }

    static func predict(
        type: String,
        region: String,
        volume: Double
    ) async throws -> Double {
        let model = try task.read(from: parametersURL)
        let dataFrame: DataFrame = [
            "type": [type],
            "region": [region],
            "volume": [volume]
        ]
        let result = try await model(dataFrame)
        return result[priceColumnID][0]!
    }
}

关键点:

  • ColumnID("price", Double.self) 定义预测目标列,后面读取预测结果也用同一个列 ID。
  • ColumnSelector(columns: ["volume"], estimator: ...) 只选择 volume 列做数值预处理。
  • OptionalUnwrapper().appending(StandardScaler<Double>()) 先解包可选值,再把数值标准化到均值为 0、标准差为 1 的尺度(19:16)。
  • BoostedTreeRegressor<String> 使用 typeregionvolume 三列作为特征,预测 price 列。
  • numeric.appending(regression) 把列预处理和回归器组合成一个表格任务。
  • DataFrame(contentsOfCSVFile:) 读取 CSV,来自 TabularData 框架。
  • task.fitted(to:validateOn:) 训练表格 transformer,并通过事件读取验证误差。
  • task.read(from:) 加载保存过的参数。Create ML Components 的模型需要任务定义和参数文件一起工作(23:34)。
  • model(dataFrame) 对包含 typeregionvolume 的新数据帧执行预测。

部署时有两条路。可以导出 Core ML 模型,优点是模型文件自包含,并且能使用优化后的 tensor 操作;限制是自定义 transformer 和 estimator 不受支持,Core ML 支持的输入输出类型也较少(23:55)。如果任务里有自定义组件,可以把任务定义和参数打包进 Swift package(24:39)。

核心启发

  • 做一个水果成熟度评分器:拍一张香蕉或牛油果照片,返回 1 到 10 的成熟度分数。这个任务适合从 session 的图像回归器开始,先用文件名保存标注分数,再组合 ImageFeaturePrintLinearRegressor 训练模型。

  • 做一个商品照片质量检查器:给电商或二手交易 App 的图片打清晰度、主体居中程度或可售卖程度分数。可以把人工审核分数写入文件名,用 AnnotatedFiles 读取标注,再按 session 的做法加入旋转、缩放等数据增强。

  • 做一个本地价格估算工具:读取 CSV 里的品类、地区、数量和历史价格,预测下一批商品价格。入口是 TabularData 的 DataFrame、Create ML Components 的 ColumnSelectorStandardScalerBoostedTreeRegressor

  • 做一个可复用的训练命令行工具:把训练、验证误差打印、参数保存封装成 Swift 命令行程序。图像任务调用 estimator.write(transformer, to:) 保存参数,App 侧用同一个任务定义读取参数并预测。

  • 做一个带预处理步骤的垂直模型包:把裁剪、特征提取、回归器放进同一个任务定义。训练时和推理时复用同一条 pipeline,减少 App 侧手写预处理逻辑和训练逻辑不一致的问题。

关联 Session

评论

GitHub Issues · utterances