def preprocess_address(address: str) -> str: """Normalize address string for better comparison""" if not address: return "" # Convert to lowercase and remove special characters processed = re.sub(r"[^\w\s]", " ", str(address).lower()) # Normalize whitespace processed = " ".join(processed.split()) return processed def find_customer_clusters(customers: list[StripeCustomer]): """ Cluster customers based on similarity of their billing addresses. Args: customers: List of StripeCustomer objects with billing_addresses Returns: dict: Clusters of similar customers, keyed by cluster ID """ # Filter out customers with no billing addresses customers_with_addresses = [c for c in customers if c.billing_addresses] if not customers_with_addresses: logging.info("No customer addresses found") return {} # Prepare addresses for vectorization # For customers with multiple addresses, join them with a space addresses = [ preprocess_address(" ".join(c.billing_addresses)) for c in customers_with_addresses ] # Create TF-IDF vectors vectorizer = TfidfVectorizer( analyzer="word", ngram_range=(1, 2), # Use both unigrams and bigrams min_df=1, stop_words="english", ) tfidf_matrix = vectorizer.fit_transform(addresses) # Calculate similarity matrix similarity_matrix = cosine_similarity(tfidf_matrix) # Perform DBSCAN clustering clustering = DBSCAN( eps=0.2, # Maximum distance between two samples to be considered in same cluster min_samples=2, # Minimum samples in a cluster metric="precomputed", # Use our pre-computed similarity matrix ).fit(1 - similarity_matrix) # Convert similarity to distance # Organize results by cluster clusters = defaultdict(list) for idx, label in enumerate(clustering.labels_): if label != -1: # -1 represents noise points customer = customers_with_addresses[idx] clusters[label].append( { "customer_id": customer.id, "email": customer.email, "addresses": customer.billing_addresses, } ) # Log results logging.info(f"Found {len(clusters)} clusters of similar addresses") for cluster_id, members in clusters.items(): logging.info(f"\nCluster {cluster_id}:") for member in members: logging.info(f"Customer: {member['customer_id']}") logging.info(f"Email: {member['email']}") logging.info(f"Addresses: {member['addresses']}") logging.info("---") return dict(clusters)