Highlight
Create ML Components 把 Create ML 的任务拆成 Transformer 和 Estimator,开发者可以用
appending组合ImageFeaturePrint、LinearRegressor、ColumnSelector等组件,训练图像回归和表格回归这类预定义任务之外的模型。
核心内容
Create ML 原来适合处理一组预定义任务:图像分类、声音分类、动作分类、表格回归等。问题出在边界之外。你可能不想判断一张图是猫还是狗,而是想给香蕉成熟度打 1 到 10 分。你也可能想把 CSV 里的类别列和数值列先做不同预处理,再预测牛油果价格。
这场 session 给出的答案是 Create ML Components。它把机器学习任务拆成两个基本概念:Transformer 负责把输入变成输出,Estimator 负责从训练数据中学习并产生一个 Transformer(03: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>使用type、region、volume三列作为特征,预测price列。numeric.appending(regression)把列预处理和回归器组合成一个表格任务。DataFrame(contentsOfCSVFile:)读取 CSV,来自 TabularData 框架。task.fitted(to:validateOn:)训练表格 transformer,并通过事件读取验证误差。task.read(from:)加载保存过的参数。Create ML Components 的模型需要任务定义和参数文件一起工作(23:34)。model(dataFrame)对包含type、region、volume的新数据帧执行预测。
部署时有两条路。可以导出 Core ML 模型,优点是模型文件自包含,并且能使用优化后的 tensor 操作;限制是自定义 transformer 和 estimator 不受支持,Core ML 支持的输入输出类型也较少(23:55)。如果任务里有自定义组件,可以把任务定义和参数打包进 Swift package(24:39)。
核心启发
-
做一个水果成熟度评分器:拍一张香蕉或牛油果照片,返回 1 到 10 的成熟度分数。这个任务适合从 session 的图像回归器开始,先用文件名保存标注分数,再组合
ImageFeaturePrint和LinearRegressor训练模型。 -
做一个商品照片质量检查器:给电商或二手交易 App 的图片打清晰度、主体居中程度或可售卖程度分数。可以把人工审核分数写入文件名,用
AnnotatedFiles读取标注,再按 session 的做法加入旋转、缩放等数据增强。 -
做一个本地价格估算工具:读取 CSV 里的品类、地区、数量和历史价格,预测下一批商品价格。入口是 TabularData 的
DataFrame、Create ML Components 的ColumnSelector、StandardScaler和BoostedTreeRegressor。 -
做一个可复用的训练命令行工具:把训练、验证误差打印、参数保存封装成 Swift 命令行程序。图像任务调用
estimator.write(transformer, to:)保存参数,App 侧用同一个任务定义读取参数并预测。 -
做一个带预处理步骤的垂直模型包:把裁剪、特征提取、回归器放进同一个任务定义。训练时和推理时复用同一条 pipeline,减少 App 侧手写预处理逻辑和训练逻辑不一致的问题。
关联 Session
- Compose advanced models with Create ML Components — 继续使用 Create ML Components,讲解时序数据、视频动作计数和增量训练。
- Explore the machine learning development experience — 从模型发现、转换、验证、集成到性能优化,串起 Apple 平台机器学习开发流程。
- Optimize your Core ML usage — 用 Xcode 和 Instruments 分析 Core ML 模型在设备上的加载、预测和计算单元使用情况。
评论
GitHub Issues · utterances