Audio similarity engine: optimized release and artists matching

This commit is contained in:
emeric
2026-06-04 23:13:38 +02:00
parent 7ef19e4c06
commit 59b710ea37
4 changed files with 135 additions and 119 deletions
+32 -52
View File
@@ -38,30 +38,49 @@ namespace lms::math
using value_type = typename VectorType::value_type; using value_type = typename VectorType::value_type;
using size_type = std::size_t; using size_type = std::size_t;
// Add a single vector, returning its index // provided value must be accessible until finalize() is called
size_type add(const VectorType& value) void add(const VectorType& value)
{ {
_vectors.push_back(value); _pointers.push_back(&value);
return _vectors.size() - 1;
} }
// Compute the medoid: the vector with minimum sum of squared distances to all others // Returns a pointer to the medoid among the added vectors.
// Returns the index of the medoid in the added vectors // The pointer remains valid as long as the original vectors are alive.
size_type findMedoidIndex() const const VectorType* finalize() const
{ {
assert(!empty()); 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{}; size_type medoidIndex{};
value_type minTotalDistance{ std::numeric_limits<value_type>::max() }; value_type minTotalDistance{ std::numeric_limits<value_type>::max() };
for (size_type i{}; i < _vectors.size(); ++i) for (size_type i{}; i < _pointers.size(); ++i)
{ {
value_type totalDistance{}; value_type totalDistance{};
const SquaredEuclideanDistance distFunc{ _vectors[i] }; const SquaredEuclideanDistance distFunc{ *_pointers[i] };
for (size_type j{}; j < _vectors.size(); ++j) for (size_type j{}; j < _pointers.size(); ++j)
{ {
if (i != j) if (i != j)
totalDistance += distFunc(_vectors[j]); totalDistance += distFunc(*_pointers[j]);
} }
if (totalDistance < minTotalDistance) if (totalDistance < minTotalDistance)
@@ -74,37 +93,7 @@ namespace lms::math
return medoidIndex; return medoidIndex;
} }
// Compute the medoid vector itself std::vector<const VectorType*> _pointers;
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<VectorType> _vectors;
}; };
template<typename VectorType> template<typename VectorType>
@@ -113,15 +102,6 @@ namespace lms::math
MedoidCalculator<VectorType> calculator; MedoidCalculator<VectorType> calculator;
for (const auto& value : values) for (const auto& value : values)
calculator.add(value); calculator.add(value);
return calculator.finalize(); return *calculator.finalize();
}
template<typename VectorType>
VectorType computeNormalizedMedoid(std::span<const VectorType> values)
{
MedoidCalculator<VectorType> calculator;
for (const auto& value : values)
calculator.add(value);
return calculator.finalizeNormalized();
} }
} // namespace lms::math } // namespace lms::math
+13 -34
View File
@@ -40,12 +40,7 @@ namespace lms::math::medoidCalculatorTests
EXPECT_FALSE(calculator.empty()); EXPECT_FALSE(calculator.empty());
EXPECT_EQ(calculator.count(), 1U); EXPECT_EQ(calculator.count(), 1U);
EXPECT_EQ(calculator.findMedoidIndex(), 0U); EXPECT_EQ(calculator.finalize(), &vec);
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);
} }
TEST(MedoidCalculator, twoVectors) TEST(MedoidCalculator, twoVectors)
@@ -58,9 +53,9 @@ namespace lms::math::medoidCalculatorTests
calculator.add(v2); calculator.add(v2);
EXPECT_EQ(calculator.count(), 2U); EXPECT_EQ(calculator.count(), 2U);
// Both have equal distance to the other, but first one is returned // Both have equal distance to the other; result must be one of the two
const std::size_t medoidIndex = calculator.findMedoidIndex(); const Vector<2, float>* medoid = calculator.finalize();
EXPECT_TRUE(medoidIndex == 0 || medoidIndex == 1); EXPECT_TRUE(medoid == &v1 || medoid == &v2);
} }
TEST(MedoidCalculator, threeDifferentVectors) TEST(MedoidCalculator, threeDifferentVectors)
@@ -68,16 +63,14 @@ namespace lms::math::medoidCalculatorTests
MedoidCalculator<Vector<2, float>> calculator; MedoidCalculator<Vector<2, float>> calculator;
// Three points: (0,0), (1,0), (10,0) // Three points: (0,0), (1,0), (10,0)
// Medoid should be (1,0) as it's closest to the others // Medoid should be (1,0) as it's closest to the others
calculator.add(Vector<2, float>{ 0.0F, 0.0F }); const Vector<2, float> v0{ 0.0F, 0.0F };
calculator.add(Vector<2, float>{ 1.0F, 0.0F }); const Vector<2, float> v1{ 1.0F, 0.0F };
calculator.add(Vector<2, float>{ 10.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(calculator.finalize(), &v1);
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);
} }
TEST(MedoidCalculator, computeMedoidSpan) TEST(MedoidCalculator, computeMedoidSpan)
@@ -94,25 +87,11 @@ namespace lms::math::medoidCalculatorTests
EXPECT_FLOAT_EQ(result[1], 0.0F); EXPECT_FLOAT_EQ(result[1], 0.0F);
} }
TEST(MedoidCalculator, getVector)
{
MedoidCalculator<Vector<2, float>> 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) TEST(MedoidCalculator, clear)
{ {
MedoidCalculator<Vector<2, float>> calculator; MedoidCalculator<Vector<2, float>> 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); EXPECT_EQ(calculator.count(), 1);
calculator.clear(); calculator.clear();
EXPECT_EQ(calculator.count(), 0); EXPECT_EQ(calculator.count(), 0);
@@ -86,7 +86,9 @@ namespace lms::recommendation
std::vector<ReducedVector> _vectors; std::vector<ReducedVector> _vectors;
std::unordered_map<db::TrackId, const ReducedVector*> _trackVectors; std::unordered_map<db::TrackId, const ReducedVector*> _trackVectors;
std::unordered_map<db::ReleaseId, std::vector<std::reference_wrapper<const ReducedVector>>> _releaseVectors; std::unordered_map<db::ReleaseId, std::vector<std::reference_wrapper<const ReducedVector>>> _releaseVectors;
std::unordered_map<db::ReleaseId, const ReducedVector*> _releaseMedoids;
std::unordered_map<db::ArtistId, std::vector<std::reference_wrapper<const ReducedVector>>> _artistVectors; std::unordered_map<db::ArtistId, std::vector<std::reference_wrapper<const ReducedVector>>> _artistVectors;
std::unordered_map<db::ArtistId, const ReducedVector*> _artistMedoids;
TrackMetadataMap _trackMetadata; TrackMetadataMap _trackMetadata;
FloatType _trackDistanceThreshold{}; FloatType _trackDistanceThreshold{};
@@ -182,7 +182,7 @@ namespace lms::recommendation
if (medoidCalculator.empty()) if (medoidCalculator.empty())
return res; return res;
const ReducedVector queryVector{ medoidCalculator.finalize() }; const ReducedVector& queryVector{ *medoidCalculator.finalize() };
const math::NormalizedCosineDistance distFunc{ queryVector }; const math::NormalizedCosineDistance distFunc{ queryVector };
using Distance = float; using Distance = float;
@@ -356,9 +356,7 @@ namespace lms::recommendation
} }
template<AudioVectorProvider Provider, std::size_t ReducedDimCount> template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
ReleaseResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarReleases( ReleaseResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const
db::ReleaseId releaseId,
std::size_t maxCount) const
{ {
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar releases"); LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar releases");
@@ -373,26 +371,37 @@ namespace lms::recommendation
const auto& queryReleaseFeatures{ itQueryRelease->second }; const auto& queryReleaseFeatures{ itQueryRelease->second };
using Distance = float; using Distance = float;
std::vector<std::pair<db::ReleaseId, Distance>> rankedReleases;
rankedReleases.reserve(_releaseVectors.size());
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>; using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
for (const auto& [candidateId, candidateReleaseVectors] : _releaseVectors) // Stage 1: fast medoid scan to get top candidates
{ constexpr std::size_t preFilterMultiplier{ 10 };
if (candidateId == releaseId || candidateReleaseVectors.empty()) constexpr std::size_t preFilterMinCount{ 50 };
continue; const std::size_t preFilterCount{ std::min(_releaseMedoids.size() - 1, std::max(preFilterMinCount, maxCount * preFilterMultiplier)) };
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>( const math::NormalizedCosineDistance queryMedoidDist{ *_releaseMedoids.at(releaseId) };
queryReleaseFeatures, std::vector<std::pair<db::ReleaseId, FloatType>> medoidCandidates;
candidateReleaseVectors) }; 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<std::pair<db::ReleaseId, Distance>> rankedReleases;
rankedReleases.reserve(preFilterCount);
for (const auto& [candidateId, _] : medoidCandidates)
{
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>(queryReleaseFeatures, _releaseVectors.at(candidateId)) };
if (distance <= _releaseDistanceThreshold) if (distance <= _releaseDistanceThreshold)
rankedReleases.emplace_back(candidateId, distance); rankedReleases.emplace_back(candidateId, distance);
} }
const std::size_t resultCount{ std::min(maxCount, rankedReleases.size()) }; 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<std::ptrdiff_t>(resultCount)), std::end(rankedReleases), [](const auto& lhs, const auto& rhs) {
return lhs.second < rhs.second; return lhs.second < rhs.second;
}); });
@@ -422,19 +431,30 @@ namespace lms::recommendation
const auto& queryArtistFeatures{ itQueryArtist->second }; const auto& queryArtistFeatures{ itQueryArtist->second };
using Distance = float; using Distance = float;
std::vector<std::pair<db::ArtistId, Distance>> rankedArtists;
rankedArtists.reserve(_artistVectors.size());
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), typename ReducedVector::value_type>; using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), typename ReducedVector::value_type>;
for (const auto& [candidateId, candidateArtistFeatures] : _artistVectors) // Stage 1: fast medoid scan to get top-K candidates
{ constexpr std::size_t preFilterMultiplier{ 10 };
if (candidateId == artistId || candidateArtistFeatures.empty()) constexpr std::size_t preFilterMinCount{ 50 };
continue; const std::size_t preFilterCount{ std::min(_artistMedoids.size() - 1, std::max(preFilterMinCount, maxCount * preFilterMultiplier)) };
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>( const math::NormalizedCosineDistance queryMedoidDist{ *_artistMedoids.at(artistId) };
queryArtistFeatures, std::vector<std::pair<db::ArtistId, FloatType>> medoidCandidates;
candidateArtistFeatures) }; 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<std::pair<db::ArtistId, Distance>> rankedArtists;
rankedArtists.reserve(preFilterCount);
for (const auto& [candidateId, _] : medoidCandidates)
{
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>(queryArtistFeatures, _artistVectors.at(candidateId)) };
if (distance <= _artistDistanceThreshold) if (distance <= _artistDistanceThreshold)
rankedArtists.emplace_back(candidateId, distance); rankedArtists.emplace_back(candidateId, distance);
@@ -578,7 +598,9 @@ namespace lms::recommendation
_vectors.clear(); _vectors.clear();
_vectors.reserve(_trackCount); // must keep pointers valid _vectors.reserve(_trackCount); // must keep pointers valid
_releaseVectors.clear(); _releaseVectors.clear();
_releaseMedoids.clear();
_artistVectors.clear(); _artistVectors.clear();
_artistMedoids.clear();
_trackMetadata.clear(); _trackMetadata.clear();
Provider::visitVectors(session, [&](db::TrackId trackId, const SourceVector& sourceVector) { Provider::visitVectors(session, [&](db::TrackId trackId, const SourceVector& sourceVector) {
@@ -652,6 +674,23 @@ namespace lms::recommendation
_artistVectors.try_emplace(artist->getId(), std::move(artistTrackVectors)); _artistVectors.try_emplace(artist->getId(), std::move(artistTrackVectors));
}); });
math::MedoidCalculator<ReducedVector> 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 // Sort artistIds in each TrackMetadata entry for set-intersection in SameArtistConstraint
for (auto& [trackId, metadata] : _trackMetadata) for (auto& [trackId, metadata] : _trackMetadata)
std::sort(metadata.artistIds.begin(), metadata.artistIds.end()); std::sort(metadata.artistIds.begin(), metadata.artistIds.end());
@@ -714,6 +753,7 @@ namespace lms::recommendation
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold"); LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold");
constexpr std::size_t maxSampleCount{ 200 }; constexpr std::size_t maxSampleCount{ 200 };
constexpr std::size_t maxCandidateCount{ 1'000 };
constexpr float stdDevMultiplier{ 2.F }; constexpr float stdDevMultiplier{ 2.F };
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>; using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
@@ -722,11 +762,18 @@ namespace lms::recommendation
for (const auto& [id, vecs] : _releaseVectors) for (const auto& [id, vecs] : _releaseVectors)
allProfiles.push_back(&vecs); allProfiles.push_back(&vecs);
const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) }; // move maxCandidateCount random elements to the front
LOG(INFO, "computing release distance threshold using " << sampleCount << " samples..."); const std::size_t candidateCount{ std::min(allProfiles.size(), maxCandidateCount) };
std::minstd_rand randomEngine{ 42 }; std::minstd_rand randomEngine{ 42 };
core::random::shuffleContainer(randomEngine, allProfiles); for (std::size_t i{}; i < candidateCount; ++i)
{
std::uniform_int_distribution<std::size_t> 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<FloatType> stats; math::StatsAccumulator<FloatType> stats;
for (std::size_t i{}; i < sampleCount; ++i) for (std::size_t i{}; i < sampleCount; ++i)
@@ -759,6 +806,7 @@ namespace lms::recommendation
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold"); LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold");
constexpr std::size_t maxSampleCount{ 200 }; constexpr std::size_t maxSampleCount{ 200 };
constexpr std::size_t maxCandidateCount{ 1'000 };
constexpr float stdDevMultiplier{ 2.F }; constexpr float stdDevMultiplier{ 2.F };
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>; using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
@@ -767,11 +815,18 @@ namespace lms::recommendation
for (const auto& [id, vecs] : _artistVectors) for (const auto& [id, vecs] : _artistVectors)
allProfiles.push_back(&vecs); allProfiles.push_back(&vecs);
const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) }; // move maxCandidateCount random elements to the front
LOG(INFO, "computing artist distance threshold using " << sampleCount << " samples..."); const std::size_t candidateCount{ std::min(allProfiles.size(), maxCandidateCount) };
std::minstd_rand randomEngine{ 42 }; std::minstd_rand randomEngine{ 42 };
core::random::shuffleContainer(randomEngine, allProfiles); for (std::size_t i{}; i < candidateCount; ++i)
{
std::uniform_int_distribution<std::size_t> 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<FloatType> stats; math::StatsAccumulator<FloatType> stats;
for (std::size_t i{}; i < sampleCount; ++i) for (std::size_t i{}; i < sampleCount; ++i)