aa37067f79
- Add reproducible v6 boundary, blessing, and consensus-adjudication corpora, plus the tiny-transformer trainer and v6 release-gate evaluator that gate every candidate on the deployed baselines. - Wire consensus-label merging, product-policy anchor evaluation, and sealed blessing benchmark review with their pytest coverage. - Refresh open-training corpus generation, iterative retraining runner, and random-holdout evaluation so v6 candidates can be benchmarked end-to-end.
1439 lines
52 KiB
Swift
1439 lines
52 KiB
Swift
#!/usr/bin/env xcrun swift
|
|
|
|
import CreateML
|
|
import Foundation
|
|
|
|
private struct CorpusRecord: Codable {
|
|
let id: String
|
|
let text: String
|
|
let language: String
|
|
let split: String
|
|
let family: String
|
|
let knownLabels: Set<String>?
|
|
let sourceDataset: String?
|
|
let sampleWeight: Double?
|
|
let task: Bool
|
|
let question: Bool
|
|
let invitation: Bool
|
|
let complaint: Bool
|
|
let scheduleNegotiation: Bool
|
|
let confirmationDecision: Bool
|
|
let followUpReminder: Bool
|
|
let blessing: Bool
|
|
let sentiment: String
|
|
let replyable: Bool
|
|
let assistantCommand: Bool?
|
|
let informationQuery: Bool?
|
|
let systemNotification: Bool?
|
|
let domain: String?
|
|
}
|
|
|
|
private struct BinaryMetrics: Codable {
|
|
let total: Int
|
|
let truePositive: Int
|
|
let trueNegative: Int
|
|
let falsePositive: Int
|
|
let falseNegative: Int
|
|
let accuracy: Double
|
|
let precision: Double
|
|
let recall: Double
|
|
let f1: Double
|
|
}
|
|
|
|
private struct MulticlassMetrics: Codable {
|
|
let total: Int
|
|
let accuracy: Double
|
|
let macroF1: Double
|
|
let perLabel: [String: BinaryMetrics]
|
|
let confusion: [String: [String: Int]]
|
|
}
|
|
|
|
private struct CandidateReport: Codable {
|
|
let algorithm: String
|
|
let modelBytes: Int
|
|
let runtimeAssetIndependent: Bool
|
|
let balancedTrainingCount: Int
|
|
let balancedValidationCount: Int
|
|
let threshold: Double?
|
|
let confidenceThresholdsByLanguage: [String: Double]?
|
|
let acceptedForAutomaticRouting: Bool
|
|
let validationBinary: BinaryMetrics?
|
|
let testBinary: BinaryMetrics?
|
|
let goldenBinary: BinaryMetrics?
|
|
let testFalsePositiveExamples: [String]?
|
|
let testFalseNegativeExamples: [String]?
|
|
let goldenFalsePositiveExamples: [String]?
|
|
let goldenFalseNegativeExamples: [String]?
|
|
let binaryByLanguage: [String: BinaryMetrics]?
|
|
let goldenBinaryByLanguage: [String: BinaryMetrics]?
|
|
let validationMulticlass: MulticlassMetrics?
|
|
let testMulticlass: MulticlassMetrics?
|
|
let goldenMulticlass: MulticlassMetrics?
|
|
let multiclassByLanguage: [String: MulticlassMetrics]?
|
|
}
|
|
|
|
private struct ClassifierReport: Codable {
|
|
let id: String
|
|
let labels: [String]
|
|
let positiveLabel: String?
|
|
let selectedAlgorithm: String
|
|
let selectedModelFile: String
|
|
let candidates: [CandidateReport]
|
|
}
|
|
|
|
private struct TrainingReport: Codable {
|
|
let generatedAt: String
|
|
let corpusPath: String
|
|
let corpusCount: Int
|
|
let trainingCount: Int
|
|
let validationCount: Int
|
|
let testCount: Int
|
|
let goldenCount: Int
|
|
let selectionPolicy: String
|
|
let classifiers: [ClassifierReport]
|
|
}
|
|
|
|
private struct ManifestClassifier: Codable {
|
|
let id: String
|
|
let modelFile: String
|
|
let algorithm: String
|
|
let labels: [String]
|
|
let positiveLabel: String?
|
|
let confidenceThreshold: Double?
|
|
let confidenceThresholdsByLanguage: [String: Double]?
|
|
let acceptedForAutomaticRouting: Bool
|
|
}
|
|
|
|
private struct ModelManifest: Codable {
|
|
let schemaVersion: Int
|
|
let generatedAt: String
|
|
let corpusRecordCount: Int
|
|
let classifiers: [ManifestClassifier]
|
|
}
|
|
|
|
private enum CandidateAlgorithm: String, CaseIterable {
|
|
case maxEnt
|
|
case bert
|
|
|
|
var fileSuffix: String {
|
|
switch self {
|
|
case .maxEnt: "maxent"
|
|
case .bert: "bert"
|
|
}
|
|
}
|
|
|
|
var createMLAlgorithm: MLTextClassifier.ModelAlgorithmType {
|
|
switch self {
|
|
case .maxEnt:
|
|
return .maxEnt(revision: 1)
|
|
case .bert:
|
|
return .transferLearning(.bertEmbedding, revision: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private let usesBaselineNegativePolicy = CommandLine.arguments.contains(
|
|
"--baseline-negative-policy"
|
|
)
|
|
|
|
private enum ClassifierID: String, CaseIterable {
|
|
case task
|
|
case question
|
|
case invitation
|
|
case complaint
|
|
case scheduleNegotiation
|
|
case confirmationDecision
|
|
case followUpReminder
|
|
case blessing
|
|
case replyableMessage
|
|
case assistantCommand
|
|
case informationQuery
|
|
case systemNotification
|
|
case domain
|
|
case sentiment
|
|
|
|
var resourceName: String {
|
|
switch self {
|
|
case .task: "TaskIntentClassifier"
|
|
case .question: "QuestionIntentClassifier"
|
|
case .invitation: "InvitationIntentClassifier"
|
|
case .complaint: "ComplaintIntentClassifier"
|
|
case .scheduleNegotiation: "ScheduleNegotiationIntentClassifier"
|
|
case .confirmationDecision: "ConfirmationDecisionIntentClassifier"
|
|
case .followUpReminder: "FollowUpReminderIntentClassifier"
|
|
case .blessing: "BlessingIntentClassifier"
|
|
case .replyableMessage: "ConversationalReplyIntentClassifier"
|
|
case .assistantCommand: "AssistantCommandIntentClassifier"
|
|
case .informationQuery: "InformationQueryIntentClassifier"
|
|
case .systemNotification: "SystemNotificationIntentClassifier"
|
|
case .domain: "ClipboardDomainClassifier"
|
|
case .sentiment: "SentimentClassifier"
|
|
}
|
|
}
|
|
|
|
var labels: [String] {
|
|
switch self {
|
|
case .task: ["notTask", "task"]
|
|
case .question: ["notQuestion", "question"]
|
|
case .invitation: ["notInvitation", "invitation"]
|
|
case .complaint: ["notComplaint", "complaint"]
|
|
case .scheduleNegotiation: ["notScheduleNegotiation", "scheduleNegotiation"]
|
|
case .confirmationDecision: ["notConfirmationDecision", "confirmationDecision"]
|
|
case .followUpReminder: ["notFollowUpReminder", "followUpReminder"]
|
|
case .blessing: ["notBlessing", "blessing"]
|
|
case .replyableMessage: ["notReplyableMessage", "replyableMessage"]
|
|
case .assistantCommand: ["notAssistantCommand", "assistantCommand"]
|
|
case .informationQuery: ["notInformationQuery", "informationQuery"]
|
|
case .systemNotification: ["notSystemNotification", "systemNotification"]
|
|
case .domain:
|
|
[
|
|
"finance", "travel", "calendar", "communication", "media", "smartHome",
|
|
"shopping", "dining", "health", "weather", "accountService", "generalKnowledge"
|
|
]
|
|
case .sentiment: ["negative", "neutral", "positive"]
|
|
}
|
|
}
|
|
|
|
var positiveLabel: String? {
|
|
switch self {
|
|
case .task: "task"
|
|
case .question: "question"
|
|
case .invitation: "invitation"
|
|
case .complaint: "complaint"
|
|
case .scheduleNegotiation: "scheduleNegotiation"
|
|
case .confirmationDecision: "confirmationDecision"
|
|
case .followUpReminder: "followUpReminder"
|
|
case .blessing: "blessing"
|
|
case .replyableMessage: "replyableMessage"
|
|
case .assistantCommand: "assistantCommand"
|
|
case .informationQuery: "informationQuery"
|
|
case .systemNotification: "systemNotification"
|
|
case .domain, .sentiment: nil
|
|
}
|
|
}
|
|
|
|
var hardNegativeFamilies: Set<String> {
|
|
switch self {
|
|
case .task:
|
|
return [
|
|
"complaint_implicit_failure",
|
|
"complaint_incident_diverse",
|
|
"complaint_request",
|
|
"complaint_statement",
|
|
"confirmation_decision",
|
|
"confirmation_selection_short",
|
|
"event_statement",
|
|
"follow_up_personal_reminder",
|
|
"neutral_fact",
|
|
"personal_action_item_boundary",
|
|
"resolved_issue_boundary",
|
|
"self_plan"
|
|
]
|
|
case .invitation:
|
|
return [
|
|
"event_statement",
|
|
"schedule_negotiation",
|
|
"task_question",
|
|
"task_statement"
|
|
]
|
|
case .complaint:
|
|
return [
|
|
"information_question",
|
|
"negative_news",
|
|
"neutral_fact",
|
|
"personal_action_item_boundary",
|
|
"positive_feedback",
|
|
"quoted_question",
|
|
"resolved_issue_boundary",
|
|
"self_plan",
|
|
"task_assignment_diverse",
|
|
"task_completion_boundary",
|
|
"task_indirect_assignment",
|
|
"task_indirect_question",
|
|
"task_statement"
|
|
]
|
|
case .scheduleNegotiation:
|
|
if usesBaselineNegativePolicy {
|
|
return [
|
|
"event_statement",
|
|
"information_question",
|
|
"invitation_question",
|
|
"schedule_fixed_invitation_boundary",
|
|
"task_question",
|
|
"task_statement",
|
|
"vague_future_boundary"
|
|
]
|
|
}
|
|
return [
|
|
"event_statement",
|
|
"confirmation_decision",
|
|
"confirmation_selection_short",
|
|
"follow_up_action",
|
|
"follow_up_triggered",
|
|
"information_question",
|
|
"invitation_question",
|
|
"schedule_fixed_invitation_boundary",
|
|
"task_question",
|
|
"task_statement",
|
|
"vague_future_boundary"
|
|
]
|
|
case .confirmationDecision:
|
|
return [
|
|
"acknowledgment_decision_boundary",
|
|
"event_statement",
|
|
"follow_up_action",
|
|
"follow_up_personal_reminder",
|
|
"follow_up_triggered",
|
|
"neutral_fact",
|
|
"negative_news",
|
|
"schedule_negotiation",
|
|
"task_assignment_diverse",
|
|
"task_statement",
|
|
"vague_future_boundary"
|
|
]
|
|
case .followUpReminder:
|
|
return [
|
|
"acknowledgment",
|
|
"complaint_request",
|
|
"confirmation_decision",
|
|
"confirmation_selection_short",
|
|
"event_statement",
|
|
"invitation_question",
|
|
"neutral_fact",
|
|
"positive_feedback",
|
|
"schedule_negotiation",
|
|
"self_plan",
|
|
"task_assignment_diverse",
|
|
"task_question",
|
|
"task_statement",
|
|
"vague_future_boundary"
|
|
]
|
|
case .blessing:
|
|
return [
|
|
"acknowledgment",
|
|
"blessing_boundary",
|
|
"conversational_message",
|
|
"event_statement",
|
|
"invitation_question",
|
|
"neutral_fact",
|
|
"positive_feedback",
|
|
"quoted_question",
|
|
"task_question",
|
|
"task_statement"
|
|
]
|
|
case .question, .replyableMessage, .assistantCommand, .informationQuery,
|
|
.systemNotification, .domain, .sentiment:
|
|
return []
|
|
}
|
|
}
|
|
|
|
var hardNegativeFraction: Double {
|
|
switch self {
|
|
case .task:
|
|
0.65
|
|
case .complaint, .blessing:
|
|
0.65
|
|
case .invitation, .followUpReminder:
|
|
0.50
|
|
case .scheduleNegotiation:
|
|
0.75
|
|
case .confirmationDecision:
|
|
0.90
|
|
case .question, .replyableMessage, .assistantCommand, .informationQuery,
|
|
.systemNotification, .domain, .sentiment:
|
|
0
|
|
}
|
|
}
|
|
|
|
func label(for record: CorpusRecord) -> String {
|
|
switch self {
|
|
case .task: record.task ? "task" : "notTask"
|
|
case .question: record.question ? "question" : "notQuestion"
|
|
case .invitation: record.invitation ? "invitation" : "notInvitation"
|
|
case .complaint: record.complaint ? "complaint" : "notComplaint"
|
|
case .scheduleNegotiation:
|
|
record.scheduleNegotiation ? "scheduleNegotiation" : "notScheduleNegotiation"
|
|
case .confirmationDecision:
|
|
record.confirmationDecision ? "confirmationDecision" : "notConfirmationDecision"
|
|
case .followUpReminder:
|
|
record.followUpReminder ? "followUpReminder" : "notFollowUpReminder"
|
|
case .blessing:
|
|
record.blessing ? "blessing" : "notBlessing"
|
|
case .replyableMessage: record.replyable ? "replyableMessage" : "notReplyableMessage"
|
|
case .assistantCommand:
|
|
record.assistantCommand == true ? "assistantCommand" : "notAssistantCommand"
|
|
case .informationQuery:
|
|
record.informationQuery == true ? "informationQuery" : "notInformationQuery"
|
|
case .systemNotification:
|
|
record.systemNotification == true ? "systemNotification" : "notSystemNotification"
|
|
case .domain:
|
|
record.domain ?? { preconditionFailure("Known domain record is missing domain") }()
|
|
case .sentiment: record.sentiment
|
|
}
|
|
}
|
|
|
|
func hasKnownLabel(in record: CorpusRecord) -> Bool {
|
|
if let knownLabels = record.knownLabels {
|
|
return knownLabels.contains(rawValue)
|
|
|| (self == .replyableMessage && knownLabels.contains("replyable"))
|
|
}
|
|
// Legacy product corpora predate knownLabels and only fully annotate
|
|
// the original nine intents plus sentiment. New fields must stay unknown.
|
|
switch self {
|
|
case .assistantCommand, .informationQuery, .systemNotification, .domain:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SeededGenerator: RandomNumberGenerator {
|
|
private var state: UInt64
|
|
|
|
init(seed: UInt64) {
|
|
state = seed
|
|
}
|
|
|
|
mutating func next() -> UInt64 {
|
|
state &+= 0x9E37_79B9_7F4A_7C15
|
|
var value = state
|
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
return value ^ (value >> 31)
|
|
}
|
|
}
|
|
|
|
private struct TrainedCandidate {
|
|
let algorithm: CandidateAlgorithm
|
|
let modelURL: URL
|
|
let report: CandidateReport
|
|
}
|
|
|
|
private let fileManager = FileManager.default
|
|
private let repositoryRoot = URL(fileURLWithPath: fileManager.currentDirectoryPath)
|
|
|
|
private func commandLineValue(after flag: String) -> String? {
|
|
guard let index = CommandLine.arguments.firstIndex(of: flag),
|
|
CommandLine.arguments.indices.contains(index + 1) else {
|
|
return nil
|
|
}
|
|
return CommandLine.arguments[index + 1]
|
|
}
|
|
|
|
private func resolvedURL(flag: String, defaultPath: String) -> URL {
|
|
let path = commandLineValue(after: flag) ?? defaultPath
|
|
return URL(fileURLWithPath: path, relativeTo: repositoryRoot).standardizedFileURL
|
|
}
|
|
|
|
private let corpusURL = resolvedURL(
|
|
flag: "--corpus",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl"
|
|
)
|
|
private let candidateDirectory = resolvedURL(
|
|
flag: "--candidate-directory",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/Candidates"
|
|
)
|
|
private let resourceDirectory = resolvedURL(
|
|
flag: "--resource-directory",
|
|
defaultPath: "OSGKeyboardShared/Resources/ClipboardSemantics"
|
|
)
|
|
private let reportURL = resolvedURL(
|
|
flag: "--report",
|
|
defaultPath: "ModelTraining/ClipboardSemantics/evaluation-report.json"
|
|
)
|
|
private let requestedLanguage = commandLineValue(after: "--language")
|
|
|
|
private func loadCorpus() throws -> [CorpusRecord] {
|
|
let content = try String(contentsOf: corpusURL, encoding: .utf8)
|
|
let decoder = JSONDecoder()
|
|
return try content.split(separator: "\n").map { line in
|
|
try decoder.decode(CorpusRecord.self, from: Data(line.utf8))
|
|
}
|
|
}
|
|
|
|
private func stableSeed(for classifier: ClassifierID, split: String) -> UInt64 {
|
|
let material = "\(classifier.rawValue)|\(split)|20260821"
|
|
return material.utf8.reduce(0xcbf2_9ce4_8422_2325) { partial, byte in
|
|
(partial ^ UInt64(byte)) &* 0x0000_0100_0000_01B3
|
|
}
|
|
}
|
|
|
|
private func sourceBalancedPrefix(
|
|
_ records: [CorpusRecord],
|
|
limit: Int,
|
|
classifier: ClassifierID,
|
|
label: String
|
|
) -> [CorpusRecord] {
|
|
// MLTextClassifier's dictionary API has no per-example weight parameter.
|
|
// Quantized weight buckets plus deterministic smooth weighted round-robin
|
|
// preserve registry weights without introducing nondeterministic duplication.
|
|
var grouped = Dictionary(grouping: records) {
|
|
let weight = min(max($0.sampleWeight ?? 1.0, 0.01), 1.0)
|
|
let bucket = (weight * 100).rounded() / 100
|
|
return "\($0.sourceDataset ?? "generated")|weight=\(bucket)"
|
|
}
|
|
for source in grouped.keys.sorted() {
|
|
var generator = SeededGenerator(
|
|
seed: stableSeed(
|
|
for: classifier,
|
|
split: "open|\(label)|\(source)"
|
|
)
|
|
)
|
|
grouped[source]?.shuffle(using: &generator)
|
|
if let values = grouped[source], let first = values.first {
|
|
let weight = min(max(first.sampleWeight ?? 1.0, 0.01), 1.0)
|
|
let weightedCount = max(1, Int((Double(values.count) * weight).rounded()))
|
|
grouped[source] = Array(values.prefix(weightedCount))
|
|
}
|
|
}
|
|
let sources = grouped.keys.sorted()
|
|
let weightedTotal = grouped.values.reduce(0) { $0 + $1.count }
|
|
if weightedTotal <= limit {
|
|
return sources.flatMap { grouped[$0] ?? [] }
|
|
}
|
|
var offsets = Dictionary(uniqueKeysWithValues: sources.map { ($0, 0) })
|
|
let sourceWeights = Dictionary(uniqueKeysWithValues: sources.map { source in
|
|
(source, grouped[source]?.first?.sampleWeight ?? 1.0)
|
|
})
|
|
var schedulingScores = Dictionary(uniqueKeysWithValues: sources.map { ($0, 0.0) })
|
|
var selected: [CorpusRecord] = []
|
|
while selected.count < limit {
|
|
let available = sources.filter {
|
|
let offset = offsets[$0] ?? 0
|
|
return grouped[$0]?.indices.contains(offset) == true
|
|
}
|
|
if available.isEmpty {
|
|
break
|
|
}
|
|
let totalWeight = available.reduce(0.0) {
|
|
$0 + max(sourceWeights[$1] ?? 1.0, 0.01)
|
|
}
|
|
for source in available {
|
|
schedulingScores[source, default: 0] += max(
|
|
sourceWeights[source] ?? 1.0,
|
|
0.01
|
|
)
|
|
}
|
|
let source = available.max {
|
|
let left = schedulingScores[$0, default: 0]
|
|
let right = schedulingScores[$1, default: 0]
|
|
return left == right ? $0 > $1 : left < right
|
|
}!
|
|
let offset = offsets[source] ?? 0
|
|
selected.append(grouped[source]![offset])
|
|
offsets[source] = offset + 1
|
|
schedulingScores[source, default: 0] -= totalWeight
|
|
}
|
|
return selected
|
|
}
|
|
|
|
private func curatedTrainingRecords(
|
|
_ records: [CorpusRecord],
|
|
classifier: ClassifierID
|
|
) -> [CorpusRecord] {
|
|
let knownRecords = records.filter {
|
|
classifier.hasKnownLabel(in: $0)
|
|
}
|
|
let generatedRecords = knownRecords.filter { $0.sourceDataset == nil }
|
|
let openRecords = knownRecords.filter { $0.sourceDataset != nil }
|
|
let weightedGeneratedRecords = sourceBalancedPrefix(
|
|
generatedRecords,
|
|
limit: generatedRecords.count,
|
|
classifier: classifier,
|
|
label: "generated"
|
|
)
|
|
guard !openRecords.isEmpty else { return weightedGeneratedRecords }
|
|
|
|
let generatedByLabel = Dictionary(grouping: weightedGeneratedRecords) {
|
|
classifier.label(for: $0)
|
|
}
|
|
let openByLabel = Dictionary(grouping: openRecords) {
|
|
classifier.label(for: $0)
|
|
}
|
|
let openOnlyBalancedCount = classifier.labels
|
|
.compactMap { openByLabel[$0]?.count }
|
|
.min() ?? 0
|
|
let multiplier = switch classifier {
|
|
case .blessing:
|
|
2.0
|
|
case .task, .question, .complaint, .confirmationDecision, .assistantCommand,
|
|
.informationQuery, .systemNotification, .domain, .sentiment:
|
|
1.0
|
|
case .invitation, .scheduleNegotiation, .followUpReminder, .replyableMessage:
|
|
0.5
|
|
}
|
|
|
|
let selectedOpenRecords = classifier.labels.flatMap { label in
|
|
let generatedCount = generatedByLabel[label]?.count ?? 0
|
|
let anchorCount = generatedCount > 0 ? generatedCount : openOnlyBalancedCount
|
|
let limit = max(1, Int((Double(anchorCount) * multiplier).rounded()))
|
|
return sourceBalancedPrefix(
|
|
openByLabel[label] ?? [],
|
|
limit: limit,
|
|
classifier: classifier,
|
|
label: label
|
|
)
|
|
}
|
|
return weightedGeneratedRecords + selectedOpenRecords
|
|
}
|
|
|
|
private func balancedTexts(
|
|
records: [CorpusRecord],
|
|
classifier: ClassifierID,
|
|
split: String
|
|
) -> [String: [String]] {
|
|
let grouped = Dictionary(grouping: records) { classifier.label(for: $0) }
|
|
let requiredLabels = classifier.labels
|
|
let minimumCount = requiredLabels
|
|
.compactMap { grouped[$0]?.count }
|
|
.min() ?? 0
|
|
precondition(minimumCount > 0, "Missing labels for \(classifier.rawValue) \(split)")
|
|
|
|
var result: [String: [String]] = [:]
|
|
for (offset, label) in requiredLabels.enumerated() {
|
|
var generator = SeededGenerator(
|
|
seed: stableSeed(for: classifier, split: split) &+ UInt64(offset)
|
|
)
|
|
let candidates = grouped[label] ?? []
|
|
if label != classifier.positiveLabel,
|
|
!classifier.hardNegativeFamilies.isEmpty,
|
|
classifier.hardNegativeFraction > 0 {
|
|
var hardNegatives = candidates
|
|
.filter { classifier.hardNegativeFamilies.contains($0.family) }
|
|
.map(\.text)
|
|
.shuffled(using: &generator)
|
|
var remaining = candidates
|
|
.filter { !classifier.hardNegativeFamilies.contains($0.family) }
|
|
.map(\.text)
|
|
.shuffled(using: &generator)
|
|
let requestedHardNegatives = Int(
|
|
(Double(minimumCount) * classifier.hardNegativeFraction).rounded(.down)
|
|
)
|
|
let hardNegativeCount = min(hardNegatives.count, requestedHardNegatives)
|
|
hardNegatives = Array(hardNegatives.prefix(hardNegativeCount))
|
|
remaining = Array(remaining.prefix(minimumCount - hardNegativeCount))
|
|
result[label] = hardNegatives + remaining
|
|
} else {
|
|
let texts = candidates
|
|
.map(\.text)
|
|
.shuffled(using: &generator)
|
|
result[label] = Array(texts.prefix(minimumCount))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func totalCount(_ dictionary: [String: [String]]) -> Int {
|
|
dictionary.values.reduce(0) { $0 + $1.count }
|
|
}
|
|
|
|
private func rounded(_ value: Double) -> Double {
|
|
guard value.isFinite else { return 0 }
|
|
return (value * 10_000).rounded() / 10_000
|
|
}
|
|
|
|
private func binaryMetrics(
|
|
records: [CorpusRecord],
|
|
classifier: ClassifierID,
|
|
positiveLabel: String,
|
|
threshold: Double,
|
|
scores: [Double]
|
|
) -> BinaryMetrics {
|
|
precondition(records.count == scores.count)
|
|
var truePositive = 0
|
|
var trueNegative = 0
|
|
var falsePositive = 0
|
|
var falseNegative = 0
|
|
|
|
for (record, score) in zip(records, scores) {
|
|
let expectedPositive = classifier.label(for: record) == positiveLabel
|
|
let predictedPositive = score >= threshold
|
|
switch (expectedPositive, predictedPositive) {
|
|
case (true, true): truePositive += 1
|
|
case (false, false): trueNegative += 1
|
|
case (false, true): falsePositive += 1
|
|
case (true, false): falseNegative += 1
|
|
}
|
|
}
|
|
|
|
let total = records.count
|
|
let precisionDenominator = truePositive + falsePositive
|
|
let recallDenominator = truePositive + falseNegative
|
|
let precision = precisionDenominator > 0
|
|
? Double(truePositive) / Double(precisionDenominator)
|
|
: 0
|
|
let recall = recallDenominator > 0
|
|
? Double(truePositive) / Double(recallDenominator)
|
|
: 0
|
|
let f1 = precision + recall > 0
|
|
? 2 * precision * recall / (precision + recall)
|
|
: 0
|
|
|
|
return BinaryMetrics(
|
|
total: total,
|
|
truePositive: truePositive,
|
|
trueNegative: trueNegative,
|
|
falsePositive: falsePositive,
|
|
falseNegative: falseNegative,
|
|
accuracy: rounded(
|
|
total > 0 ? Double(truePositive + trueNegative) / Double(total) : 0
|
|
),
|
|
precision: rounded(precision),
|
|
recall: rounded(recall),
|
|
f1: rounded(f1)
|
|
)
|
|
}
|
|
|
|
private func binaryMetrics(
|
|
records: [CorpusRecord],
|
|
classifier: ClassifierID,
|
|
positiveLabel: String,
|
|
globalThreshold: Double,
|
|
thresholdsByLanguage: [String: Double],
|
|
scores: [Double]
|
|
) -> BinaryMetrics {
|
|
precondition(records.count == scores.count)
|
|
let predictions = zip(records, scores).map { record, score in
|
|
score >= (thresholdsByLanguage[record.language] ?? globalThreshold)
|
|
}
|
|
var truePositive = 0
|
|
var trueNegative = 0
|
|
var falsePositive = 0
|
|
var falseNegative = 0
|
|
for (record, predictedPositive) in zip(records, predictions) {
|
|
let expectedPositive = classifier.label(for: record) == positiveLabel
|
|
switch (expectedPositive, predictedPositive) {
|
|
case (true, true): truePositive += 1
|
|
case (false, false): trueNegative += 1
|
|
case (false, true): falsePositive += 1
|
|
case (true, false): falseNegative += 1
|
|
}
|
|
}
|
|
let total = records.count
|
|
let precision = truePositive + falsePositive > 0
|
|
? Double(truePositive) / Double(truePositive + falsePositive)
|
|
: 0
|
|
let recall = truePositive + falseNegative > 0
|
|
? Double(truePositive) / Double(truePositive + falseNegative)
|
|
: 0
|
|
return BinaryMetrics(
|
|
total: total,
|
|
truePositive: truePositive,
|
|
trueNegative: trueNegative,
|
|
falsePositive: falsePositive,
|
|
falseNegative: falseNegative,
|
|
accuracy: rounded(
|
|
total > 0 ? Double(truePositive + trueNegative) / Double(total) : 0
|
|
),
|
|
precision: rounded(precision),
|
|
recall: rounded(recall),
|
|
f1: rounded(
|
|
precision + recall > 0
|
|
? 2 * precision * recall / (precision + recall)
|
|
: 0
|
|
)
|
|
)
|
|
}
|
|
|
|
private func scores(
|
|
classifier: MLTextClassifier,
|
|
records: [CorpusRecord],
|
|
positiveLabel: String
|
|
) throws -> [Double] {
|
|
try records.map { record in
|
|
try classifier.predictionWithConfidence(from: record.text)[positiveLabel] ?? 0
|
|
}
|
|
}
|
|
|
|
private func binaryErrorExamples(
|
|
records: [CorpusRecord],
|
|
classifier: ClassifierID,
|
|
positiveLabel: String,
|
|
threshold: Double,
|
|
thresholdsByLanguage: [String: Double] = [:],
|
|
scores: [Double],
|
|
expectedPositive: Bool,
|
|
predictedPositive: Bool,
|
|
limit: Int = 12
|
|
) -> [String] {
|
|
zip(records, scores).compactMap { record, score -> String? in
|
|
let isExpectedPositive = classifier.label(for: record) == positiveLabel
|
|
let effectiveThreshold = thresholdsByLanguage[record.language] ?? threshold
|
|
let isPredictedPositive = score >= effectiveThreshold
|
|
guard isExpectedPositive == expectedPositive,
|
|
isPredictedPositive == predictedPositive else {
|
|
return nil
|
|
}
|
|
return "[\(record.language)] \(record.text) (score=\(rounded(score)))"
|
|
}
|
|
.prefix(limit)
|
|
.map { $0 }
|
|
}
|
|
|
|
private func calibratedThreshold(
|
|
records: [CorpusRecord],
|
|
classifierID: ClassifierID,
|
|
positiveLabel: String,
|
|
scores: [Double]
|
|
) -> (threshold: Double, metrics: BinaryMetrics) {
|
|
var candidates: [(Double, BinaryMetrics)] = []
|
|
// Low-confidence positives are too unstable for automatic keyboard
|
|
// routing even when a synthetic validation split happens to accept them.
|
|
let minimumThreshold = switch classifierID {
|
|
case .scheduleNegotiation, .confirmationDecision:
|
|
30
|
|
case .followUpReminder:
|
|
58
|
|
default:
|
|
60
|
|
}
|
|
for integer in minimumThreshold...99 {
|
|
let threshold = Double(integer) / 100
|
|
candidates.append(
|
|
(
|
|
threshold,
|
|
binaryMetrics(
|
|
records: records,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: threshold,
|
|
scores: scores
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
let highPrecision = candidates.filter { $0.1.precision >= 0.97 }
|
|
if let best = highPrecision.max(by: {
|
|
if $0.1.recall == $1.1.recall {
|
|
if $0.1.precision == $1.1.precision {
|
|
// Prefer the lowest threshold on an identical validation
|
|
// plateau so held-out paraphrases are not needlessly lost.
|
|
return $0.0 > $1.0
|
|
}
|
|
return $0.1.precision < $1.1.precision
|
|
}
|
|
return $0.1.recall < $1.1.recall
|
|
}) {
|
|
return best
|
|
}
|
|
return candidates.max(by: { $0.1.f1 < $1.1.f1 })
|
|
?? (0.50, binaryMetrics(
|
|
records: records,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: 0.50,
|
|
scores: scores
|
|
))
|
|
}
|
|
|
|
private func calibratedThresholdsByLanguage(
|
|
records: [CorpusRecord],
|
|
classifierID: ClassifierID,
|
|
positiveLabel: String,
|
|
scores: [Double],
|
|
minimumPerClass: Int = 20
|
|
) -> [String: Double] {
|
|
var result: [String: Double] = [:]
|
|
for language in Set(records.map(\.language)).sorted() {
|
|
let indexed = records.enumerated().filter { $0.element.language == language }
|
|
let languageRecords = indexed.map(\.element)
|
|
let positiveCount = languageRecords.filter {
|
|
classifierID.label(for: $0) == positiveLabel
|
|
}.count
|
|
let negativeCount = languageRecords.count - positiveCount
|
|
guard positiveCount >= minimumPerClass, negativeCount >= minimumPerClass else {
|
|
print(
|
|
"CALIBRATION_SKIPPED classifier=\(classifierID.rawValue) "
|
|
+ "language=\(language) positives=\(positiveCount) negatives=\(negativeCount)"
|
|
)
|
|
continue
|
|
}
|
|
let languageScores = indexed.map { scores[$0.offset] }
|
|
let calibration = calibratedThreshold(
|
|
records: languageRecords,
|
|
classifierID: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
scores: languageScores
|
|
)
|
|
result[language] = rounded(calibration.threshold)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func multiclassMetrics(
|
|
records: [CorpusRecord],
|
|
classifierID: ClassifierID,
|
|
predictions: [String]
|
|
) -> MulticlassMetrics {
|
|
precondition(records.count == predictions.count)
|
|
let labels = classifierID.labels
|
|
var confusion = Dictionary(
|
|
uniqueKeysWithValues: labels.map { expected in
|
|
(expected, Dictionary(uniqueKeysWithValues: labels.map { ($0, 0) }))
|
|
}
|
|
)
|
|
|
|
for (record, predicted) in zip(records, predictions) {
|
|
let expected = classifierID.label(for: record)
|
|
confusion[expected, default: [:]][predicted, default: 0] += 1
|
|
}
|
|
|
|
var perLabel: [String: BinaryMetrics] = [:]
|
|
for label in labels {
|
|
let binaryRecords = records.enumerated().map { index, record in
|
|
(expected: classifierID.label(for: record) == label, predicted: predictions[index] == label)
|
|
}
|
|
let truePositive = binaryRecords.filter { $0.expected && $0.predicted }.count
|
|
let trueNegative = binaryRecords.filter { !$0.expected && !$0.predicted }.count
|
|
let falsePositive = binaryRecords.filter { !$0.expected && $0.predicted }.count
|
|
let falseNegative = binaryRecords.filter { $0.expected && !$0.predicted }.count
|
|
let precision = truePositive + falsePositive > 0
|
|
? Double(truePositive) / Double(truePositive + falsePositive)
|
|
: 0
|
|
let recall = truePositive + falseNegative > 0
|
|
? Double(truePositive) / Double(truePositive + falseNegative)
|
|
: 0
|
|
let f1 = precision + recall > 0
|
|
? 2 * precision * recall / (precision + recall)
|
|
: 0
|
|
perLabel[label] = BinaryMetrics(
|
|
total: records.count,
|
|
truePositive: truePositive,
|
|
trueNegative: trueNegative,
|
|
falsePositive: falsePositive,
|
|
falseNegative: falseNegative,
|
|
accuracy: rounded(
|
|
records.isEmpty
|
|
? 0
|
|
: Double(truePositive + trueNegative) / Double(records.count)
|
|
),
|
|
precision: rounded(precision),
|
|
recall: rounded(recall),
|
|
f1: rounded(f1)
|
|
)
|
|
}
|
|
|
|
let correct = zip(records, predictions).filter {
|
|
classifierID.label(for: $0.0) == $0.1
|
|
}.count
|
|
let macroF1 = labels.compactMap { perLabel[$0]?.f1 }.reduce(0, +)
|
|
/ Double(labels.count)
|
|
return MulticlassMetrics(
|
|
total: records.count,
|
|
accuracy: rounded(records.isEmpty ? 0 : Double(correct) / Double(records.count)),
|
|
macroF1: rounded(macroF1),
|
|
perLabel: perLabel,
|
|
confusion: confusion
|
|
)
|
|
}
|
|
|
|
private func modelFileSize(at url: URL) -> Int {
|
|
let attributes = try? fileManager.attributesOfItem(atPath: url.path)
|
|
return attributes?[.size] as? Int ?? 0
|
|
}
|
|
|
|
private func train(
|
|
classifierID: ClassifierID,
|
|
algorithm: CandidateAlgorithm,
|
|
trainingRecords: [CorpusRecord],
|
|
validationRecords: [CorpusRecord],
|
|
testRecords: [CorpusRecord],
|
|
goldenRecords: [CorpusRecord]
|
|
) throws -> TrainedCandidate {
|
|
// Open datasets often annotate only a subset of product intents. Excluding
|
|
// unknown labels prevents an unannotated intent from becoming a false negative.
|
|
// Source-balanced caps then preserve the reviewed base corpus as the boundary
|
|
// anchor instead of allowing one large dataset to dominate model weights.
|
|
let knownTrainingRecords = curatedTrainingRecords(
|
|
trainingRecords,
|
|
classifier: classifierID
|
|
)
|
|
let trainingTexts = balancedTexts(
|
|
records: knownTrainingRecords,
|
|
classifier: classifierID,
|
|
split: "train"
|
|
)
|
|
let validationTexts = balancedTexts(
|
|
records: validationRecords,
|
|
classifier: classifierID,
|
|
split: "validation"
|
|
)
|
|
var parameters = MLTextClassifier.ModelParameters(
|
|
validation: .dictionary(validationTexts),
|
|
algorithm: algorithm.createMLAlgorithm,
|
|
language: nil
|
|
)
|
|
parameters.maxIterations = algorithm == .bert ? 20 : 50
|
|
|
|
print(
|
|
"TRAIN_BEGIN classifier=\(classifierID.rawValue) "
|
|
+ "algorithm=\(algorithm.rawValue) samples=\(totalCount(trainingTexts))"
|
|
)
|
|
let classifier = try MLTextClassifier(
|
|
trainingData: trainingTexts,
|
|
parameters: parameters
|
|
)
|
|
|
|
try fileManager.createDirectory(
|
|
at: candidateDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let modelURL = candidateDirectory.appendingPathComponent(
|
|
"\(classifierID.resourceName)-\(algorithm.fileSuffix).mlmodel"
|
|
)
|
|
if fileManager.fileExists(atPath: modelURL.path) {
|
|
try fileManager.removeItem(at: modelURL)
|
|
}
|
|
let metadata = MLModelMetadata(
|
|
author: "OSGKeyboard",
|
|
shortDescription: "Local clipboard \(classifierID.rawValue) classifier",
|
|
license: nil,
|
|
version: "1.0.0",
|
|
additional: [
|
|
"Corpus": "Synthetic bilingual clipboard semantics corpus",
|
|
"ContainsUserClipboardData": "false",
|
|
"Algorithm": algorithm.rawValue
|
|
]
|
|
)
|
|
try classifier.write(to: modelURL, metadata: metadata)
|
|
|
|
let report: CandidateReport
|
|
if let positiveLabel = classifierID.positiveLabel {
|
|
let validationScores = try scores(
|
|
classifier: classifier,
|
|
records: validationRecords,
|
|
positiveLabel: positiveLabel
|
|
)
|
|
let calibration = calibratedThreshold(
|
|
records: validationRecords,
|
|
classifierID: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
scores: validationScores
|
|
)
|
|
let calibratedLanguageThresholds = calibratedThresholdsByLanguage(
|
|
records: validationRecords,
|
|
classifierID: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
scores: validationScores
|
|
)
|
|
let thresholdsByLanguage = calibratedLanguageThresholds.mapValues {
|
|
max($0, rounded(calibration.threshold))
|
|
}
|
|
let testScores = try scores(
|
|
classifier: classifier,
|
|
records: testRecords,
|
|
positiveLabel: positiveLabel
|
|
)
|
|
let testMetrics = binaryMetrics(
|
|
records: testRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
globalThreshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: testScores
|
|
)
|
|
let goldenScores = try scores(
|
|
classifier: classifier,
|
|
records: goldenRecords,
|
|
positiveLabel: positiveLabel
|
|
)
|
|
let goldenMetrics = binaryMetrics(
|
|
records: goldenRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
globalThreshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: goldenScores
|
|
)
|
|
var byLanguage: [String: BinaryMetrics] = [:]
|
|
for language in Set(testRecords.map(\.language)).sorted() {
|
|
let indexed = testRecords.enumerated().filter { $0.element.language == language }
|
|
let records = indexed.map(\.element)
|
|
let languageScores = indexed.map { testScores[$0.offset] }
|
|
byLanguage[language] = binaryMetrics(
|
|
records: records,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: thresholdsByLanguage[language] ?? calibration.threshold,
|
|
scores: languageScores
|
|
)
|
|
}
|
|
var goldenByLanguage: [String: BinaryMetrics] = [:]
|
|
for language in Set(goldenRecords.map(\.language)).sorted() {
|
|
let indexed = goldenRecords.enumerated().filter { $0.element.language == language }
|
|
goldenByLanguage[language] = binaryMetrics(
|
|
records: indexed.map(\.element),
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: thresholdsByLanguage[language] ?? calibration.threshold,
|
|
scores: indexed.map { goldenScores[$0.offset] }
|
|
)
|
|
}
|
|
report = CandidateReport(
|
|
algorithm: algorithm.rawValue,
|
|
modelBytes: modelFileSize(at: modelURL),
|
|
runtimeAssetIndependent: algorithm == .maxEnt,
|
|
balancedTrainingCount: totalCount(trainingTexts),
|
|
balancedValidationCount: totalCount(validationTexts),
|
|
threshold: rounded(calibration.threshold),
|
|
confidenceThresholdsByLanguage:
|
|
thresholdsByLanguage.isEmpty ? nil : thresholdsByLanguage,
|
|
acceptedForAutomaticRouting: algorithm == .maxEnt
|
|
&& calibration.metrics.precision >= 0.97
|
|
&& testMetrics.precision >= 0.90
|
|
&& goldenMetrics.precision >= 0.90,
|
|
validationBinary: calibration.metrics,
|
|
testBinary: testMetrics,
|
|
goldenBinary: goldenMetrics,
|
|
testFalsePositiveExamples: binaryErrorExamples(
|
|
records: testRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: testScores,
|
|
expectedPositive: false,
|
|
predictedPositive: true
|
|
),
|
|
testFalseNegativeExamples: binaryErrorExamples(
|
|
records: testRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: testScores,
|
|
expectedPositive: true,
|
|
predictedPositive: false
|
|
),
|
|
goldenFalsePositiveExamples: binaryErrorExamples(
|
|
records: goldenRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: goldenScores,
|
|
expectedPositive: false,
|
|
predictedPositive: true
|
|
),
|
|
goldenFalseNegativeExamples: binaryErrorExamples(
|
|
records: goldenRecords,
|
|
classifier: classifierID,
|
|
positiveLabel: positiveLabel,
|
|
threshold: calibration.threshold,
|
|
thresholdsByLanguage: thresholdsByLanguage,
|
|
scores: goldenScores,
|
|
expectedPositive: true,
|
|
predictedPositive: false
|
|
),
|
|
binaryByLanguage: byLanguage,
|
|
goldenBinaryByLanguage: goldenByLanguage,
|
|
validationMulticlass: nil,
|
|
testMulticlass: nil,
|
|
goldenMulticlass: nil,
|
|
multiclassByLanguage: nil
|
|
)
|
|
} else {
|
|
let validationPredictions = try classifier.predictions(
|
|
from: validationRecords.map(\.text)
|
|
)
|
|
let testPredictions = try classifier.predictions(
|
|
from: testRecords.map(\.text)
|
|
)
|
|
let validationMetrics = multiclassMetrics(
|
|
records: validationRecords,
|
|
classifierID: classifierID,
|
|
predictions: validationPredictions
|
|
)
|
|
let testMetrics = multiclassMetrics(
|
|
records: testRecords,
|
|
classifierID: classifierID,
|
|
predictions: testPredictions
|
|
)
|
|
let goldenPredictions = try classifier.predictions(
|
|
from: goldenRecords.map(\.text)
|
|
)
|
|
let goldenMetrics = multiclassMetrics(
|
|
records: goldenRecords,
|
|
classifierID: classifierID,
|
|
predictions: goldenPredictions
|
|
)
|
|
var byLanguage: [String: MulticlassMetrics] = [:]
|
|
for language in Set(testRecords.map(\.language)).sorted() {
|
|
let indexed = testRecords.enumerated().filter { $0.element.language == language }
|
|
byLanguage[language] = multiclassMetrics(
|
|
records: indexed.map(\.element),
|
|
classifierID: classifierID,
|
|
predictions: indexed.map { testPredictions[$0.offset] }
|
|
)
|
|
}
|
|
report = CandidateReport(
|
|
algorithm: algorithm.rawValue,
|
|
modelBytes: modelFileSize(at: modelURL),
|
|
runtimeAssetIndependent: algorithm == .maxEnt,
|
|
balancedTrainingCount: totalCount(trainingTexts),
|
|
balancedValidationCount: totalCount(validationTexts),
|
|
threshold: nil,
|
|
confidenceThresholdsByLanguage: nil,
|
|
acceptedForAutomaticRouting: algorithm == .maxEnt
|
|
&& validationMetrics.macroF1 >= 0.85
|
|
&& testMetrics.macroF1 >= 0.85
|
|
&& goldenMetrics.macroF1 >= 0.75,
|
|
validationBinary: nil,
|
|
testBinary: nil,
|
|
goldenBinary: nil,
|
|
testFalsePositiveExamples: nil,
|
|
testFalseNegativeExamples: nil,
|
|
goldenFalsePositiveExamples: nil,
|
|
goldenFalseNegativeExamples: nil,
|
|
binaryByLanguage: nil,
|
|
goldenBinaryByLanguage: nil,
|
|
validationMulticlass: validationMetrics,
|
|
testMulticlass: testMetrics,
|
|
goldenMulticlass: goldenMetrics,
|
|
multiclassByLanguage: byLanguage
|
|
)
|
|
}
|
|
|
|
print(
|
|
"TRAIN_DONE classifier=\(classifierID.rawValue) "
|
|
+ "algorithm=\(algorithm.rawValue) bytes=\(report.modelBytes)"
|
|
)
|
|
return TrainedCandidate(
|
|
algorithm: algorithm,
|
|
modelURL: modelURL,
|
|
report: report
|
|
)
|
|
}
|
|
|
|
private func selectionScore(_ candidate: TrainedCandidate) -> (Int, Double, Double) {
|
|
if let binary = candidate.report.validationBinary {
|
|
return (
|
|
candidate.report.acceptedForAutomaticRouting
|
|
? 2
|
|
: (candidate.report.runtimeAssetIndependent ? 1 : 0),
|
|
binary.recall,
|
|
binary.precision
|
|
)
|
|
}
|
|
if let multiclass = candidate.report.validationMulticlass {
|
|
return (
|
|
candidate.report.acceptedForAutomaticRouting
|
|
? 2
|
|
: (candidate.report.runtimeAssetIndependent ? 1 : 0),
|
|
multiclass.macroF1,
|
|
multiclass.accuracy
|
|
)
|
|
}
|
|
return (0, 0, 0)
|
|
}
|
|
|
|
private func isBetter(_ lhs: TrainedCandidate, than rhs: TrainedCandidate) -> Bool {
|
|
let left = selectionScore(lhs)
|
|
let right = selectionScore(rhs)
|
|
if left.0 != right.0 { return left.0 > right.0 }
|
|
if left.1 != right.1 { return left.1 > right.1 }
|
|
if left.2 != right.2 { return left.2 > right.2 }
|
|
return lhs.report.modelBytes < rhs.report.modelBytes
|
|
}
|
|
|
|
private func selectedAlgorithms() -> [CandidateAlgorithm] {
|
|
guard let index = CommandLine.arguments.firstIndex(of: "--algorithms"),
|
|
CommandLine.arguments.indices.contains(index + 1)
|
|
else {
|
|
return [.maxEnt]
|
|
}
|
|
let requested = Set(
|
|
CommandLine.arguments[index + 1]
|
|
.split(separator: ",")
|
|
.map(String.init)
|
|
)
|
|
return CandidateAlgorithm.allCases.filter { requested.contains($0.rawValue) }
|
|
}
|
|
|
|
private func selectedClassifiers() -> [ClassifierID] {
|
|
guard let index = CommandLine.arguments.firstIndex(of: "--classifiers"),
|
|
CommandLine.arguments.indices.contains(index + 1)
|
|
else {
|
|
return ClassifierID.allCases
|
|
}
|
|
let requested = Set(
|
|
CommandLine.arguments[index + 1]
|
|
.split(separator: ",")
|
|
.map(String.init)
|
|
)
|
|
return ClassifierID.allCases.filter { requested.contains($0.rawValue) }
|
|
}
|
|
|
|
private func writeJSON<T: Encodable>(_ value: T, to url: URL) throws {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
let data = try encoder.encode(value)
|
|
try fileManager.createDirectory(
|
|
at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try data.write(to: url, options: .atomic)
|
|
}
|
|
|
|
private func main() throws {
|
|
let loadedRecords = try loadCorpus()
|
|
let records = requestedLanguage.map { language in
|
|
loadedRecords.filter { $0.language == language }
|
|
} ?? loadedRecords
|
|
precondition(!records.isEmpty, "No corpus records match the requested language")
|
|
let trainingRecords = records.filter { $0.split == "train" }
|
|
let validationRecords = records.filter { $0.split == "validation" }
|
|
let testRecords = records.filter { $0.split == "test" }
|
|
let goldenRecords = records.filter { $0.split == "golden" }
|
|
let algorithms = selectedAlgorithms()
|
|
let requestedClassifiers = selectedClassifiers()
|
|
let classifiers = requestedClassifiers.filter { classifier in
|
|
let trainingLabels = Set(
|
|
trainingRecords
|
|
.filter { classifier.hasKnownLabel(in: $0) }
|
|
.map { classifier.label(for: $0) }
|
|
)
|
|
return Set(classifier.labels).isSubset(of: trainingLabels)
|
|
&& [validationRecords, testRecords, goldenRecords].allSatisfy {
|
|
!$0.filter { classifier.hasKnownLabel(in: $0) }.isEmpty
|
|
}
|
|
}
|
|
for classifier in requestedClassifiers where !classifiers.contains(classifier) {
|
|
print(
|
|
"TRAIN_SKIPPED classifier=\(classifier.rawValue) "
|
|
+ "reason=insufficient-known-label-coverage"
|
|
)
|
|
}
|
|
precondition(!algorithms.isEmpty, "No supported algorithms requested")
|
|
precondition(!classifiers.isEmpty, "No classifiers have sufficient known-label coverage")
|
|
|
|
try fileManager.createDirectory(
|
|
at: resourceDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let generatedAt = ISO8601DateFormatter().string(from: Date())
|
|
var classifierReports: [ClassifierReport] = []
|
|
var manifestClassifiers: [ManifestClassifier] = []
|
|
|
|
for classifierID in classifiers {
|
|
var candidates: [TrainedCandidate] = []
|
|
let knownValidationRecords = validationRecords.filter {
|
|
classifierID.hasKnownLabel(in: $0)
|
|
}
|
|
let knownTestRecords = testRecords.filter {
|
|
classifierID.hasKnownLabel(in: $0)
|
|
}
|
|
let knownGoldenRecords = goldenRecords.filter {
|
|
classifierID.hasKnownLabel(in: $0)
|
|
}
|
|
for algorithm in algorithms {
|
|
do {
|
|
candidates.append(
|
|
try train(
|
|
classifierID: classifierID,
|
|
algorithm: algorithm,
|
|
trainingRecords: trainingRecords,
|
|
validationRecords: knownValidationRecords,
|
|
testRecords: knownTestRecords,
|
|
goldenRecords: knownGoldenRecords
|
|
)
|
|
)
|
|
} catch {
|
|
print(
|
|
"TRAIN_FAILED classifier=\(classifierID.rawValue) "
|
|
+ "algorithm=\(algorithm.rawValue) error=\(error)"
|
|
)
|
|
}
|
|
}
|
|
guard let selected = candidates.max(by: { isBetter($1, than: $0) }) else {
|
|
throw NSError(
|
|
domain: "ClipboardSemanticTraining",
|
|
code: 1,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey:
|
|
"All training candidates failed for \(classifierID.rawValue)"
|
|
]
|
|
)
|
|
}
|
|
let selectedURL = resourceDirectory.appendingPathComponent(
|
|
"\(classifierID.resourceName).mlmodel"
|
|
)
|
|
if fileManager.fileExists(atPath: selectedURL.path) {
|
|
try fileManager.removeItem(at: selectedURL)
|
|
}
|
|
try fileManager.copyItem(at: selected.modelURL, to: selectedURL)
|
|
|
|
classifierReports.append(
|
|
ClassifierReport(
|
|
id: classifierID.rawValue,
|
|
labels: classifierID.labels,
|
|
positiveLabel: classifierID.positiveLabel,
|
|
selectedAlgorithm: selected.algorithm.rawValue,
|
|
selectedModelFile: selectedURL.lastPathComponent,
|
|
candidates: candidates.map(\.report)
|
|
)
|
|
)
|
|
manifestClassifiers.append(
|
|
ManifestClassifier(
|
|
id: classifierID.rawValue,
|
|
modelFile: selectedURL.lastPathComponent,
|
|
algorithm: selected.algorithm.rawValue,
|
|
labels: classifierID.labels,
|
|
positiveLabel: classifierID.positiveLabel,
|
|
confidenceThreshold: selected.report.threshold,
|
|
confidenceThresholdsByLanguage:
|
|
selected.report.confidenceThresholdsByLanguage,
|
|
acceptedForAutomaticRouting: selected.report.acceptedForAutomaticRouting
|
|
)
|
|
)
|
|
print(
|
|
"SELECTED classifier=\(classifierID.rawValue) "
|
|
+ "algorithm=\(selected.algorithm.rawValue)"
|
|
)
|
|
}
|
|
|
|
let report = TrainingReport(
|
|
generatedAt: generatedAt,
|
|
corpusPath: corpusURL.path,
|
|
corpusCount: records.count,
|
|
trainingCount: trainingRecords.count,
|
|
validationCount: validationRecords.count,
|
|
testCount: testRecords.count,
|
|
goldenCount: goldenRecords.count,
|
|
selectionPolicy:
|
|
"Open records with unknown labels are excluded per classifier, and source-balanced "
|
|
+ "caps anchor each label to the reviewed generated corpus size. Because Create ML "
|
|
+ "does not expose per-example weights, registry sampleWeight values are applied as "
|
|
+ "deterministic quantized quotas with smooth weighted source scheduling. "
|
|
+ "Validation only: global and per-language binary thresholds require precision "
|
|
+ ">= 0.97, then maximize recall; languages with fewer than 20 examples per class "
|
|
+ "fall back to the global threshold. "
|
|
+ "sentiment prioritizes macro-F1. Automatic routing also requires a self-contained "
|
|
+ "maxEnt model because BERT embedding assets are not guaranteed in extensions. "
|
|
+ "Test and golden data gate deployment but never tune model weights.",
|
|
classifiers: classifierReports
|
|
)
|
|
try writeJSON(report, to: reportURL)
|
|
try writeJSON(
|
|
ModelManifest(
|
|
schemaVersion: 4,
|
|
generatedAt: generatedAt,
|
|
corpusRecordCount: records.count,
|
|
classifiers: manifestClassifiers
|
|
),
|
|
to: resourceDirectory.appendingPathComponent(
|
|
"clipboard-semantic-models.json"
|
|
)
|
|
)
|
|
print("TRAINING_REPORT \(reportURL.path)")
|
|
}
|
|
|
|
do {
|
|
try main()
|
|
} catch {
|
|
fputs("Training failed: \(error)\n", stderr)
|
|
exit(1)
|
|
}
|