pdm-ai
Version:
PDM-AI - Transform customer feedback into structured product insights using the Jobs-to-be-Done (JTBD) methodology
624 lines (517 loc) • 22.1 kB
JavaScript
// src/utils/clustering/clustering-service.js
import * as adaptiveClustering from './adaptive-clustering.js';
import * as embeddingService from '../embedding/embedding-service.js';
import logger from '../logger.js';
/**
* Generate hierarchical clusters from a list of items
* @param {Array} items - Items to cluster
* @param {Object} options - Options for clustering
* @returns {Promise<Object>} Hierarchical clusters
*/
async function generateHierarchicalClusters(items, options = {}) {
try {
const layerCount = options.layerCount || 2;
const verbose = options.verbose || false;
if (verbose) {
logger.info(`Clustering ${items.length} scenarios...`);
}
// Check if we have existing clusters to preserve (for incremental processing)
const existingClusters = options.existingClusters || null;
const preserveExisting = existingClusters !== null;
if (preserveExisting && verbose) {
logger.debug('Using existing clusters for incremental processing');
}
// Generate embeddings for items
const texts = items.map(item => item.statement);
const embeddings = await embeddingService.getEmbeddings(texts);
// Calculate similarity matrix
const similarityMatrix = calculateSimilarityMatrix(embeddings);
// First layer clustering - adapt based on whether we're preserving existing clusters
let firstLayerClusters;
let firstLayerResult;
if (preserveExisting && Object.keys(existingClusters.layer1).length > 0) {
// Use existing clusters and assign new items to the closest cluster
if (verbose) {
logger.debug(`Preserving ${Object.keys(existingClusters.layer1).length} existing first-layer clusters`);
}
// We're going to build a result that mimics what would come from adaptive clustering
firstLayerResult = await preserveAndExtendClusters(
items,
embeddings,
existingClusters.layer1,
options.layer1Threshold
);
firstLayerClusters = firstLayerResult.clusters;
} else {
// Generate new clusters from scratch
logger.debug(`Calculating optimal threshold for ${items.length} scenarios at layer 1`);
firstLayerResult = await adaptiveClustering.findOptimalThreshold(
similarityMatrix,
items,
(matrix, items, threshold) => clusterItems(matrix, items, threshold),
{ targetLayer: 1, verbose }
);
firstLayerClusters = firstLayerResult.clusters;
}
// Map items to first layer cluster IDs
const itemToFirstLayerCluster = {};
firstLayerClusters.forEach((cluster, index) => {
const clusterId = `cluster1-${index + 1}`;
cluster.forEach(item => {
itemToFirstLayerCluster[item.id] = clusterId;
});
});
// Create a structure for all layers
const result = {
layerCount,
items,
layers: [
{
layerId: 1,
clusters: firstLayerClusters.map((cluster, index) => ({
id: `cluster1-${index + 1}`,
items: cluster,
parentId: null // Will be filled later
}))
}
]
};
// Initialize firstLayerToSecondLayerCluster here to ensure it exists for all code paths
const firstLayerToSecondLayerCluster = {};
// Generate second layer clustering if requested
if (layerCount >= 2) {
// Create embeddings for first layer clusters by averaging their items
const clusterEmbeddings = firstLayerClusters.map(cluster => {
const clusterItemIndices = cluster.map(item => items.findIndex(i => i.id === item.id));
return averageEmbeddings(embeddings, clusterItemIndices);
});
// Calculate similarity matrix for first layer clusters
const clusterSimilarityMatrix = calculateSimilarityMatrix(clusterEmbeddings);
// Second layer clustering - handle existing clusters if preserving
let secondLayerClusters;
if (preserveExisting && Object.keys(existingClusters.layer2).length > 0) {
// Use existing second-layer clusters
if (verbose) {
logger.debug(`Preserving ${Object.keys(existingClusters.layer2).length} existing second-layer clusters`);
}
// Preserve and extend second layer clusters
const secondLayerResult = await preserveAndExtendLayerTwoClusters(
firstLayerClusters,
clusterEmbeddings,
existingClusters.layer2,
options.layer2Threshold
);
secondLayerClusters = secondLayerResult.clusters;
} else {
// Generate new second layer clusters
logger.debug(`Calculating optimal threshold for ${firstLayerClusters.length} clusters at layer 2`);
const secondLayerResult = await adaptiveClustering.findOptimalThreshold(
clusterSimilarityMatrix,
firstLayerClusters,
(matrix, clusters, threshold) => clusterItems(matrix, clusters, threshold),
{ targetLayer: 2, verbose }
);
secondLayerClusters = secondLayerResult.clusters;
}
// Map first layer cluster IDs to second layer cluster IDs
secondLayerClusters.forEach((clusterGroup, index) => {
const secondLayerClusterId = `cluster2-${index + 1}`;
// Each clusterGroup is an array of first-layer clusters
clusterGroup.forEach((firstLayerCluster, firstLayerIndex) => {
const firstLayerClusterId = `cluster1-${firstLayerClusters.indexOf(firstLayerCluster) + 1}`;
firstLayerToSecondLayerCluster[firstLayerClusterId] = secondLayerClusterId;
});
});
// Update parent IDs in first layer
result.layers[0].clusters.forEach(cluster => {
cluster.parentId = firstLayerToSecondLayerCluster[cluster.id] || null;
});
// Add second layer to result
result.layers.push({
layerId: 2,
clusters: secondLayerClusters.map((clusterGroup, index) => {
const secondLayerClusterId = `cluster2-${index + 1}`;
// Find all first layer cluster IDs that belong to this second layer cluster
const childIds = Object.entries(firstLayerToSecondLayerCluster)
.filter(([_, parentId]) => parentId === secondLayerClusterId)
.map(([childId, _]) => childId);
// Get all items from the child clusters
const clusterItems = [];
childIds.forEach(childId => {
const childCluster = result.layers[0].clusters.find(c => c.id === childId);
if (childCluster) {
clusterItems.push(...childCluster.items);
}
});
return {
id: secondLayerClusterId,
childIds,
items: clusterItems,
parentId: null // Could be extended to more layers if needed
};
})
});
}
// Add item to cluster mapping for convenience
result.itemToClusterMap = {};
// Map each item to its clusters at each layer
items.forEach(item => {
result.itemToClusterMap[item.id] = {
layer1: itemToFirstLayerCluster[item.id] || null,
layer2: itemToFirstLayerCluster[item.id] ?
firstLayerToSecondLayerCluster[itemToFirstLayerCluster[item.id]] || null : null
};
});
return result;
} catch (error) {
logger.error(`Error generating hierarchical clusters: ${error.message}`);
throw error;
}
}
/**
* Preserve existing clusters and extend them with new items
* @param {Array} items - New items to cluster
* @param {Array} embeddings - Embeddings for new items
* @param {Object} existingClusters - Mapping of existing cluster IDs to their info
* @param {number} threshold - Similarity threshold for new items
* @returns {Promise<Object>} Result mimicking adaptive clustering output
*/
async function preserveAndExtendClusters(items, embeddings, existingClusters, threshold) {
// Create placeholder clusters for existing cluster IDs
const existingClusterIds = Object.keys(existingClusters);
const placeholderClusters = existingClusterIds.map(id => []);
// Create cluster embeddings using random sample items (we'll update these)
let clusterEmbeddings = placeholderClusters.map(() => null);
// Function to find the most similar cluster for an item
const findBestCluster = async (item, itemEmbedding) => {
let bestCluster = 0;
let bestSimilarity = -1;
// For each cluster, calculate similarity with the item
for (let i = 0; i < placeholderClusters.length; i++) {
// Skip clusters with no embedding yet
if (!clusterEmbeddings[i]) continue;
// Calculate similarity
const similarity = cosineSimilarity(itemEmbedding, clusterEmbeddings[i]);
// If this is the best match so far, record it
if (similarity > bestSimilarity) {
bestSimilarity = similarity;
bestCluster = i;
}
}
// If best similarity is above threshold, assign to that cluster
// Otherwise create a new cluster
if (bestSimilarity >= (threshold || 0.5)) {
return bestCluster;
} else {
return -1; // Indicates we need a new cluster
}
};
// First pass: For each existing cluster, try to find items in the input that match
// the scenarios we know should be in this cluster
for (let clusterIdx = 0; clusterIdx < existingClusterIds.length; clusterIdx++) {
const clusterId = existingClusterIds[clusterIdx];
const clusterInfo = existingClusters[clusterId];
// Find items that match existing scenario IDs
for (const item of items) {
if (clusterInfo.scenarioIds && clusterInfo.scenarioIds.includes(item.id)) {
// If this item belongs to the current cluster, add it
placeholderClusters[clusterIdx].push(item);
}
}
// If we found items for this cluster, calculate the cluster embedding
if (placeholderClusters[clusterIdx].length > 0) {
const clusterItemIndices = placeholderClusters[clusterIdx]
.map(item => items.findIndex(i => i.id === item.id))
.filter(idx => idx !== -1);
clusterEmbeddings[clusterIdx] = averageEmbeddings(embeddings, clusterItemIndices);
}
}
// Second pass: Assign new items (those without existing cluster assignments) to the best matching cluster
// or create new clusters if no good match
const unassignedItems = items.filter(item =>
!existingClusterIds.some(clusterId =>
existingClusters[clusterId].scenarioIds &&
existingClusters[clusterId].scenarioIds.includes(item.id)
)
);
// New clusters we'll create
const newClusters = [];
// Process each unassigned item
for (const item of unassignedItems) {
const itemIndex = items.findIndex(i => i.id === item.id);
if (itemIndex === -1) continue;
const itemEmbedding = embeddings[itemIndex];
if (!itemEmbedding) continue;
// Find the best cluster for this item
const bestCluster = await findBestCluster(item, itemEmbedding);
if (bestCluster === -1) {
// Create a new cluster for this item
newClusters.push([item]);
} else {
// Assign to existing cluster
placeholderClusters[bestCluster].push(item);
// Update the cluster embedding
const clusterItemIndices = placeholderClusters[bestCluster]
.map(clusterItem => items.findIndex(i => i.id === clusterItem.id))
.filter(idx => idx !== -1);
clusterEmbeddings[bestCluster] = averageEmbeddings(embeddings, clusterItemIndices);
}
}
// Combine existing (now filled) and new clusters, removing any empty clusters
const finalClusters = [...placeholderClusters.filter(cluster => cluster.length > 0), ...newClusters];
// Return a result that mimics adaptive clustering output
return {
threshold: threshold || 0.5,
clusters: finalClusters
};
}
/**
* Preserve existing second-layer clusters and extend them with new first-layer clusters
* @param {Array} firstLayerClusters - All first layer clusters
* @param {Array} clusterEmbeddings - Embeddings for first layer clusters
* @param {Object} existingLayer2Clusters - Existing second layer cluster mappings
* @param {number} threshold - Similarity threshold
* @returns {Promise<Object>} Result mimicking adaptive clustering output
*/
async function preserveAndExtendLayerTwoClusters(firstLayerClusters, clusterEmbeddings, existingLayer2Clusters, threshold) {
// Create mapping of first layer clusters to their indexes
const firstLayerClusterIndices = {};
firstLayerClusters.forEach((cluster, index) => {
// Use a cluster key that can be matched against existing relationships
const scenarioIds = cluster.map(item => item.id).sort().join(',');
firstLayerClusterIndices[scenarioIds] = index;
});
// Create placeholder clusters for existing layer 2 cluster IDs
const existingClusterIds = Object.keys(existingLayer2Clusters);
const secondLayerClusters = existingClusterIds.map(() => []);
// For each existing layer 2 cluster, try to find matching first layer clusters
for (let clusterIdx = 0; clusterIdx < existingClusterIds.length; clusterIdx++) {
const clusterId = existingClusterIds[clusterIdx];
const clusterInfo = existingLayer2Clusters[clusterId];
// For each child cluster ID in this second layer cluster
if (clusterInfo.childClusterIds) {
for (let i = 0; i < firstLayerClusters.length; i++) {
const firstLayerCluster = firstLayerClusters[i];
// See if this first layer cluster matches any of the child clusters
const scenarioIds = firstLayerCluster.map(item => item.id).sort().join(',');
// If this first layer cluster belongs to an existing second layer cluster, add it
if (clusterInfo.childClusterIds.some(childId => {
// Get all scenario IDs belonging to this child cluster ID
const childScenarios = existingLayer2Clusters[clusterId]?.childClusterScenarioIds?.[childId];
return childScenarios && scenaroIdsOverlap(
childScenarios,
firstLayerCluster.map(item => item.id)
);
})) {
secondLayerClusters[clusterIdx].push(firstLayerCluster);
}
}
}
}
// For first layer clusters not assigned to any second layer cluster,
// assign them to the most similar second layer cluster or create new ones
const unassignedIndices = [];
for (let i = 0; i < firstLayerClusters.length; i++) {
const isAssigned = secondLayerClusters.some(cluster =>
cluster.some(c => c === firstLayerClusters[i])
);
if (!isAssigned) {
unassignedIndices.push(i);
}
}
// Calculate embeddings for existing second layer clusters
const secondLayerEmbeddings = secondLayerClusters.map(cluster => {
if (cluster.length === 0) return null;
const indices = [];
cluster.forEach(firstLayerCluster => {
const index = firstLayerClusters.indexOf(firstLayerCluster);
if (index !== -1) indices.push(index);
});
return averageEmbeddings(clusterEmbeddings, indices);
});
// New second layer clusters we'll create
const newSecondLayerClusters = [];
// Assign unassigned first layer clusters
for (const unassignedIdx of unassignedIndices) {
// Find the most similar second layer cluster
let bestClusterIdx = -1;
let bestSimilarity = 0;
for (let i = 0; i < secondLayerClusters.length; i++) {
// Skip if no embedding (empty cluster)
if (!secondLayerEmbeddings[i]) continue;
const similarity = cosineSimilarity(clusterEmbeddings[unassignedIdx], secondLayerEmbeddings[i]);
if (similarity > threshold && similarity > bestSimilarity) {
bestSimilarity = similarity;
bestClusterIdx = i;
}
}
if (bestClusterIdx !== -1) {
// Assign to existing second layer cluster
secondLayerClusters[bestClusterIdx].push(firstLayerClusters[unassignedIdx]);
// Update embedding
const indices = [];
secondLayerClusters[bestClusterIdx].forEach(firstLayerCluster => {
const index = firstLayerClusters.indexOf(firstLayerCluster);
if (index !== -1) indices.push(index);
});
secondLayerEmbeddings[bestClusterIdx] = averageEmbeddings(clusterEmbeddings, indices);
} else {
// Create new second layer cluster
newSecondLayerClusters.push([firstLayerClusters[unassignedIdx]]);
}
}
// Combine existing (now filled) and new second layer clusters
const finalClusters = [
...secondLayerClusters.filter(cluster => cluster.length > 0),
...newSecondLayerClusters
];
// Return a result that mimics adaptive clustering output
return {
threshold: threshold || 0.5,
clusters: finalClusters
};
}
/**
* Check if two sets of scenario IDs have significant overlap
* @param {Array} setA - First set of scenario IDs
* @param {Array} setB - Second set of scenario IDs
* @returns {boolean} True if significant overlap exists
*/
function scenaroIdsOverlap(setA, setB) {
// If either set is empty, no overlap
if (!setA || !setB || setA.length === 0 || setB.length === 0) {
return false;
}
// Count overlap
const overlap = setA.filter(id => setB.includes(id)).length;
// Check if overlap is significant (at least 50% of the smaller set)
const smallerSetSize = Math.min(setA.length, setB.length);
return overlap >= smallerSetSize * 0.5;
}
/**
* Cluster items based on similarity matrix and threshold
* @param {Array<Array<number>>} similarityMatrix - Similarity matrix
* @param {Array} items - Items to cluster
* @param {number} threshold - Similarity threshold for clustering
* @returns {Array<Array>} Array of clusters (each cluster is an array of items)
*/
function clusterItems(similarityMatrix, items, threshold) {
// Initialize each item as its own cluster
const clusters = items.map(item => [item]);
const clusterIndices = items.map((_, i) => i);
// Keep track of which items are already merged
const merged = new Set();
// Merge clusters until no more merges are possible
let mergeHappened = true;
while (mergeHappened) {
mergeHappened = false;
// Find the most similar pair of clusters
let bestPair = null;
let bestSimilarity = 0;
for (let i = 0; i < clusterIndices.length; i++) {
// Skip if this cluster is already merged
if (merged.has(i)) continue;
for (let j = i + 1; j < clusterIndices.length; j++) {
// Skip if this cluster is already merged
if (merged.has(j)) continue;
// Calculate average similarity between clusters
let totalSim = 0;
let count = 0;
for (const itemI of clusters[i]) {
const indexI = items.findIndex(item => item.id === itemI.id);
for (const itemJ of clusters[j]) {
const indexJ = items.findIndex(item => item.id === itemJ.id);
if (indexI !== -1 && indexJ !== -1) {
totalSim += similarityMatrix[indexI][indexJ];
count++;
}
}
}
const avgSim = count > 0 ? totalSim / count : 0;
// If similarity is above threshold and better than current best
if (avgSim >= threshold && avgSim > bestSimilarity) {
bestPair = [i, j];
bestSimilarity = avgSim;
}
}
}
// Merge the best pair if found
if (bestPair) {
const [i, j] = bestPair;
clusters[i] = [...clusters[i], ...clusters[j]];
merged.add(j);
mergeHappened = true;
}
}
// Filter out merged clusters
return clusters.filter((_, i) => !merged.has(i));
}
/**
* Calculate cosine similarity matrix between embeddings
* @param {Array<Array<number>>} embeddings - Array of embedding vectors
* @returns {Array<Array<number>>} Similarity matrix
*/
function calculateSimilarityMatrix(embeddings) {
const n = embeddings.length;
const matrix = Array(n).fill().map(() => Array(n).fill(0));
// Calculate similarity for each pair of embeddings
for (let i = 0; i < n; i++) {
matrix[i][i] = 1; // Self-similarity is 1
for (let j = i + 1; j < n; j++) {
const similarity = cosineSimilarity(embeddings[i], embeddings[j]);
matrix[i][j] = similarity;
matrix[j][i] = similarity; // Similarity matrix is symmetric
}
}
return matrix;
}
/**
* Calculate cosine similarity between two vectors
* @param {Array<number>} a - First vector
* @param {Array<number>} b - Second vector
* @returns {number} Cosine similarity (0-1)
*/
function cosineSimilarity(a, b) {
if (!a || !b) return 0;
let dotProduct = 0;
let magA = 0;
let magB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
magA = Math.sqrt(magA);
magB = Math.sqrt(magB);
if (magA === 0 || magB === 0) return 0;
return dotProduct / (magA * magB);
}
/**
* Average multiple embeddings
* @param {Array<Array<number>>} embeddings - Array of all embedding vectors
* @param {Array<number>} indices - Indices of embeddings to average
* @returns {Array<number>} Averaged embedding
*/
function averageEmbeddings(embeddings, indices) {
if (!indices.length) return null;
const validEmbeddings = indices
.map(i => embeddings[i])
.filter(Boolean);
if (!validEmbeddings.length) return null;
const result = Array(validEmbeddings[0].length).fill(0);
for (const embedding of validEmbeddings) {
for (let i = 0; i < embedding.length; i++) {
result[i] += embedding[i];
}
}
for (let i = 0; i < result.length; i++) {
result[i] /= validEmbeddings.length;
}
return result;
}
export {
generateHierarchicalClusters,
clusterItems,
calculateSimilarityMatrix,
cosineSimilarity
};