Asmitha-28 commited on
Commit
a2abe8a
·
verified ·
1 Parent(s): 3496596

Upload src\churn_extractor.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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
+