diff --git a/src/libs/math/include/math/MedoidCalculator.hpp b/src/libs/math/include/math/MedoidCalculator.hpp index f2893ff4..e6303a2b 100644 --- a/src/libs/math/include/math/MedoidCalculator.hpp +++ b/src/libs/math/include/math/MedoidCalculator.hpp @@ -38,30 +38,49 @@ namespace lms::math using value_type = typename VectorType::value_type; using size_type = std::size_t; - // Add a single vector, returning its index - size_type add(const VectorType& value) + // provided value must be accessible until finalize() is called + void add(const VectorType& value) { - _vectors.push_back(value); - return _vectors.size() - 1; + _pointers.push_back(&value); } - // Compute the medoid: the vector with minimum sum of squared distances to all others - // Returns the index of the medoid in the added vectors - size_type findMedoidIndex() const + // Returns a pointer to the medoid among the added vectors. + // The pointer remains valid as long as the original vectors are alive. + const VectorType* finalize() const { assert(!empty()); + return _pointers[findMedoidIndex()]; + } + bool empty() const + { + return _pointers.empty(); + } + + size_type count() const + { + return _pointers.size(); + } + + void clear() + { + _pointers.clear(); + } + + private: + size_type findMedoidIndex() const + { size_type medoidIndex{}; value_type minTotalDistance{ std::numeric_limits::max() }; - for (size_type i{}; i < _vectors.size(); ++i) + for (size_type i{}; i < _pointers.size(); ++i) { value_type totalDistance{}; - const SquaredEuclideanDistance distFunc{ _vectors[i] }; - for (size_type j{}; j < _vectors.size(); ++j) + const SquaredEuclideanDistance distFunc{ *_pointers[i] }; + for (size_type j{}; j < _pointers.size(); ++j) { if (i != j) - totalDistance += distFunc(_vectors[j]); + totalDistance += distFunc(*_pointers[j]); } if (totalDistance < minTotalDistance) @@ -74,37 +93,7 @@ namespace lms::math return medoidIndex; } - // Compute the medoid vector itself - VectorType finalize() const - { - return _vectors[findMedoidIndex()]; - } - - // Get a specific vector by index - const VectorType& getVector(size_type index) const - { - assert(index < _vectors.size()); - return _vectors[index]; - } - - // Query methods - bool empty() const - { - return _vectors.empty(); - } - - size_type count() const - { - return _vectors.size(); - } - - void clear() - { - _vectors.clear(); - } - - private: - std::vector _vectors; + std::vector _pointers; }; template @@ -113,15 +102,6 @@ namespace lms::math MedoidCalculator calculator; for (const auto& value : values) calculator.add(value); - return calculator.finalize(); - } - - template - VectorType computeNormalizedMedoid(std::span values) - { - MedoidCalculator calculator; - for (const auto& value : values) - calculator.add(value); - return calculator.finalizeNormalized(); + return *calculator.finalize(); } } // namespace lms::math diff --git a/src/libs/math/test/MedoidCalculator.cpp b/src/libs/math/test/MedoidCalculator.cpp index b1082979..c2bc68df 100644 --- a/src/libs/math/test/MedoidCalculator.cpp +++ b/src/libs/math/test/MedoidCalculator.cpp @@ -40,12 +40,7 @@ namespace lms::math::medoidCalculatorTests EXPECT_FALSE(calculator.empty()); EXPECT_EQ(calculator.count(), 1U); - EXPECT_EQ(calculator.findMedoidIndex(), 0U); - - const Vector<3, float> result = calculator.finalize(); - EXPECT_EQ(result[0], 1.0F); - EXPECT_EQ(result[1], 2.0F); - EXPECT_EQ(result[2], 3.0F); + EXPECT_EQ(calculator.finalize(), &vec); } TEST(MedoidCalculator, twoVectors) @@ -58,9 +53,9 @@ namespace lms::math::medoidCalculatorTests calculator.add(v2); EXPECT_EQ(calculator.count(), 2U); - // Both have equal distance to the other, but first one is returned - const std::size_t medoidIndex = calculator.findMedoidIndex(); - EXPECT_TRUE(medoidIndex == 0 || medoidIndex == 1); + // Both have equal distance to the other; result must be one of the two + const Vector<2, float>* medoid = calculator.finalize(); + EXPECT_TRUE(medoid == &v1 || medoid == &v2); } TEST(MedoidCalculator, threeDifferentVectors) @@ -68,16 +63,14 @@ namespace lms::math::medoidCalculatorTests MedoidCalculator> calculator; // Three points: (0,0), (1,0), (10,0) // Medoid should be (1,0) as it's closest to the others - calculator.add(Vector<2, float>{ 0.0F, 0.0F }); - calculator.add(Vector<2, float>{ 1.0F, 0.0F }); - calculator.add(Vector<2, float>{ 10.0F, 0.0F }); + const Vector<2, float> v0{ 0.0F, 0.0F }; + const Vector<2, float> v1{ 1.0F, 0.0F }; + const Vector<2, float> v2{ 10.0F, 0.0F }; + calculator.add(v0); + calculator.add(v1); + calculator.add(v2); - const std::size_t medoidIndex = calculator.findMedoidIndex(); - EXPECT_EQ(medoidIndex, 1U); // The middle point (1,0) is the medoid - - const Vector<2, float> result = calculator.finalize(); - EXPECT_FLOAT_EQ(result[0], 1.0F); - EXPECT_FLOAT_EQ(result[1], 0.0F); + EXPECT_EQ(calculator.finalize(), &v1); } TEST(MedoidCalculator, computeMedoidSpan) @@ -94,25 +87,11 @@ namespace lms::math::medoidCalculatorTests EXPECT_FLOAT_EQ(result[1], 0.0F); } - TEST(MedoidCalculator, getVector) - { - MedoidCalculator> calculator; - calculator.add(Vector<2, float>{ 1.0F, 2.0F }); - calculator.add(Vector<2, float>{ 3.0F, 4.0F }); - - const Vector<2, float>& v0 = calculator.getVector(0U); - const Vector<2, float>& v1 = calculator.getVector(1U); - - EXPECT_FLOAT_EQ(v0[0], 1.0F); - EXPECT_FLOAT_EQ(v0[1], 2.0F); - EXPECT_FLOAT_EQ(v1[0], 3.0F); - EXPECT_FLOAT_EQ(v1[1], 4.0F); - } - TEST(MedoidCalculator, clear) { MedoidCalculator> calculator; - calculator.add(Vector<2, float>{ 1.0F, 2.0F }); + const Vector<2, float> v{ 1.0F, 2.0F }; + calculator.add(v); EXPECT_EQ(calculator.count(), 1); calculator.clear(); EXPECT_EQ(calculator.count(), 0); diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp index 228225af..2462c4d3 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp @@ -86,7 +86,9 @@ namespace lms::recommendation std::vector _vectors; std::unordered_map _trackVectors; std::unordered_map>> _releaseVectors; + std::unordered_map _releaseMedoids; std::unordered_map>> _artistVectors; + std::unordered_map _artistMedoids; TrackMetadataMap _trackMetadata; FloatType _trackDistanceThreshold{}; diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp index 0665d632..9939c201 100644 --- a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -182,7 +182,7 @@ namespace lms::recommendation if (medoidCalculator.empty()) return res; - const ReducedVector queryVector{ medoidCalculator.finalize() }; + const ReducedVector& queryVector{ *medoidCalculator.finalize() }; const math::NormalizedCosineDistance distFunc{ queryVector }; using Distance = float; @@ -356,9 +356,7 @@ namespace lms::recommendation } template - ReleaseResults AudioSimilarityEngine::findSimilarReleases( - db::ReleaseId releaseId, - std::size_t maxCount) const + ReleaseResults AudioSimilarityEngine::findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const { LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar releases"); @@ -373,26 +371,37 @@ namespace lms::recommendation const auto& queryReleaseFeatures{ itQueryRelease->second }; using Distance = float; - std::vector> rankedReleases; - rankedReleases.reserve(_releaseVectors.size()); - using CosineDistance = math::NormalizedCosineDistance; - for (const auto& [candidateId, candidateReleaseVectors] : _releaseVectors) - { - if (candidateId == releaseId || candidateReleaseVectors.empty()) - continue; + // Stage 1: fast medoid scan to get top candidates + constexpr std::size_t preFilterMultiplier{ 10 }; + constexpr std::size_t preFilterMinCount{ 50 }; + const std::size_t preFilterCount{ std::min(_releaseMedoids.size() - 1, std::max(preFilterMinCount, maxCount * preFilterMultiplier)) }; - const FloatType distance{ math::symmetricalChamferDistance( - queryReleaseFeatures, - candidateReleaseVectors) }; + const math::NormalizedCosineDistance queryMedoidDist{ *_releaseMedoids.at(releaseId) }; + std::vector> medoidCandidates; + medoidCandidates.reserve(_releaseMedoids.size()); + for (const auto& [candidateId, medoid] : _releaseMedoids) + { + if (candidateId != releaseId) + medoidCandidates.emplace_back(candidateId, queryMedoidDist(*medoid)); + } + std::nth_element(medoidCandidates.begin(), std::next(medoidCandidates.begin(), preFilterCount), medoidCandidates.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); + medoidCandidates.resize(preFilterCount); + + // Stage 2: Chamfer re-rank on top candidates + std::vector> rankedReleases; + rankedReleases.reserve(preFilterCount); + for (const auto& [candidateId, _] : medoidCandidates) + { + const FloatType distance{ math::symmetricalChamferDistance(queryReleaseFeatures, _releaseVectors.at(candidateId)) }; if (distance <= _releaseDistanceThreshold) rankedReleases.emplace_back(candidateId, distance); } const std::size_t resultCount{ std::min(maxCount, rankedReleases.size()) }; - std::partial_sort(std::begin(rankedReleases), std::next(std::begin(rankedReleases), resultCount), std::end(rankedReleases), [](const auto& lhs, const auto& rhs) { + std::partial_sort(std::begin(rankedReleases), std::next(std::begin(rankedReleases), static_cast(resultCount)), std::end(rankedReleases), [](const auto& lhs, const auto& rhs) { return lhs.second < rhs.second; }); @@ -422,19 +431,30 @@ namespace lms::recommendation const auto& queryArtistFeatures{ itQueryArtist->second }; using Distance = float; - std::vector> rankedArtists; - rankedArtists.reserve(_artistVectors.size()); - using CosineDistance = math::NormalizedCosineDistance; - for (const auto& [candidateId, candidateArtistFeatures] : _artistVectors) - { - if (candidateId == artistId || candidateArtistFeatures.empty()) - continue; + // Stage 1: fast medoid scan to get top-K candidates + constexpr std::size_t preFilterMultiplier{ 10 }; + constexpr std::size_t preFilterMinCount{ 50 }; + const std::size_t preFilterCount{ std::min(_artistMedoids.size() - 1, std::max(preFilterMinCount, maxCount * preFilterMultiplier)) }; - const FloatType distance{ math::symmetricalChamferDistance( - queryArtistFeatures, - candidateArtistFeatures) }; + const math::NormalizedCosineDistance queryMedoidDist{ *_artistMedoids.at(artistId) }; + std::vector> medoidCandidates; + medoidCandidates.reserve(_artistMedoids.size()); + for (const auto& [candidateId, medoid] : _artistMedoids) + { + if (candidateId != artistId) + medoidCandidates.emplace_back(candidateId, queryMedoidDist(*medoid)); + } + std::nth_element(medoidCandidates.begin(), std::next(medoidCandidates.begin(), preFilterCount), medoidCandidates.end(), [](const auto& a, const auto& b) { return a.second < b.second; }); + medoidCandidates.resize(preFilterCount); + + // Stage 2: Chamfer re-rank on top-K candidates + std::vector> rankedArtists; + rankedArtists.reserve(preFilterCount); + for (const auto& [candidateId, _] : medoidCandidates) + { + const FloatType distance{ math::symmetricalChamferDistance(queryArtistFeatures, _artistVectors.at(candidateId)) }; if (distance <= _artistDistanceThreshold) rankedArtists.emplace_back(candidateId, distance); @@ -578,7 +598,9 @@ namespace lms::recommendation _vectors.clear(); _vectors.reserve(_trackCount); // must keep pointers valid _releaseVectors.clear(); + _releaseMedoids.clear(); _artistVectors.clear(); + _artistMedoids.clear(); _trackMetadata.clear(); Provider::visitVectors(session, [&](db::TrackId trackId, const SourceVector& sourceVector) { @@ -652,6 +674,23 @@ namespace lms::recommendation _artistVectors.try_emplace(artist->getId(), std::move(artistTrackVectors)); }); + math::MedoidCalculator calc; + for (const auto& [id, vecs] : _releaseVectors) + { + calc.clear(); + for (const auto& v : vecs) + calc.add(v.get()); + _releaseMedoids.try_emplace(id, calc.finalize()); + } + + for (const auto& [id, vecs] : _artistVectors) + { + calc.clear(); + for (const auto& v : vecs) + calc.add(v.get()); + _artistMedoids.try_emplace(id, calc.finalize()); + } + // Sort artistIds in each TrackMetadata entry for set-intersection in SameArtistConstraint for (auto& [trackId, metadata] : _trackMetadata) std::sort(metadata.artistIds.begin(), metadata.artistIds.end()); @@ -714,6 +753,7 @@ namespace lms::recommendation LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold"); constexpr std::size_t maxSampleCount{ 200 }; + constexpr std::size_t maxCandidateCount{ 1'000 }; constexpr float stdDevMultiplier{ 2.F }; using CosineDistance = math::NormalizedCosineDistance; @@ -722,11 +762,18 @@ namespace lms::recommendation for (const auto& [id, vecs] : _releaseVectors) allProfiles.push_back(&vecs); - const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) }; - LOG(INFO, "computing release distance threshold using " << sampleCount << " samples..."); - + // move maxCandidateCount random elements to the front + const std::size_t candidateCount{ std::min(allProfiles.size(), maxCandidateCount) }; std::minstd_rand randomEngine{ 42 }; - core::random::shuffleContainer(randomEngine, allProfiles); + for (std::size_t i{}; i < candidateCount; ++i) + { + std::uniform_int_distribution dist{ i, allProfiles.size() - 1 }; + std::swap(allProfiles[i], allProfiles[dist(randomEngine)]); + } + allProfiles.resize(candidateCount); + + const std::size_t sampleCount{ std::min(candidateCount, maxSampleCount) }; + LOG(INFO, "computing release distance threshold using " << sampleCount << " samples on " << candidateCount << " candidates..."); math::StatsAccumulator stats; for (std::size_t i{}; i < sampleCount; ++i) @@ -759,6 +806,7 @@ namespace lms::recommendation LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold"); constexpr std::size_t maxSampleCount{ 200 }; + constexpr std::size_t maxCandidateCount{ 1'000 }; constexpr float stdDevMultiplier{ 2.F }; using CosineDistance = math::NormalizedCosineDistance; @@ -767,11 +815,18 @@ namespace lms::recommendation for (const auto& [id, vecs] : _artistVectors) allProfiles.push_back(&vecs); - const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) }; - LOG(INFO, "computing artist distance threshold using " << sampleCount << " samples..."); - + // move maxCandidateCount random elements to the front + const std::size_t candidateCount{ std::min(allProfiles.size(), maxCandidateCount) }; std::minstd_rand randomEngine{ 42 }; - core::random::shuffleContainer(randomEngine, allProfiles); + for (std::size_t i{}; i < candidateCount; ++i) + { + std::uniform_int_distribution dist{ i, allProfiles.size() - 1 }; + std::swap(allProfiles[i], allProfiles[dist(randomEngine)]); + } + allProfiles.resize(candidateCount); + + const std::size_t sampleCount{ std::min(candidateCount, maxSampleCount) }; + LOG(INFO, "computing artist distance threshold using " << sampleCount << " samples on " << candidateCount << " candidates..."); math::StatsAccumulator stats; for (std::size_t i{}; i < sampleCount; ++i)