From 4e15c5e67c9a91189308d0cefb64007656287ae5 Mon Sep 17 00:00:00 2001 From: sukru tikves Date: Thu, 1 Oct 2026 10:50:11 -0700 Subject: [PATCH 1/2] Consolidate scalar sigmoid onto a shared overflow-safe helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetectionDecoder and SegmentationPostprocessor each defined their own scalar `sigmoid(_:)`. The video decoder's branches on the sign so the exponent argument stays non-positive (overflow-safe); the segmenter's naive `1/(1+exp(-x))` underflows large-negative logits to exactly 0. Add one shared `sigmoid` in CoreAIShared/Runtime/VectorMath.swift (the sign-branched form) and route both subsystems and their tests through it, deleting the two duplicates. VectorMathTests characterizes both extremes. Intended behavior change: the segmenter path now returns a tiny positive value instead of exactly 0 for large-negative logits — negligible for any threshold > 0. WS4 PR2. --- .../SegmentationPostprocessor.swift | 6 +-- .../CoreAIShared/Runtime/VectorMath.swift | 23 ++++++++++++ .../Tracking/DetectionDecoder.swift | 11 ------ .../Tracking/FrameProcessor.swift | 2 +- .../CoreAISharedTests/VectorMathTests.swift | 37 +++++++++++++++++++ .../ImageSegmentationTests.swift | 7 ++-- .../HotstartAndMemoryTests.swift | 4 +- .../TrackingPrimitiveTests.swift | 18 ++++----- 8 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 swift/Sources/CoreAIShared/Runtime/VectorMath.swift create mode 100644 swift/Tests/CoreAISharedTests/VectorMathTests.swift diff --git a/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift b/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift index 7e2ab99a..d86dae73 100644 --- a/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift +++ b/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift @@ -3,6 +3,7 @@ // Use of this source code is governed by a BSD-3-clause license that can // be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause +import CoreAIShared import CoreGraphics import Foundation @@ -227,11 +228,6 @@ public enum SegmentationPostprocessor { probabilities: probabilities, width: outputWidth, height: outputHeight) } - // MARK: - Helpers - public static func sigmoid(_ x: Float) -> Float { - 1.0 / (1.0 + exp(-x)) - } - /// Bilinear-upsample a Float grid and threshold at the destination resolution. /// /// Pre-image sampling (PIL `BILINEAR` / align-corners=False semantics): the diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift new file mode 100644 index 00000000..8fbe1323 --- /dev/null +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -0,0 +1,23 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation + +/// Numerically stable logistic sigmoid. +/// +/// Branches on the sign so the exponent argument stays non-positive. The naive +/// `1 / (1 + exp(-x))` is fine for `x >= 0`, but on a large *negative* logit `exp(-x)` +/// overflows to `+inf` and the result underflows to exactly `0`; the equivalent +/// `exp(x) / (1 + exp(x))` form stays finite there and returns a tiny positive value. +/// (The opposite naive arrangement would produce `inf / inf = NaN` on large *positive* +/// logits — this one avoids that too.) Detector/segmenter logits are unbounded, so the +/// branch matters. +public func sigmoid(_ x: Float) -> Float { + if x >= 0 { + return 1 / (1 + Foundation.exp(-x)) + } + let e = Foundation.exp(x) + return e / (1 + e) +} diff --git a/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift b/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift index d7b1d011..a01812a0 100644 --- a/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift +++ b/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift @@ -109,15 +109,4 @@ enum DetectionDecoder { // prompt's own ordering. return kept.sorted() } - - @inline(__always) - static func sigmoid(_ x: Float) -> Float { - // Branch on the sign to keep the exponent argument negative. `exp` of a large - // positive logit overflows to infinity and yields NaN. - if x >= 0 { - return 1 / (1 + Foundation.exp(-x)) - } - let e = Foundation.exp(x) - return e / (1 + e) - } } diff --git a/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift b/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift index 72cf5a3f..085095dd 100644 --- a/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift +++ b/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift @@ -416,7 +416,7 @@ final class FrameProcessor { for (objectID, score) in newScores { trackerScores[objectID] = score } for (objectID, logit) in zip(trackedObjectIDs, trackerScoreLogits) where !removed.contains(objectID) { - trackerScores[objectID] = DetectionDecoder.sigmoid(logit) + trackerScores[objectID] = sigmoid(logit) } session.trackerScoreByFrame[frameIndex] = trackerScores diff --git a/swift/Tests/CoreAISharedTests/VectorMathTests.swift b/swift/Tests/CoreAISharedTests/VectorMathTests.swift new file mode 100644 index 00000000..eebcbfae --- /dev/null +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -0,0 +1,37 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation +import Testing + +@testable import CoreAIShared + +// MARK: - sigmoid + +/// The shared logistic sigmoid is the single home for the two segmenter copies that previously +/// lived in `DetectionDecoder` and `SegmentationPostprocessor`. Detector/segmenter logits are +/// unbounded, so these cases pin the overflow-safe behavior at both extremes. +@Suite("VectorMath") +struct VectorMathTests { + @Test("sigmoid(0) == 0.5 and is symmetric") + func knownValues() { + #expect(sigmoid(0) == 0.5) + let x: Float = 2.5 + #expect(abs(sigmoid(x) + sigmoid(-x) - 1.0) < 1e-6) + #expect(abs(sigmoid(2) - 0.880797) < 1e-5) + } + + @Test("sigmoid stays finite at both extremes") + func overflowSafe() { + // Large positive: naive `exp(x)/(1+exp(x))` would be inf/inf = NaN; this returns 1. + #expect(sigmoid(100) == 1) + #expect(sigmoid(100).isNaN == false) + // Large negative: a tiny positive value, never NaN. The naive `1/(1+exp(-x))` underflows + // to exactly 0 here; the sign-branched form keeps the denormal. + #expect(sigmoid(-100) > 0) + #expect(sigmoid(-100) < 1e-40) + #expect(sigmoid(-100).isNaN == false) + } +} diff --git a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift index dc473d1b..f4a12e26 100644 --- a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift +++ b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift @@ -3,6 +3,7 @@ // Use of this source code is governed by a BSD-3-clause license that can // be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause +import CoreAIShared import CoreGraphics import Foundation import Testing @@ -95,7 +96,7 @@ struct ImageSegmentationTests { @Test("Decode: absent presence logits treated as 1.0") func decodeEmptyPresenceLogits() { let logit: Float = 2.0 - let expectedScore = SegmentationPostprocessor.sigmoid(logit) + let expectedScore = sigmoid(logit) let output = SegmentationOutput( predictedMasks: [Float](repeating: 0, count: 1 * 1 * 2 * 2), @@ -376,9 +377,9 @@ struct ImageSegmentationTests { @Test("sigmoid(0) == 0.5 and sigmoid(x) + sigmoid(-x) == 1") func sigmoidKnownValues() { - #expect(SegmentationPostprocessor.sigmoid(0) == 0.5) + #expect(sigmoid(0) == 0.5) let x: Float = 2.5 - #expect(abs(SegmentationPostprocessor.sigmoid(x) + SegmentationPostprocessor.sigmoid(-x) - 1.0) < 1e-6) + #expect(abs(sigmoid(x) + sigmoid(-x) - 1.0) < 1e-6) } // MARK: - Helpers diff --git a/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift b/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift index aa8fba6a..9ce5e04f 100644 --- a/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift +++ b/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift @@ -588,9 +588,9 @@ struct BuildOutputsPairingTests { trackerLogits: maskLogits(for: tracked), trackerScoreLogits: scoreLogits(for: tracked), plan: plan, newScores: [:]) - #expect(output.trackerScoreByObjectID[1] == DetectionDecoder.sigmoid(1)) + #expect(output.trackerScoreByObjectID[1] == sigmoid(1)) #expect( - output.trackerScoreByObjectID[3] == DetectionDecoder.sigmoid(3), + output.trackerScoreByObjectID[3] == sigmoid(3), "object 3 must not inherit object 2's score") } diff --git a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift index 9274bbf5..664ab167 100644 --- a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift +++ b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift @@ -152,13 +152,13 @@ struct DetectionDecoderTests { func sigmoidStability() { // `1 / (1 + exp(-x))` is fine for large x but `exp(x) / (1 + exp(x))` overflows to // inf/inf = NaN. The detector's logits are unbounded, so the branch matters. - #expect(DetectionDecoder.sigmoid(0) == 0.5) - #expect(DetectionDecoder.sigmoid(100) == 1) + #expect(sigmoid(0) == 0.5) + #expect(sigmoid(100) == 1) // Not exactly zero: the negative branch keeps the denormal rather than underflowing. - #expect(DetectionDecoder.sigmoid(-100) < 1e-40) - #expect(DetectionDecoder.sigmoid(-100) >= 0) - #expect(DetectionDecoder.sigmoid(-100).isNaN == false) - #expect(abs(DetectionDecoder.sigmoid(2) - 0.880797) < 1e-5) + #expect(sigmoid(-100) < 1e-40) + #expect(sigmoid(-100) >= 0) + #expect(sigmoid(-100).isNaN == false) + #expect(abs(sigmoid(2) - 0.880797) < 1e-5) } @Test("NMS keeps the highest-scoring member of an overlapping group") @@ -235,7 +235,7 @@ struct DetectionDecoderTests { outputs(queries: [2], presence: 1, masks: [[1, 1, -1, -1]]), promptID: 0, maskSize: 2, parameters: parameters(threshold: 0.1, nms: 0)) #expect(decoded.count == 1) - let expected = DetectionDecoder.sigmoid(2) * DetectionDecoder.sigmoid(1) + let expected = sigmoid(2) * sigmoid(1) #expect(abs(decoded.scores[0] - expected) < 1e-6) #expect(abs(expected - 0.643914) < 1e-5) } @@ -289,8 +289,8 @@ struct DetectionDecoderTests { #expect(decoded.maskLogits[1] == [-1, -1, -1, 1]) #expect(decoded.masks[1].area == 1) let expected = [ - DetectionDecoder.sigmoid(3) * DetectionDecoder.sigmoid(8), - DetectionDecoder.sigmoid(1) * DetectionDecoder.sigmoid(8), + sigmoid(3) * sigmoid(8), + sigmoid(1) * sigmoid(8), ] #expect(zip(decoded.scores, expected).allSatisfy { abs($0 - $1) < 1e-6 }) } From 95dcfa686f44329ef0110778e6bd2eb57517a28e Mon Sep 17 00:00:00 2001 From: sukru tikves Date: Fri, 2 Oct 2026 11:50:39 -0700 Subject: [PATCH 2/2] Address review: condense sigmoid doc, inline, drop duplicate tests - Condense the `sigmoid` doc comment and mark it `@inline(__always)` (it runs per-pixel on the detector/segmenter paths). - Delete the `sigmoidKnownValues` (image-segmenter) and `sigmoidStability` (video-segmenter) tests: `VectorMathTests` already covers both the known values and the overflow-safe extremes. --- swift/Sources/CoreAIShared/Runtime/VectorMath.swift | 10 +++------- .../ImageSegmentationTests.swift | 9 --------- .../TrackingPrimitiveTests.swift | 13 ------------- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift index 8fbe1323..18b40110 100644 --- a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -7,13 +7,9 @@ import Foundation /// Numerically stable logistic sigmoid. /// -/// Branches on the sign so the exponent argument stays non-positive. The naive -/// `1 / (1 + exp(-x))` is fine for `x >= 0`, but on a large *negative* logit `exp(-x)` -/// overflows to `+inf` and the result underflows to exactly `0`; the equivalent -/// `exp(x) / (1 + exp(x))` form stays finite there and returns a tiny positive value. -/// (The opposite naive arrangement would produce `inf / inf = NaN` on large *positive* -/// logits — this one avoids that too.) Detector/segmenter logits are unbounded, so the -/// branch matters. +/// Branches on the sign to keep the exponent argument non-positive, so unbounded +/// detector/segmenter logits stay finite at both extremes (no `NaN`, no underflow to `0`). +@inline(__always) public func sigmoid(_ x: Float) -> Float { if x >= 0 { return 1 / (1 + Foundation.exp(-x)) diff --git a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift index f4a12e26..e7dd85d5 100644 --- a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift +++ b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift @@ -373,15 +373,6 @@ struct ImageSegmentationTests { #expect(overlay.height == h) } - // MARK: - Sigmoid - - @Test("sigmoid(0) == 0.5 and sigmoid(x) + sigmoid(-x) == 1") - func sigmoidKnownValues() { - #expect(sigmoid(0) == 0.5) - let x: Float = 2.5 - #expect(abs(sigmoid(x) + sigmoid(-x) - 1.0) < 1e-6) - } - // MARK: - Helpers private func makeSolidCGImage(width: Int, height: Int, r: UInt8, g: UInt8, b: UInt8) -> CGImage? { diff --git a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift index 664ab167..aeb81b5a 100644 --- a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift +++ b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift @@ -148,19 +148,6 @@ struct DetectionDecoderTests { return bitset } - @Test("sigmoid does not overflow on a large positive logit") - func sigmoidStability() { - // `1 / (1 + exp(-x))` is fine for large x but `exp(x) / (1 + exp(x))` overflows to - // inf/inf = NaN. The detector's logits are unbounded, so the branch matters. - #expect(sigmoid(0) == 0.5) - #expect(sigmoid(100) == 1) - // Not exactly zero: the negative branch keeps the denormal rather than underflowing. - #expect(sigmoid(-100) < 1e-40) - #expect(sigmoid(-100) >= 0) - #expect(sigmoid(-100).isNaN == false) - #expect(abs(sigmoid(2) - 0.880797) < 1e-5) - } - @Test("NMS keeps the highest-scoring member of an overlapping group") func nmsSuppresses() { let masks = [mask(["####"]), mask(["###."]), mask(["...#"])]