Spaces:
Running
Running
Upload src\churn_extractor.py with huggingface_hub
Browse files- src//churn_extractor.py +155 -0
src//churn_extractor.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/churn_extractor.py
|
| 2 |
+
# Churn Signal Extractor — Sentiment + Pattern Analysis
|
| 3 |
+
# SupportMind v1.0 — Asmitha
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, List
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
| 13 |
+
HAS_VADER = True
|
| 14 |
+
except ImportError:
|
| 15 |
+
HAS_VADER = False
|
| 16 |
+
|
| 17 |
+
COMPETITOR_PATTERNS = [
|
| 18 |
+
r'switch(?:ing)? to', r'moving to', r'looking at (?:alternatives?|competitors?|other\s+(?:tools?|platforms?|solutions?|vendors?))',
|
| 19 |
+
r'competitor', r'alternative', r'another (?:tool|platform|solution)',
|
| 20 |
+
r'better option', r'other providers',
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
CANCELLATION_PATTERNS = [
|
| 24 |
+
r'cancel', r'stop (?:using|subscription)', r'end (?:my )?contract',
|
| 25 |
+
r'not renew(?:ing)?', r'downgrad(?:e|ing)', r'close (?:my )?account',
|
| 26 |
+
r'terminate', r'discontinue', r'opt out',
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
FRUSTRATION_PATTERNS = [
|
| 30 |
+
r'very frustrated', r'completely broken', r'this is unacceptable',
|
| 31 |
+
r'third time', r'again\b', r'still not (?:fixed|working|resolved)',
|
| 32 |
+
r'waste of time', r'terrible', r'awful', r'disgusted',
|
| 33 |
+
r'fed up', r'last straw', r'ridiculous',
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
URGENCY_PATTERNS = [
|
| 37 |
+
r'asap', r'urgent(?:ly)?', r'immediately', r'critical',
|
| 38 |
+
r'blocking', r'production (?:is )?down', r'outage',
|
| 39 |
+
r'deadline', r'cannot wait',
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ChurnSignalExtractor:
|
| 44 |
+
"""
|
| 45 |
+
Extracts churn risk signals from support thread history.
|
| 46 |
+
|
| 47 |
+
Scans for competitor mentions, cancellation language, frustration
|
| 48 |
+
patterns, and sentiment trajectory. Produces a composite churn
|
| 49 |
+
risk score [0–1] for CRM health record updates.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
def __init__(self):
|
| 53 |
+
if HAS_VADER:
|
| 54 |
+
self.analyzer = SentimentIntensityAnalyzer()
|
| 55 |
+
else:
|
| 56 |
+
self.analyzer = None
|
| 57 |
+
logger.warning("VADER not installed. Using basic sentiment heuristic.")
|
| 58 |
+
|
| 59 |
+
def _get_sentiment(self, text: str) -> float:
|
| 60 |
+
"""Get sentiment score from -1.0 (negative) to 1.0 (positive)."""
|
| 61 |
+
if self.analyzer:
|
| 62 |
+
return self.analyzer.polarity_scores(text)['compound']
|
| 63 |
+
# Basic fallback
|
| 64 |
+
neg_words = ['bad', 'terrible', 'awful', 'broken', 'frustrated',
|
| 65 |
+
'angry', 'worst', 'hate', 'useless', 'horrible']
|
| 66 |
+
pos_words = ['good', 'great', 'love', 'excellent', 'amazing',
|
| 67 |
+
'helpful', 'perfect', 'thanks', 'wonderful']
|
| 68 |
+
text_lower = text.lower()
|
| 69 |
+
neg = sum(1 for w in neg_words if w in text_lower)
|
| 70 |
+
pos = sum(1 for w in pos_words if w in text_lower)
|
| 71 |
+
total = neg + pos
|
| 72 |
+
if total == 0:
|
| 73 |
+
return 0.0
|
| 74 |
+
return (pos - neg) / total
|
| 75 |
+
|
| 76 |
+
def extract(self, thread_texts: List[str]) -> Dict:
|
| 77 |
+
"""
|
| 78 |
+
Extract churn signals from a support thread.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
thread_texts: List of message strings in the support thread
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
Dictionary with churn_risk_score, flags, and details
|
| 85 |
+
"""
|
| 86 |
+
full_text = ' '.join(thread_texts).lower()
|
| 87 |
+
|
| 88 |
+
# Pattern matching
|
| 89 |
+
competitor = any(re.search(p, full_text) for p in COMPETITOR_PATTERNS)
|
| 90 |
+
cancellation = any(re.search(p, full_text) for p in CANCELLATION_PATTERNS)
|
| 91 |
+
frustration = sum(1 for p in FRUSTRATION_PATTERNS if re.search(p, full_text))
|
| 92 |
+
urgency = sum(1 for p in URGENCY_PATTERNS if re.search(p, full_text))
|
| 93 |
+
|
| 94 |
+
# Sentiment trajectory (across messages)
|
| 95 |
+
sentiments = [self._get_sentiment(t) for t in thread_texts[:10]]
|
| 96 |
+
neg_count = sum(1 for s in sentiments if s < -0.3)
|
| 97 |
+
avg_sentiment = sum(sentiments) / max(len(sentiments), 1)
|
| 98 |
+
|
| 99 |
+
# Sentiment trajectory: is it getting worse?
|
| 100 |
+
if len(sentiments) >= 3:
|
| 101 |
+
early = sum(sentiments[:len(sentiments)//2]) / max(len(sentiments)//2, 1)
|
| 102 |
+
late = sum(sentiments[len(sentiments)//2:]) / max(len(sentiments) - len(sentiments)//2, 1)
|
| 103 |
+
deteriorating = late < early - 0.2
|
| 104 |
+
else:
|
| 105 |
+
deteriorating = False
|
| 106 |
+
|
| 107 |
+
# Composite churn risk score [0–1]
|
| 108 |
+
score = min(1.0,
|
| 109 |
+
(0.40 if cancellation else 0.0) +
|
| 110 |
+
(0.30 if competitor else 0.0) +
|
| 111 |
+
min(frustration * 0.10, 0.20) +
|
| 112 |
+
(neg_count / max(len(sentiments), 1)) * 0.10 +
|
| 113 |
+
(0.10 if deteriorating else 0.0)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
risk_level = 'critical' if score >= 0.7 else 'high' if score >= 0.5 else 'medium' if score >= 0.3 else 'low'
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
'churn_risk_score': round(score, 3),
|
| 120 |
+
'risk_level': risk_level,
|
| 121 |
+
'competitor_mention': competitor,
|
| 122 |
+
'cancellation_language': cancellation,
|
| 123 |
+
'frustration_count': frustration,
|
| 124 |
+
'urgency_count': urgency,
|
| 125 |
+
'negative_sentiment_ratio': round(neg_count / max(len(sentiments), 1), 3),
|
| 126 |
+
'average_sentiment': round(avg_sentiment, 3),
|
| 127 |
+
'sentiment_deteriorating': deteriorating,
|
| 128 |
+
'message_count': len(thread_texts),
|
| 129 |
+
'recommendation': self._get_recommendation(score, competitor, cancellation),
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
def _get_recommendation(self, score: float, competitor: bool, cancellation: bool) -> str:
|
| 133 |
+
if score >= 0.7:
|
| 134 |
+
return 'IMMEDIATE escalation to Customer Success Manager'
|
| 135 |
+
if cancellation:
|
| 136 |
+
return 'Route to retention team with priority flag'
|
| 137 |
+
if competitor:
|
| 138 |
+
return 'Alert Account Manager — competitive threat detected'
|
| 139 |
+
if score >= 0.4:
|
| 140 |
+
return 'Flag for proactive outreach within 24 hours'
|
| 141 |
+
return 'Standard processing — monitor sentiment'
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
if __name__ == '__main__':
|
| 145 |
+
extractor = ChurnSignalExtractor()
|
| 146 |
+
thread = [
|
| 147 |
+
"Hi, I've been having issues with the export feature for two weeks now.",
|
| 148 |
+
"This is the third time I'm reporting this. Still not fixed.",
|
| 149 |
+
"I'm very frustrated. We're looking at alternative solutions.",
|
| 150 |
+
"If this isn't resolved by Friday, we'll need to cancel our subscription.",
|
| 151 |
+
]
|
| 152 |
+
result = extractor.extract(thread)
|
| 153 |
+
for k, v in result.items():
|
| 154 |
+
print(f" {k}: {v}")
|
| 155 |
+
|