916 lines
33 KiB
Swift
916 lines
33 KiB
Swift
#!/usr/bin/env swift
|
|
|
|
import Foundation
|
|
import NaturalLanguage
|
|
|
|
private struct HoldoutRecord: Decodable {
|
|
let id: String
|
|
let text: String
|
|
let language: String
|
|
let split: String?
|
|
let family: String
|
|
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 sourceDataset: String?
|
|
|
|
func isPositive(for classifierID: String) -> Bool {
|
|
switch classifierID {
|
|
case "task": task
|
|
case "question": question
|
|
case "invitation": invitation
|
|
case "complaint": complaint
|
|
case "scheduleNegotiation": scheduleNegotiation
|
|
case "confirmationDecision": confirmationDecision
|
|
case "followUpReminder": followUpReminder
|
|
case "blessing": blessing ?? false
|
|
case "replyableMessage": replyable
|
|
default: false
|
|
}
|
|
}
|
|
|
|
func expectedVerifierLabel(for verifierID: String) -> String {
|
|
if verifierID == "action" {
|
|
if task && complaint {
|
|
return "both"
|
|
}
|
|
if task {
|
|
return "taskOnly"
|
|
}
|
|
if complaint {
|
|
return "complaintOnly"
|
|
}
|
|
if question {
|
|
return "questionRequest"
|
|
}
|
|
return "neither"
|
|
}
|
|
let coordinationLabels = [
|
|
invitation ? "invitation" : nil,
|
|
scheduleNegotiation ? "scheduleNegotiation" : nil,
|
|
confirmationDecision ? "confirmationDecision" : nil,
|
|
followUpReminder ? "followUpReminder" : nil
|
|
].compactMap { $0 }
|
|
return coordinationLabels.count == 1 ? coordinationLabels[0] : "neither"
|
|
}
|
|
}
|
|
|
|
private struct TrainingRecord: Decodable {
|
|
let text: String
|
|
}
|
|
|
|
private struct Manifest: Decodable {
|
|
let schemaVersion: Int
|
|
let classifiers: [ManifestClassifier]
|
|
let verifiers: [ManifestVerifier]?
|
|
}
|
|
|
|
private struct ManifestClassifier: Decodable {
|
|
let id: String
|
|
let modelFile: String
|
|
let positiveLabel: String?
|
|
let confidenceThreshold: Double?
|
|
let confidenceThresholdsByLanguage: [String: Double]?
|
|
let acceptedForAutomaticRouting: Bool
|
|
}
|
|
|
|
private struct ManifestVerifier: Decodable {
|
|
let id: String
|
|
let modelFile: String
|
|
let confidenceThreshold: Double
|
|
let confidenceThresholdsByLanguage: [String: Double]?
|
|
let minimumMargin: Double
|
|
let minimumMarginsByLanguage: [String: Double]?
|
|
let acceptedForAutomaticRouting: Bool
|
|
let deploymentMode: String
|
|
}
|
|
|
|
private struct BinaryMetrics: Encodable {
|
|
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 ErrorExample: Encodable {
|
|
let id: String
|
|
let language: String
|
|
let family: String
|
|
let sourceDataset: String?
|
|
let text: String
|
|
let confidence: Double
|
|
}
|
|
|
|
private struct BinaryEvaluation: Encodable {
|
|
let id: String
|
|
let metrics: BinaryMetrics
|
|
let metricsByLanguage: [String: BinaryMetrics]
|
|
let metricsBySource: [String: BinaryMetrics]
|
|
let thresholdAt90Precision: ThresholdRecommendation?
|
|
let thresholdAt95Precision: ThresholdRecommendation?
|
|
let thresholdsAt90PrecisionByLanguage: [String: ThresholdRecommendation]
|
|
let thresholdsAt95PrecisionByLanguage: [String: ThresholdRecommendation]
|
|
let falsePositiveExamples: [ErrorExample]
|
|
let falseNegativeExamples: [ErrorExample]
|
|
}
|
|
|
|
private struct ThresholdRecommendation: Encodable {
|
|
let threshold: Double
|
|
let metrics: BinaryMetrics
|
|
}
|
|
|
|
private struct SentimentMetrics: Encodable {
|
|
let total: Int
|
|
let correct: Int
|
|
let unknown: Int
|
|
let accuracy: Double
|
|
let unknownRate: Double
|
|
let macroF1: Double
|
|
let perLabelF1: [String: Double]
|
|
}
|
|
|
|
private struct AggregateMetrics: Encodable {
|
|
let accuracy: Double
|
|
let precision: Double
|
|
let recall: Double
|
|
let f1: Double
|
|
}
|
|
|
|
private struct VerifierRoutingMetrics: Encodable {
|
|
let total: Int
|
|
let expectedSpecialized: Int
|
|
let stageACandidates: Int
|
|
let routed: Int
|
|
let correctRouted: Int
|
|
let falseRouted: Int
|
|
let stageARecall: Double
|
|
let stageBExactAccuracy: Double
|
|
let finalPrecision: Double
|
|
let finalPrecisionWilsonLower95: Double
|
|
let finalRecall: Double
|
|
}
|
|
|
|
private struct VerifierEvaluation: Encodable {
|
|
let id: String
|
|
let deploymentMode: String
|
|
let acceptedForAutomaticRouting: Bool
|
|
let metrics: VerifierRoutingMetrics
|
|
let metricsByLanguage: [String: VerifierRoutingMetrics]
|
|
let metricsBySource: [String: VerifierRoutingMetrics]
|
|
let leaveOneSourceOut: [String: VerifierRoutingMetrics]
|
|
let coldLoadMilliseconds: Double
|
|
let warmMedianMilliseconds: Double
|
|
let warmP95Milliseconds: Double
|
|
}
|
|
|
|
private struct Report: Encodable {
|
|
let generatedAt: String
|
|
let seed: Int
|
|
let corpusRecordCount: Int
|
|
let familyCount: Int
|
|
let languageCounts: [String: Int]
|
|
let exactTrainingOverlapCount: Int
|
|
let manifestSchemaVersion: Int
|
|
let binaryMacro: AggregateMetrics
|
|
let binaryMacroByLanguage: [String: AggregateMetrics]
|
|
let classifiers: [BinaryEvaluation]
|
|
let verifierLayers: [VerifierEvaluation]
|
|
let sentiment: SentimentMetrics
|
|
let sentimentBySource: [String: SentimentMetrics]
|
|
}
|
|
|
|
private struct BinaryObservation {
|
|
let record: HoldoutRecord
|
|
let expected: Bool
|
|
let predicted: Bool
|
|
let confidence: Double
|
|
let isSuppressed: Bool
|
|
}
|
|
|
|
private struct VerifierObservation {
|
|
let record: HoldoutRecord
|
|
let expectedLabel: String
|
|
let isStageACandidate: Bool
|
|
let predictedLabel: String
|
|
let confidence: Double
|
|
let margin: Double
|
|
let isRouted: Bool
|
|
let latencyMilliseconds: Double
|
|
}
|
|
|
|
private let fileManager = FileManager.default
|
|
private let root = URL(fileURLWithPath: fileManager.currentDirectoryPath)
|
|
private func argumentValue(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 let corpusURL = argumentValue(after: "--corpus").map {
|
|
URL(fileURLWithPath: $0, relativeTo: root).standardizedFileURL
|
|
} ?? root.appendingPathComponent(
|
|
"ModelTraining/ClipboardSemantics/random-holdout-corpus.jsonl"
|
|
)
|
|
private let trainingCorpusURL = root.appendingPathComponent(
|
|
"ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl"
|
|
)
|
|
private let manifestURL = argumentValue(after: "--manifest").map {
|
|
URL(fileURLWithPath: $0, relativeTo: root).standardizedFileURL
|
|
} ?? root.appendingPathComponent(
|
|
"OSGKeyboardShared/Resources/ClipboardSemantics/clipboard-semantic-models.json"
|
|
)
|
|
private let modelDirectory = argumentValue(after: "--models").map {
|
|
URL(fileURLWithPath: $0, relativeTo: root).standardizedFileURL
|
|
} ?? manifestURL.deletingLastPathComponent()
|
|
private let reportURL = argumentValue(after: "--report").map {
|
|
URL(fileURLWithPath: $0, relativeTo: root).standardizedFileURL
|
|
} ?? root.appendingPathComponent(
|
|
"ModelTraining/ClipboardSemantics/random-holdout-report.json"
|
|
)
|
|
private let sentimentMinimumConfidence = 0.65
|
|
private let sentimentMinimumMargin = 0.15
|
|
private let holdoutSeed = argumentValue(after: "--seed").flatMap(Int.init) ?? 20260826
|
|
private let includesRejectedModels = CommandLine.arguments.contains(
|
|
"--include-rejected-models"
|
|
)
|
|
private let requestedSplit = argumentValue(after: "--split")
|
|
|
|
private func rounded(_ value: Double) -> Double {
|
|
guard value.isFinite else { return 0 }
|
|
return (value * 10_000).rounded() / 10_000
|
|
}
|
|
|
|
private func milliseconds(_ duration: Duration) -> Double {
|
|
Double(duration.components.seconds) * 1_000
|
|
+ Double(duration.components.attoseconds) / 1_000_000_000_000_000
|
|
}
|
|
|
|
private func decodeJSONLines<T: Decodable>(_ type: T.Type, from url: URL) throws -> [T] {
|
|
let content = try String(contentsOf: url, encoding: .utf8)
|
|
let decoder = JSONDecoder()
|
|
return try content.split(separator: "\n").map {
|
|
try decoder.decode(type, from: Data($0.utf8))
|
|
}
|
|
}
|
|
|
|
private func normalized(_ text: String) -> String {
|
|
text
|
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
|
|
.split(whereSeparator: \.isWhitespace)
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
private func compileModel(sourceURL: URL, outputDirectory: URL) throws -> URL {
|
|
let process = Process()
|
|
let outputPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
|
|
process.arguments = [
|
|
"coremlcompiler",
|
|
"compile",
|
|
sourceURL.path,
|
|
outputDirectory.path
|
|
]
|
|
process.standardOutput = outputPipe
|
|
process.standardError = outputPipe
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus == 0 else {
|
|
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let output = String(bytes: data, encoding: .utf8) ?? ""
|
|
throw NSError(
|
|
domain: "RandomHoldoutEvaluation",
|
|
code: Int(process.terminationStatus),
|
|
userInfo: [NSLocalizedDescriptionKey: output]
|
|
)
|
|
}
|
|
let resourceName = sourceURL.deletingPathExtension().lastPathComponent
|
|
return outputDirectory.appendingPathComponent("\(resourceName).mlmodelc")
|
|
}
|
|
|
|
private func binaryMetrics(_ observations: [BinaryObservation]) -> BinaryMetrics {
|
|
var truePositive = 0
|
|
var trueNegative = 0
|
|
var falsePositive = 0
|
|
var falseNegative = 0
|
|
for observation in observations {
|
|
switch (observation.expected, observation.predicted) {
|
|
case (true, true): truePositive += 1
|
|
case (false, false): trueNegative += 1
|
|
case (false, true): falsePositive += 1
|
|
case (true, false): falseNegative += 1
|
|
}
|
|
}
|
|
let total = observations.count
|
|
let precision = truePositive + falsePositive > 0
|
|
? Double(truePositive) / Double(truePositive + falsePositive)
|
|
: 0
|
|
let recall = truePositive + falseNegative > 0
|
|
? Double(truePositive) / Double(truePositive + falseNegative)
|
|
: 0
|
|
let accuracy = total > 0
|
|
? Double(truePositive + trueNegative) / Double(total)
|
|
: 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(accuracy),
|
|
precision: rounded(precision),
|
|
recall: rounded(recall),
|
|
f1: rounded(f1)
|
|
)
|
|
}
|
|
|
|
private func thresholdRecommendation(
|
|
observations: [BinaryObservation],
|
|
minimumPrecision: Double
|
|
) -> ThresholdRecommendation? {
|
|
let candidates = stride(from: 0.05, through: 0.99, by: 0.01).compactMap { threshold
|
|
-> ThresholdRecommendation? in
|
|
let adjusted = observations.map {
|
|
BinaryObservation(
|
|
record: $0.record,
|
|
expected: $0.expected,
|
|
predicted: !$0.isSuppressed && $0.confidence >= threshold,
|
|
confidence: $0.confidence,
|
|
isSuppressed: $0.isSuppressed
|
|
)
|
|
}
|
|
let metrics = binaryMetrics(adjusted)
|
|
guard metrics.truePositive > 0, metrics.precision >= minimumPrecision else {
|
|
return nil
|
|
}
|
|
return ThresholdRecommendation(
|
|
threshold: rounded(threshold),
|
|
metrics: metrics
|
|
)
|
|
}
|
|
return candidates.max {
|
|
if $0.metrics.recall != $1.metrics.recall {
|
|
return $0.metrics.recall < $1.metrics.recall
|
|
}
|
|
if $0.metrics.precision != $1.metrics.precision {
|
|
return $0.metrics.precision < $1.metrics.precision
|
|
}
|
|
return $0.threshold > $1.threshold
|
|
}
|
|
}
|
|
|
|
private func shouldSuppressTask(
|
|
text: String,
|
|
complaintConfidence: Double
|
|
) -> Bool {
|
|
guard complaintConfidence >= 0.60 else { return false }
|
|
let normalized = text.lowercased()
|
|
let explicitTaskMarkers = [
|
|
"请", "麻烦", "能否", "可以请你", "由你", "交给你", "需要你",
|
|
"你负责", "下一步", "行动项", "please ", "can you", "could you",
|
|
"would you", "assigned to you", "you are responsible", "we need you",
|
|
"would like you", "counting on you", "take ownership", "your task",
|
|
"next action", "complete the", "finish the", "send it to",
|
|
"deliver it to"
|
|
]
|
|
return !explicitTaskMarkers.contains { normalized.contains($0) }
|
|
}
|
|
|
|
private func hasExplicitBlessingMarker(in text: String) -> Bool {
|
|
let normalized = text.lowercased()
|
|
let quotedOrMetaContexts = [
|
|
"祝福模板", "祝福语模板", "文章引用", "搜索词", "系统正在检查",
|
|
"文档里收录", "贺卡名单", "收集祝福", "greeting template",
|
|
"message template", "the article quotes", "search phrase",
|
|
"system is checking", "document contains", "card list",
|
|
"quotes the phrase", "如何描述生日快乐", "怎么说生日快乐",
|
|
"如何写生日祝福", "how would you describe a happy birthday",
|
|
"how do you say happy birthday", "what does happy birthday mean",
|
|
"宁愿你", "祝你倒闭", "祝你立马倒闭", "祝你去死", "祝你倒霉",
|
|
"祝你失败", "祝你完蛋"
|
|
]
|
|
guard !quotedOrMetaContexts.contains(where: { normalized.contains($0) }) else {
|
|
return false
|
|
}
|
|
let markers = [
|
|
"生日快乐", "新年快乐", "春节快乐", "节日快乐", "圣诞快乐",
|
|
"中秋快乐", "恭喜", "预祝", "祝你", "祝您", "祝大家", "祝他", "祝她",
|
|
"愿你", "愿您", "happy birthday", "happy new year",
|
|
"merry christmas", "happy holidays", "congratulations",
|
|
"congrats", "best wishes", "good luck", "wishing you",
|
|
"wish you", "wish him", "wish her", "wish them", "let us wish",
|
|
"let's wish", "we wish", "may you"
|
|
]
|
|
return markers.contains { normalized.contains($0) }
|
|
}
|
|
|
|
private func aggregate(_ metrics: [BinaryMetrics]) -> AggregateMetrics {
|
|
let divisor = Double(max(metrics.count, 1))
|
|
return AggregateMetrics(
|
|
accuracy: rounded(metrics.reduce(0) { $0 + $1.accuracy } / divisor),
|
|
precision: rounded(metrics.reduce(0) { $0 + $1.precision } / divisor),
|
|
recall: rounded(metrics.reduce(0) { $0 + $1.recall } / divisor),
|
|
f1: rounded(metrics.reduce(0) { $0 + $1.f1 } / divisor)
|
|
)
|
|
}
|
|
|
|
private func errorExamples(
|
|
from observations: [BinaryObservation],
|
|
expected: Bool,
|
|
predicted: Bool
|
|
) -> [ErrorExample] {
|
|
observations
|
|
.filter { $0.expected == expected && $0.predicted == predicted }
|
|
.sorted { $0.confidence > $1.confidence }
|
|
.prefix(5)
|
|
.map {
|
|
ErrorExample(
|
|
id: $0.record.id,
|
|
language: $0.record.language,
|
|
family: $0.record.family,
|
|
sourceDataset: $0.record.sourceDataset,
|
|
text: $0.record.text,
|
|
confidence: rounded($0.confidence)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func wilsonLowerBound(successes: Int, total: Int) -> Double {
|
|
guard total > 0 else { return 0 }
|
|
let z = 1.959_963_984_540_054
|
|
let proportion = Double(successes) / Double(total)
|
|
let denominator = 1 + z * z / Double(total)
|
|
let center = proportion + z * z / (2 * Double(total))
|
|
let adjustment = z * sqrt(
|
|
(
|
|
proportion * (1 - proportion)
|
|
+ z * z / (4 * Double(total))
|
|
) / Double(total)
|
|
)
|
|
return rounded((center - adjustment) / denominator)
|
|
}
|
|
|
|
private func verifierMetrics(
|
|
_ observations: [VerifierObservation]
|
|
) -> VerifierRoutingMetrics {
|
|
let expectedSpecialized = observations.filter {
|
|
$0.expectedLabel != "neither"
|
|
}.count
|
|
let stageACandidates = observations.filter(\.isStageACandidate).count
|
|
let stageATruePositives = observations.filter {
|
|
$0.isStageACandidate && $0.expectedLabel != "neither"
|
|
}.count
|
|
let stageBObservations = observations.filter(\.isStageACandidate)
|
|
let stageBCorrect = stageBObservations.filter {
|
|
$0.predictedLabel == $0.expectedLabel
|
|
}.count
|
|
let routed = observations.filter(\.isRouted)
|
|
let correctRouted = routed.filter {
|
|
$0.predictedLabel == $0.expectedLabel
|
|
}.count
|
|
let stageARecall = expectedSpecialized > 0
|
|
? Double(stageATruePositives) / Double(expectedSpecialized)
|
|
: 0
|
|
let stageBAccuracy = stageBObservations.isEmpty
|
|
? 0
|
|
: Double(stageBCorrect) / Double(stageBObservations.count)
|
|
let precision = routed.isEmpty
|
|
? 0
|
|
: Double(correctRouted) / Double(routed.count)
|
|
let recall = expectedSpecialized > 0
|
|
? Double(correctRouted) / Double(expectedSpecialized)
|
|
: 0
|
|
return VerifierRoutingMetrics(
|
|
total: observations.count,
|
|
expectedSpecialized: expectedSpecialized,
|
|
stageACandidates: stageACandidates,
|
|
routed: routed.count,
|
|
correctRouted: correctRouted,
|
|
falseRouted: routed.count - correctRouted,
|
|
stageARecall: rounded(stageARecall),
|
|
stageBExactAccuracy: rounded(stageBAccuracy),
|
|
finalPrecision: rounded(precision),
|
|
finalPrecisionWilsonLower95: wilsonLowerBound(
|
|
successes: correctRouted,
|
|
total: routed.count
|
|
),
|
|
finalRecall: rounded(recall)
|
|
)
|
|
}
|
|
|
|
private func percentile(_ values: [Double], proportion: Double) -> Double {
|
|
guard !values.isEmpty else { return 0 }
|
|
let sorted = values.sorted()
|
|
let index = min(
|
|
Int((Double(sorted.count - 1) * proportion).rounded()),
|
|
sorted.count - 1
|
|
)
|
|
return rounded(sorted[index])
|
|
}
|
|
|
|
private func candidateClassifierIDs(for verifierID: String) -> [String] {
|
|
if verifierID == "action" {
|
|
return ["task", "question", "complaint"]
|
|
}
|
|
return [
|
|
"invitation",
|
|
"scheduleNegotiation",
|
|
"confirmationDecision",
|
|
"followUpReminder"
|
|
]
|
|
}
|
|
|
|
private func isStageACandidate(
|
|
record: HoldoutRecord,
|
|
verifierID: String,
|
|
models: [String: NLModel],
|
|
configurations: [String: ManifestClassifier]
|
|
) -> Bool {
|
|
candidateClassifierIDs(for: verifierID).contains { classifierID in
|
|
guard let model = models[classifierID],
|
|
let configuration = configurations[classifierID],
|
|
let positiveLabel = configuration.positiveLabel else {
|
|
return false
|
|
}
|
|
let confidence = model.predictedLabelHypotheses(
|
|
for: record.text,
|
|
maximumCount: 2
|
|
)[positiveLabel] ?? 0
|
|
let configuredThreshold = configuration
|
|
.confidenceThresholdsByLanguage?[record.language]
|
|
?? configuration.confidenceThreshold
|
|
?? 1
|
|
return confidence >= min(configuredThreshold, 0.50)
|
|
}
|
|
}
|
|
|
|
private func sentimentMetrics(
|
|
records: [HoldoutRecord],
|
|
model: NLModel
|
|
) -> SentimentMetrics {
|
|
let labels = ["negative", "neutral", "positive"]
|
|
var confusion: [String: [String: Int]] = [:]
|
|
var correct = 0
|
|
var unknown = 0
|
|
|
|
for record in records {
|
|
let ranked = model.predictedLabelHypotheses(for: record.text, maximumCount: 3)
|
|
.sorted { $0.value > $1.value }
|
|
let winner = ranked.first
|
|
let runnerUp = ranked.dropFirst().first?.value ?? 0
|
|
let prediction: String
|
|
if let winner,
|
|
winner.value >= sentimentMinimumConfidence,
|
|
winner.value - runnerUp >= sentimentMinimumMargin {
|
|
prediction = winner.key
|
|
} else {
|
|
prediction = "unknown"
|
|
unknown += 1
|
|
}
|
|
confusion[record.sentiment, default: [:]][prediction, default: 0] += 1
|
|
if prediction == record.sentiment {
|
|
correct += 1
|
|
}
|
|
}
|
|
|
|
var perLabelF1: [String: Double] = [:]
|
|
for label in labels {
|
|
let truePositive = confusion[label]?[label] ?? 0
|
|
let falseNegative = (confusion[label] ?? [:])
|
|
.filter { $0.key != label }
|
|
.reduce(0) { $0 + $1.value }
|
|
let falsePositive = labels
|
|
.filter { $0 != label }
|
|
.reduce(0) { $0 + (confusion[$1]?[label] ?? 0) }
|
|
let precision = truePositive + falsePositive > 0
|
|
? Double(truePositive) / Double(truePositive + falsePositive)
|
|
: 0
|
|
let recall = truePositive + falseNegative > 0
|
|
? Double(truePositive) / Double(truePositive + falseNegative)
|
|
: 0
|
|
perLabelF1[label] = rounded(
|
|
precision + recall > 0
|
|
? 2 * precision * recall / (precision + recall)
|
|
: 0
|
|
)
|
|
}
|
|
let macroF1 = perLabelF1.values.reduce(0, +) / Double(labels.count)
|
|
return SentimentMetrics(
|
|
total: records.count,
|
|
correct: correct,
|
|
unknown: unknown,
|
|
accuracy: rounded(Double(correct) / Double(max(records.count, 1))),
|
|
unknownRate: rounded(Double(unknown) / Double(max(records.count, 1))),
|
|
macroF1: rounded(macroF1),
|
|
perLabelF1: perLabelF1
|
|
)
|
|
}
|
|
|
|
private func writeJSON<T: Encodable>(_ value: T, to url: URL) throws {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
try encoder.encode(value).write(to: url, options: .atomic)
|
|
}
|
|
|
|
private func main() throws {
|
|
let decodedRecords = try decodeJSONLines(HoldoutRecord.self, from: corpusURL)
|
|
let records = requestedSplit.map { split in
|
|
decodedRecords.filter { $0.split == split }
|
|
} ?? decodedRecords
|
|
let trainingRecords = try decodeJSONLines(TrainingRecord.self, from: trainingCorpusURL)
|
|
let manifest = try JSONDecoder().decode(
|
|
Manifest.self,
|
|
from: Data(contentsOf: manifestURL)
|
|
)
|
|
let trainingTexts = Set(trainingRecords.map { normalized($0.text) })
|
|
let exactOverlapCount = records.filter { trainingTexts.contains(normalized($0.text)) }.count
|
|
|
|
let temporaryDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
|
"osg-random-holdout-\(UUID().uuidString)",
|
|
isDirectory: true
|
|
)
|
|
try fileManager.createDirectory(
|
|
at: temporaryDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
defer { try? fileManager.removeItem(at: temporaryDirectory) }
|
|
|
|
var models: [String: NLModel] = [:]
|
|
let classifierConfigurations = Dictionary(
|
|
uniqueKeysWithValues: manifest.classifiers.map { ($0.id, $0) }
|
|
)
|
|
for configuration in manifest.classifiers {
|
|
let sourceURL = modelDirectory.appendingPathComponent(configuration.modelFile)
|
|
let compiledURL = try compileModel(
|
|
sourceURL: sourceURL,
|
|
outputDirectory: temporaryDirectory
|
|
)
|
|
let model = try NLModel(contentsOf: compiledURL)
|
|
models[configuration.id] = model
|
|
}
|
|
var verifierModels: [String: NLModel] = [:]
|
|
var verifierLoadMilliseconds: [String: Double] = [:]
|
|
for configuration in manifest.verifiers ?? [] {
|
|
let sourceURL = modelDirectory.appendingPathComponent(configuration.modelFile)
|
|
let compiledURL = try compileModel(
|
|
sourceURL: sourceURL,
|
|
outputDirectory: temporaryDirectory
|
|
)
|
|
let startedAt = ContinuousClock.now
|
|
verifierModels[configuration.id] = try NLModel(contentsOf: compiledURL)
|
|
verifierLoadMilliseconds[configuration.id] = milliseconds(
|
|
startedAt.duration(to: .now)
|
|
)
|
|
}
|
|
|
|
var binaryEvaluations: [BinaryEvaluation] = []
|
|
let sentimentResult = models["sentiment"].map {
|
|
sentimentMetrics(records: records, model: $0)
|
|
}
|
|
for configuration in manifest.classifiers {
|
|
if configuration.id == "sentiment" {
|
|
continue
|
|
}
|
|
guard let model = models[configuration.id] else { continue }
|
|
guard let positiveLabel = configuration.positiveLabel else {
|
|
continue
|
|
}
|
|
let observations = records.map { record in
|
|
let confidence = model.predictedLabelHypotheses(
|
|
for: record.text,
|
|
maximumCount: 2
|
|
)[positiveLabel] ?? 0
|
|
let threshold = configuration.confidenceThresholdsByLanguage?[record.language]
|
|
?? configuration.confidenceThreshold
|
|
?? 1
|
|
let complaintConfidence: Double
|
|
if configuration.id == "task", let complaintModel = models["complaint"] {
|
|
complaintConfidence = complaintModel.predictedLabelHypotheses(
|
|
for: record.text,
|
|
maximumCount: 2
|
|
)["complaint"] ?? 0
|
|
} else {
|
|
complaintConfidence = 0
|
|
}
|
|
let hasBlessingMarker = configuration.id == "blessing"
|
|
&& hasExplicitBlessingMarker(in: record.text)
|
|
let effectiveConfidence = hasBlessingMarker ? 1 : confidence
|
|
let isTaskSuppressed = configuration.id == "task"
|
|
&& shouldSuppressTask(
|
|
text: record.text,
|
|
complaintConfidence: complaintConfidence
|
|
)
|
|
let isBlessingSuppressed = configuration.id == "blessing"
|
|
&& !hasBlessingMarker
|
|
let isSuppressed = isTaskSuppressed || isBlessingSuppressed
|
|
return BinaryObservation(
|
|
record: record,
|
|
expected: record.isPositive(for: configuration.id),
|
|
predicted: (configuration.acceptedForAutomaticRouting || includesRejectedModels)
|
|
&& !isSuppressed
|
|
&& effectiveConfidence >= threshold,
|
|
confidence: effectiveConfidence,
|
|
isSuppressed: isSuppressed
|
|
)
|
|
}
|
|
let byLanguage = Dictionary(grouping: observations) { $0.record.language }
|
|
let metricsByLanguage = byLanguage.mapValues(binaryMetrics)
|
|
let metricsBySource = Dictionary(grouping: observations) {
|
|
$0.record.sourceDataset ?? $0.record.family
|
|
}.mapValues(binaryMetrics)
|
|
binaryEvaluations.append(
|
|
BinaryEvaluation(
|
|
id: configuration.id,
|
|
metrics: binaryMetrics(observations),
|
|
metricsByLanguage: metricsByLanguage,
|
|
metricsBySource: metricsBySource,
|
|
thresholdAt90Precision: thresholdRecommendation(
|
|
observations: observations,
|
|
minimumPrecision: 0.90
|
|
),
|
|
thresholdAt95Precision: thresholdRecommendation(
|
|
observations: observations,
|
|
minimumPrecision: 0.95
|
|
),
|
|
thresholdsAt90PrecisionByLanguage: byLanguage.compactMapValues {
|
|
thresholdRecommendation(observations: $0, minimumPrecision: 0.90)
|
|
},
|
|
thresholdsAt95PrecisionByLanguage: byLanguage.compactMapValues {
|
|
thresholdRecommendation(observations: $0, minimumPrecision: 0.95)
|
|
},
|
|
falsePositiveExamples: errorExamples(
|
|
from: observations,
|
|
expected: false,
|
|
predicted: true
|
|
),
|
|
falseNegativeExamples: errorExamples(
|
|
from: observations,
|
|
expected: true,
|
|
predicted: false
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
let verifierEvaluations = (manifest.verifiers ?? []).compactMap { configuration
|
|
-> VerifierEvaluation? in
|
|
guard let model = verifierModels[configuration.id] else { return nil }
|
|
let observations = records.map { record -> VerifierObservation in
|
|
let candidate = isStageACandidate(
|
|
record: record,
|
|
verifierID: configuration.id,
|
|
models: models,
|
|
configurations: classifierConfigurations
|
|
)
|
|
guard candidate else {
|
|
return VerifierObservation(
|
|
record: record,
|
|
expectedLabel: record.expectedVerifierLabel(
|
|
for: configuration.id
|
|
),
|
|
isStageACandidate: false,
|
|
predictedLabel: "neither",
|
|
confidence: 0,
|
|
margin: 0,
|
|
isRouted: false,
|
|
latencyMilliseconds: 0
|
|
)
|
|
}
|
|
let startedAt = ContinuousClock.now
|
|
let ranked = model.predictedLabelHypotheses(
|
|
for: record.text,
|
|
maximumCount: 2
|
|
).sorted { $0.value > $1.value }
|
|
let elapsed = startedAt.duration(to: .now)
|
|
let winner = ranked.first ?? (key: "neither", value: 0)
|
|
let margin = winner.value - (ranked.dropFirst().first?.value ?? 0)
|
|
let threshold = configuration
|
|
.confidenceThresholdsByLanguage?[record.language]
|
|
?? configuration.confidenceThreshold
|
|
let minimumMargin = configuration
|
|
.minimumMarginsByLanguage?[record.language]
|
|
?? configuration.minimumMargin
|
|
return VerifierObservation(
|
|
record: record,
|
|
expectedLabel: record.expectedVerifierLabel(
|
|
for: configuration.id
|
|
),
|
|
isStageACandidate: true,
|
|
predictedLabel: winner.key,
|
|
confidence: winner.value,
|
|
margin: margin,
|
|
isRouted: winner.key != "neither"
|
|
&& winner.value >= threshold
|
|
&& margin >= minimumMargin,
|
|
latencyMilliseconds: milliseconds(
|
|
elapsed
|
|
)
|
|
)
|
|
}
|
|
let byLanguage = Dictionary(grouping: observations) {
|
|
$0.record.language
|
|
}.mapValues(verifierMetrics)
|
|
let bySourceGroups = Dictionary(grouping: observations) {
|
|
$0.record.sourceDataset ?? $0.record.family
|
|
}
|
|
let bySource = bySourceGroups.mapValues(verifierMetrics)
|
|
let leaveOneSourceOut = Dictionary(uniqueKeysWithValues: bySourceGroups.keys.map { source
|
|
in
|
|
(
|
|
source,
|
|
verifierMetrics(
|
|
observations.filter {
|
|
($0.record.sourceDataset ?? $0.record.family) != source
|
|
}
|
|
)
|
|
)
|
|
})
|
|
let latencies = observations.filter(\.isStageACandidate)
|
|
.map(\.latencyMilliseconds)
|
|
return VerifierEvaluation(
|
|
id: configuration.id,
|
|
deploymentMode: configuration.deploymentMode,
|
|
acceptedForAutomaticRouting:
|
|
configuration.acceptedForAutomaticRouting,
|
|
metrics: verifierMetrics(observations),
|
|
metricsByLanguage: byLanguage,
|
|
metricsBySource: bySource,
|
|
leaveOneSourceOut: leaveOneSourceOut,
|
|
coldLoadMilliseconds: rounded(
|
|
verifierLoadMilliseconds[configuration.id] ?? 0
|
|
),
|
|
warmMedianMilliseconds: percentile(latencies, proportion: 0.50),
|
|
warmP95Milliseconds: percentile(latencies, proportion: 0.95)
|
|
)
|
|
}
|
|
|
|
guard let sentimentResult else {
|
|
throw NSError(
|
|
domain: "RandomHoldoutEvaluation",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Sentiment model was not evaluated"]
|
|
)
|
|
}
|
|
let languages = Set(records.map(\.language)).sorted()
|
|
let macroByLanguage = Dictionary(uniqueKeysWithValues: languages.map { language in
|
|
let metrics = binaryEvaluations.compactMap {
|
|
$0.metricsByLanguage[language]
|
|
}
|
|
return (language, aggregate(metrics))
|
|
})
|
|
let sentimentModel = models["sentiment"]!
|
|
let sentimentBySource = Dictionary(grouping: records) {
|
|
$0.sourceDataset ?? $0.family
|
|
}.mapValues {
|
|
sentimentMetrics(records: $0, model: sentimentModel)
|
|
}
|
|
let report = Report(
|
|
generatedAt: ISO8601DateFormatter().string(from: Date()),
|
|
seed: holdoutSeed,
|
|
corpusRecordCount: records.count,
|
|
familyCount: Set(records.map(\.family)).count,
|
|
languageCounts: Dictionary(grouping: records, by: \.language).mapValues(\.count),
|
|
exactTrainingOverlapCount: exactOverlapCount,
|
|
manifestSchemaVersion: manifest.schemaVersion,
|
|
binaryMacro: aggregate(binaryEvaluations.map(\.metrics)),
|
|
binaryMacroByLanguage: macroByLanguage,
|
|
classifiers: binaryEvaluations,
|
|
verifierLayers: verifierEvaluations,
|
|
sentiment: sentimentResult,
|
|
sentimentBySource: sentimentBySource
|
|
)
|
|
try writeJSON(report, to: reportURL)
|
|
print(
|
|
"RANDOM_HOLDOUT_EVAL_DONE records=\(records.count) "
|
|
+ "overlap=\(exactOverlapCount) "
|
|
+ "macroPrecision=\(report.binaryMacro.precision) "
|
|
+ "macroRecall=\(report.binaryMacro.recall) "
|
|
+ "macroF1=\(report.binaryMacro.f1) "
|
|
+ "verifiers=\(report.verifierLayers.count) "
|
|
+ "sentimentMacroF1=\(report.sentiment.macroF1)"
|
|
)
|
|
}
|
|
|
|
do {
|
|
try main()
|
|
} catch {
|
|
fputs("RANDOM_HOLDOUT_EVAL_FAILED \(error)\n", stderr)
|
|
exit(1)
|
|
}
|