pandas-survey-toolkit 1.0.4__py3-none-any.whl → 1.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pandas_survey_toolkit/analytics.py +151 -113
- pandas_survey_toolkit/nlp.py +997 -824
- pandas_survey_toolkit/utils.py +120 -88
- pandas_survey_toolkit/vis.py +198 -760
- {pandas_survey_toolkit-1.0.4.dist-info → pandas_survey_toolkit-1.0.9.dist-info}/METADATA +76 -73
- pandas_survey_toolkit-1.0.9.dist-info/RECORD +10 -0
- {pandas_survey_toolkit-1.0.4.dist-info → pandas_survey_toolkit-1.0.9.dist-info}/WHEEL +1 -1
- {pandas_survey_toolkit-1.0.4.dist-info → pandas_survey_toolkit-1.0.9.dist-info}/licenses/LICENSE +21 -21
- pandas_survey_toolkit-1.0.4.dist-info/RECORD +0 -10
- {pandas_survey_toolkit-1.0.4.dist-info → pandas_survey_toolkit-1.0.9.dist-info}/top_level.txt +0 -0
pandas_survey_toolkit/nlp.py
CHANGED
@@ -1,824 +1,997 @@
|
|
1
|
-
import re
|
2
|
-
import warnings
|
3
|
-
from collections import defaultdict
|
4
|
-
from typing import List, Tuple, Union
|
5
|
-
|
6
|
-
import numpy as np
|
7
|
-
import pandas as pd
|
8
|
-
import pandas_flavor as pf
|
9
|
-
import spacy
|
10
|
-
from gensim.parsing.preprocessing import (
|
11
|
-
remove_stopwords,
|
12
|
-
strip_multiple_whitespaces,
|
13
|
-
strip_numeric,
|
14
|
-
strip_tags,
|
15
|
-
)
|
16
|
-
from scipy.special import softmax
|
17
|
-
from sentence_transformers import SentenceTransformer
|
18
|
-
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
19
|
-
|
20
|
-
from pandas_survey_toolkit.analytics import fit_cluster_hdbscan, fit_umap
|
21
|
-
from pandas_survey_toolkit.utils import (
|
22
|
-
apply_vectorizer,
|
23
|
-
combine_results,
|
24
|
-
create_masked_df,
|
25
|
-
)
|
26
|
-
|
27
|
-
|
28
|
-
@pf.register_dataframe_method
|
29
|
-
def cluster_questions(
|
30
|
-
df,
|
31
|
-
columns=None,
|
32
|
-
pattern=None,
|
33
|
-
likert_mapping=None,
|
34
|
-
umap_n_neighbors=15,
|
35
|
-
umap_min_dist=0.1,
|
36
|
-
hdbscan_min_cluster_size=20,
|
37
|
-
hdbscan_min_samples=None,
|
38
|
-
cluster_selection_epsilon=0.4,
|
39
|
-
):
|
40
|
-
"""
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
df
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
)
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
""
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
)
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
)
|
367
|
-
|
368
|
-
#
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
)
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
@pf.register_dataframe_method
|
544
|
-
def
|
545
|
-
df
|
546
|
-
input_column: str,
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
""
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
773
|
-
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
779
|
-
|
780
|
-
|
781
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
|
809
|
-
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
814
|
-
|
815
|
-
|
816
|
-
|
817
|
-
|
818
|
-
|
819
|
-
|
820
|
-
|
821
|
-
|
822
|
-
|
823
|
-
|
824
|
-
|
1
|
+
import re
|
2
|
+
import warnings
|
3
|
+
from collections import defaultdict
|
4
|
+
from typing import List, Tuple, Union
|
5
|
+
|
6
|
+
import numpy as np
|
7
|
+
import pandas as pd
|
8
|
+
import pandas_flavor as pf
|
9
|
+
import spacy
|
10
|
+
from gensim.parsing.preprocessing import (
|
11
|
+
remove_stopwords,
|
12
|
+
strip_multiple_whitespaces,
|
13
|
+
strip_numeric,
|
14
|
+
strip_tags,
|
15
|
+
)
|
16
|
+
from scipy.special import softmax
|
17
|
+
from sentence_transformers import SentenceTransformer
|
18
|
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
19
|
+
|
20
|
+
from pandas_survey_toolkit.analytics import fit_cluster_hdbscan, fit_umap
|
21
|
+
from pandas_survey_toolkit.utils import (
|
22
|
+
apply_vectorizer,
|
23
|
+
combine_results,
|
24
|
+
create_masked_df,
|
25
|
+
)
|
26
|
+
|
27
|
+
|
28
|
+
@pf.register_dataframe_method
|
29
|
+
def cluster_questions(
|
30
|
+
df,
|
31
|
+
columns=None,
|
32
|
+
pattern=None,
|
33
|
+
likert_mapping=None,
|
34
|
+
umap_n_neighbors=15,
|
35
|
+
umap_min_dist=0.1,
|
36
|
+
hdbscan_min_cluster_size=20,
|
37
|
+
hdbscan_min_samples=None,
|
38
|
+
cluster_selection_epsilon=0.4,
|
39
|
+
):
|
40
|
+
"""Cluster Likert scale questions based on response patterns.
|
41
|
+
|
42
|
+
Parameters
|
43
|
+
----------
|
44
|
+
df : pandas.DataFrame
|
45
|
+
The input DataFrame.
|
46
|
+
columns : list, optional
|
47
|
+
List of column names to cluster. If None, all columns matching the pattern will be used.
|
48
|
+
pattern : str, optional
|
49
|
+
Regex pattern to match column names. Used if columns is None.
|
50
|
+
likert_mapping : dict, optional
|
51
|
+
Custom mapping for Likert scale responses. If None, default mapping is used.
|
52
|
+
umap_n_neighbors : int, optional
|
53
|
+
The size of local neighborhood for UMAP. Default is 15.
|
54
|
+
umap_min_dist : float, optional
|
55
|
+
The minimum distance between points in UMAP. Default is 0.1.
|
56
|
+
hdbscan_min_cluster_size : int, optional
|
57
|
+
The minimum size of clusters for HDBSCAN. Default is 20.
|
58
|
+
hdbscan_min_samples : int, optional
|
59
|
+
The number of samples in a neighborhood for a core point in HDBSCAN. Default is None.
|
60
|
+
cluster_selection_epsilon : float, optional
|
61
|
+
A distance threshold. Clusters below this value will be merged. Default is 0.4.
|
62
|
+
Higher epsilon means fewer, larger clusters.
|
63
|
+
|
64
|
+
Returns
|
65
|
+
-------
|
66
|
+
pandas.DataFrame
|
67
|
+
The input DataFrame with additional columns for encoded Likert responses,
|
68
|
+
UMAP coordinates, and cluster IDs.
|
69
|
+
|
70
|
+
Raises
|
71
|
+
------
|
72
|
+
ValueError
|
73
|
+
If neither 'columns' nor 'pattern' is provided.
|
74
|
+
"""
|
75
|
+
# Select columns
|
76
|
+
if columns is None and pattern is None:
|
77
|
+
raise ValueError("Either 'columns' or 'pattern' must be provided.")
|
78
|
+
elif columns is None:
|
79
|
+
columns = df.filter(regex=pattern).columns.tolist()
|
80
|
+
|
81
|
+
# Encode Likert scales
|
82
|
+
df = df.encode_likert(columns, custom_mapping=likert_mapping)
|
83
|
+
encoded_columns = [f"likert_encoded_{col}" for col in columns]
|
84
|
+
|
85
|
+
# Apply UMAP
|
86
|
+
df = df.fit_umap(
|
87
|
+
input_columns=encoded_columns,
|
88
|
+
output_columns=["likert_umap_x", "likert_umap_y"],
|
89
|
+
n_neighbors=umap_n_neighbors,
|
90
|
+
min_dist=umap_min_dist,
|
91
|
+
metric="cosine",
|
92
|
+
)
|
93
|
+
|
94
|
+
# Apply HDBSCAN
|
95
|
+
df = df.fit_cluster_hdbscan(
|
96
|
+
input_columns=["likert_umap_x", "likert_umap_y"],
|
97
|
+
output_columns=["question_cluster_id", "question_cluster_probability"],
|
98
|
+
min_cluster_size=hdbscan_min_cluster_size,
|
99
|
+
min_samples=hdbscan_min_samples,
|
100
|
+
cluster_selection_epsilon=cluster_selection_epsilon,
|
101
|
+
)
|
102
|
+
|
103
|
+
return df
|
104
|
+
|
105
|
+
|
106
|
+
@pf.register_dataframe_method
|
107
|
+
def encode_likert(
|
108
|
+
df, likert_columns, output_prefix="likert_encoded_", custom_mapping=None, debug=True
|
109
|
+
):
|
110
|
+
"""Encode Likert scale responses to numeric values.
|
111
|
+
|
112
|
+
Parameters
|
113
|
+
----------
|
114
|
+
df : pandas.DataFrame
|
115
|
+
The input DataFrame.
|
116
|
+
likert_columns : list
|
117
|
+
List of column names containing Likert scale responses.
|
118
|
+
output_prefix : str, optional
|
119
|
+
Prefix for the new encoded columns. Default is 'likert_encoded_'.
|
120
|
+
custom_mapping : dict, optional
|
121
|
+
Optional custom mapping for Likert scale responses.
|
122
|
+
debug : bool, optional
|
123
|
+
If True, prints out the mappings. Default is True.
|
124
|
+
|
125
|
+
Returns
|
126
|
+
-------
|
127
|
+
pandas.DataFrame
|
128
|
+
The input DataFrame with additional columns for encoded Likert responses.
|
129
|
+
|
130
|
+
Notes
|
131
|
+
-----
|
132
|
+
Default mapping:
|
133
|
+
- -1: Phrases containing 'disagree', 'do not agree', etc.
|
134
|
+
- 0: Phrases containing 'neutral', 'neither', 'unsure', etc.
|
135
|
+
- +1: Phrases containing 'agree' (but not 'disagree' or 'not agree')
|
136
|
+
- NaN: NaN values are preserved
|
137
|
+
"""
|
138
|
+
|
139
|
+
|
140
|
+
def default_mapping(response):
|
141
|
+
if pd.isna(response):
|
142
|
+
return pd.NA
|
143
|
+
response = str(response).lower().strip()
|
144
|
+
|
145
|
+
# Neutral / Neither / Unsure / Don't know (0)
|
146
|
+
if re.search(r"\b(neutral|neither|unsure|know)\b", response) or re.search(
|
147
|
+
r"neither\s+agree\s+nor\s+disagree", response
|
148
|
+
):
|
149
|
+
return 0
|
150
|
+
|
151
|
+
# Disagree / Dissatisfied (-1)
|
152
|
+
if re.search(r"\b(disagree)\b", response) or re.search(
|
153
|
+
r"\b(dis|not|no)[-]{0,1}\s*(agree|satisf)", response
|
154
|
+
):
|
155
|
+
return -1
|
156
|
+
|
157
|
+
# Agree / Satisfied (1)
|
158
|
+
if re.search(r"\bagree\b", response) or re.search(r"satisf", response):
|
159
|
+
return 1
|
160
|
+
|
161
|
+
# Unable to classify
|
162
|
+
return None
|
163
|
+
|
164
|
+
conversion_summary = defaultdict(int)
|
165
|
+
unconverted_phrases = set()
|
166
|
+
|
167
|
+
if custom_mapping is None:
|
168
|
+
mapping_func = default_mapping
|
169
|
+
if debug:
|
170
|
+
print("Using default mapping:")
|
171
|
+
print("-1: Phrases containing 'disagree', 'do not agree', etc.")
|
172
|
+
print(" 0: Phrases containing 'neutral', 'neither', 'unsure', etc.")
|
173
|
+
print("+1: Phrases containing 'agree' (but not 'disagree' or 'not agree')")
|
174
|
+
print("NaN: NaN values are preserved")
|
175
|
+
else:
|
176
|
+
|
177
|
+
def mapping_func(response):
|
178
|
+
if pd.isna(response):
|
179
|
+
return pd.NA
|
180
|
+
converted = custom_mapping.get(str(response).lower().strip())
|
181
|
+
if converted is None:
|
182
|
+
unconverted_phrases.add(str(response))
|
183
|
+
return pd.NA
|
184
|
+
return converted
|
185
|
+
|
186
|
+
if debug:
|
187
|
+
print("Using custom mapping:", custom_mapping)
|
188
|
+
print("NaN: NaN values are preserved")
|
189
|
+
|
190
|
+
for column in likert_columns:
|
191
|
+
output_column = f"{output_prefix}{column}"
|
192
|
+
df[output_column] = df[column].apply(lambda x: mapping_func(x))
|
193
|
+
|
194
|
+
# Update conversion summary
|
195
|
+
for original, converted in zip(df[column], df[output_column]):
|
196
|
+
conversion_summary[f"{original} -> {converted}"] += 1
|
197
|
+
|
198
|
+
if debug:
|
199
|
+
for conversion, count in conversion_summary.items():
|
200
|
+
print(f" {conversion}: {count} times")
|
201
|
+
|
202
|
+
# Alert about unconverted phrases
|
203
|
+
if unconverted_phrases:
|
204
|
+
warnings.warn(
|
205
|
+
f"The following phrases were not converted (mapped to NaN): {', '.join(unconverted_phrases)}"
|
206
|
+
)
|
207
|
+
|
208
|
+
# Alert if default mapping didn't convert everything
|
209
|
+
if custom_mapping is None:
|
210
|
+
all_responses = set()
|
211
|
+
for column in likert_columns:
|
212
|
+
all_responses.update(df[column].dropna().unique())
|
213
|
+
unconverted = [
|
214
|
+
resp for resp in all_responses if default_mapping(resp) not in [-1, 0, 1]
|
215
|
+
]
|
216
|
+
if unconverted:
|
217
|
+
warnings.warn(
|
218
|
+
f"The default mapping didn't convert the following responses: {', '.join(unconverted)}"
|
219
|
+
)
|
220
|
+
|
221
|
+
return df
|
222
|
+
|
223
|
+
@pf.register_dataframe_method
|
224
|
+
def extract_keywords(
|
225
|
+
df: pd.DataFrame,
|
226
|
+
input_column: str,
|
227
|
+
output_column: str = "keywords",
|
228
|
+
preprocessed_column: str = "preprocessed_text",
|
229
|
+
spacy_column: str = "spacy_output",
|
230
|
+
lemma_column: str = "lemmatized_text",
|
231
|
+
top_n: int = 3,
|
232
|
+
threshold: float = 0.4,
|
233
|
+
ngram_range: Tuple[int, int] = (1, 1),
|
234
|
+
min_df: int = 5,
|
235
|
+
min_count: int = None,
|
236
|
+
min_proportion_with_keywords: float = 0.95,
|
237
|
+
**kwargs,
|
238
|
+
) -> pd.DataFrame:
|
239
|
+
"""Apply a pipeline of text preprocessing, spaCy processing, lemmatization, and TF-IDF
|
240
|
+
to extract keywords from the specified column.
|
241
|
+
|
242
|
+
Parameters
|
243
|
+
----------
|
244
|
+
df : pandas.DataFrame
|
245
|
+
The input DataFrame.
|
246
|
+
input_column : str
|
247
|
+
Name of the column containing text to process.
|
248
|
+
output_column : str, optional
|
249
|
+
Name of the column to store the extracted keywords. Default is 'keywords'.
|
250
|
+
preprocessed_column : str, optional
|
251
|
+
Name of the column to store preprocessed text. Default is 'preprocessed_text'.
|
252
|
+
spacy_column : str, optional
|
253
|
+
Name of the column to store spaCy output. Default is 'spacy_output'.
|
254
|
+
lemma_column : str, optional
|
255
|
+
Name of the column to store lemmatized text. Default is 'lemmatized_text'.
|
256
|
+
top_n : int, optional
|
257
|
+
Number of top keywords to extract for each document. Default is 3.
|
258
|
+
threshold : float, optional
|
259
|
+
Minimum TF-IDF score for a keyword to be included. Default is 0.4.
|
260
|
+
ngram_range : tuple, optional
|
261
|
+
The lower and upper boundary of the range of n-values for different n-grams to be extracted.
|
262
|
+
Default is (1, 1) which means only unigrams.
|
263
|
+
min_df : int, optional
|
264
|
+
Minimum document frequency for TF-IDF. Default is 5.
|
265
|
+
min_count : int, optional
|
266
|
+
Minimum count for a keyword to be considered common in refinement. Default is None.
|
267
|
+
min_proportion_with_keywords : float, optional
|
268
|
+
Minimum proportion of rows that should have keywords after refinement. Default is 0.95.
|
269
|
+
**kwargs
|
270
|
+
Additional keyword arguments to pass to the preprocessing, spaCy,
|
271
|
+
lemmatization, or TF-IDF functions.
|
272
|
+
|
273
|
+
Returns
|
274
|
+
-------
|
275
|
+
pandas.DataFrame
|
276
|
+
The input DataFrame with additional columns for preprocessed text,
|
277
|
+
spaCy output, lemmatized text, and extracted keywords.
|
278
|
+
"""
|
279
|
+
df_temp = df.copy()
|
280
|
+
# Step 1: Preprocess text
|
281
|
+
df_temp = df_temp.preprocess_text(
|
282
|
+
input_column=input_column,
|
283
|
+
output_column=preprocessed_column,
|
284
|
+
**kwargs.get("preprocess_kwargs", {}),
|
285
|
+
)
|
286
|
+
|
287
|
+
df_temp = df_temp.remove_short_comments(
|
288
|
+
input_column=input_column, min_comment_length=5
|
289
|
+
)
|
290
|
+
|
291
|
+
# Step 2: Apply spaCy
|
292
|
+
df_temp = df_temp.fit_spacy(
|
293
|
+
input_column=preprocessed_column, output_column=spacy_column
|
294
|
+
)
|
295
|
+
|
296
|
+
# Step 3: Get lemmatized text
|
297
|
+
df_temp = df_temp.get_lemma(
|
298
|
+
input_column=spacy_column,
|
299
|
+
output_column=lemma_column,
|
300
|
+
**kwargs.get("lemma_kwargs", {}),
|
301
|
+
)
|
302
|
+
|
303
|
+
# Step 4: Apply TF-IDF and extract keywords
|
304
|
+
df_temp = df_temp.fit_tfidf(
|
305
|
+
input_column=lemma_column,
|
306
|
+
output_column=output_column,
|
307
|
+
top_n=top_n,
|
308
|
+
threshold=threshold,
|
309
|
+
ngram_range=ngram_range,
|
310
|
+
min_df=min_df,
|
311
|
+
**kwargs.get("tfidf_kwargs", {}),
|
312
|
+
)
|
313
|
+
|
314
|
+
df_temp = df_temp.refine_keywords(
|
315
|
+
keyword_column=output_column,
|
316
|
+
text_column=lemma_column,
|
317
|
+
min_proportion=min_proportion_with_keywords,
|
318
|
+
output_column="refined_keywords",
|
319
|
+
min_count=min_count,
|
320
|
+
)
|
321
|
+
|
322
|
+
return df_temp
|
323
|
+
|
324
|
+
|
325
|
+
@pf.register_dataframe_method
|
326
|
+
def refine_keywords(
|
327
|
+
df: pd.DataFrame,
|
328
|
+
keyword_column: str = "keywords",
|
329
|
+
text_column: str = "lemmatized_text",
|
330
|
+
min_count: Union[int, None] = None,
|
331
|
+
min_proportion: float = 0.95,
|
332
|
+
output_column: str = None,
|
333
|
+
debug: bool = True,
|
334
|
+
) -> pd.DataFrame:
|
335
|
+
"""Refine keywords by replacing rare keywords with more common ones based on the text content.
|
336
|
+
|
337
|
+
Parameters
|
338
|
+
----------
|
339
|
+
df : pd.DataFrame
|
340
|
+
The input DataFrame.
|
341
|
+
keyword_column : str, optional
|
342
|
+
Name of the column containing keyword lists. Default is 'keywords'.
|
343
|
+
text_column : str, optional
|
344
|
+
Name of the column containing the original text. Default is 'lemmatized_text'.
|
345
|
+
min_count : int, optional
|
346
|
+
Minimum count for a keyword to be considered common. If None,
|
347
|
+
it will be determined automatically. Default is None.
|
348
|
+
min_proportion : float, optional
|
349
|
+
Minimum proportion of rows that should have keywords after refinement.
|
350
|
+
Used only if min_count is None. Default is 0.95.
|
351
|
+
output_column : str, optional
|
352
|
+
Column name for the refined keyword output. If None, the keyword_column
|
353
|
+
is overwritten. Default is None.
|
354
|
+
debug : bool, optional
|
355
|
+
If True, print detailed statistics about the refinement process. Default is True.
|
356
|
+
|
357
|
+
Returns
|
358
|
+
-------
|
359
|
+
pd.DataFrame
|
360
|
+
The input DataFrame with refined keywords.
|
361
|
+
"""
|
362
|
+
if output_column is None:
|
363
|
+
output_column = keyword_column
|
364
|
+
|
365
|
+
# Create masked DataFrame
|
366
|
+
masked_df, mask = create_masked_df(df, [keyword_column, text_column])
|
367
|
+
|
368
|
+
# Step 1 & 2: Collect all keywords and count them
|
369
|
+
all_keywords = [
|
370
|
+
keyword
|
371
|
+
for keywords in masked_df[keyword_column]
|
372
|
+
if isinstance(keywords, list)
|
373
|
+
for keyword in keywords
|
374
|
+
]
|
375
|
+
keyword_counts = pd.Series(all_keywords).value_counts()
|
376
|
+
|
377
|
+
def refine_row_keywords(row, common_keywords):
|
378
|
+
if pd.isna(row[text_column]) or not isinstance(row[keyword_column], list):
|
379
|
+
return []
|
380
|
+
|
381
|
+
text = str(row[text_column]).lower()
|
382
|
+
current_keywords = row[keyword_column]
|
383
|
+
refined_keywords = []
|
384
|
+
|
385
|
+
for keyword in current_keywords:
|
386
|
+
if keyword in common_keywords:
|
387
|
+
refined_keywords.append(keyword)
|
388
|
+
else:
|
389
|
+
# Find a replacement from common keywords
|
390
|
+
for common_keyword in sorted(
|
391
|
+
common_keywords, key=lambda k: (-keyword_counts[k], len(k))
|
392
|
+
):
|
393
|
+
if (
|
394
|
+
common_keyword in text
|
395
|
+
and common_keyword not in refined_keywords
|
396
|
+
):
|
397
|
+
refined_keywords.append(common_keyword)
|
398
|
+
break
|
399
|
+
|
400
|
+
# Ensure correct ordering based on appearance in the original text
|
401
|
+
return (
|
402
|
+
sorted(refined_keywords, key=lambda k: text.index(k))
|
403
|
+
if refined_keywords
|
404
|
+
else []
|
405
|
+
)
|
406
|
+
|
407
|
+
if min_count is None:
|
408
|
+
# Determine min_count automatically
|
409
|
+
def get_proportion_with_keywords(count):
|
410
|
+
common_keywords = set(keyword_counts[keyword_counts >= count].index)
|
411
|
+
refined_keywords = masked_df.apply(
|
412
|
+
lambda row: refine_row_keywords(row, common_keywords), axis=1
|
413
|
+
)
|
414
|
+
return (refined_keywords.str.len() > 0).mean()
|
415
|
+
|
416
|
+
min_count = 1
|
417
|
+
while get_proportion_with_keywords(min_count) > min_proportion:
|
418
|
+
min_count += 1
|
419
|
+
min_count -= 1 # Go back one step to ensure we're above the min_proportion
|
420
|
+
|
421
|
+
# Separate common and rare keywords
|
422
|
+
common_keywords = set(keyword_counts[keyword_counts >= min_count].index)
|
423
|
+
|
424
|
+
# Apply the refinement to each row
|
425
|
+
masked_df[output_column] = masked_df.apply(
|
426
|
+
lambda row: refine_row_keywords(row, common_keywords), axis=1
|
427
|
+
)
|
428
|
+
|
429
|
+
# Combine results
|
430
|
+
df_to_return = combine_results(df, masked_df, mask, [output_column])
|
431
|
+
|
432
|
+
if debug:
|
433
|
+
# Calculate statistics
|
434
|
+
original_keyword_count = masked_df[keyword_column].apply(
|
435
|
+
lambda x: len(x) if isinstance(x, list) else 0
|
436
|
+
)
|
437
|
+
refined_keyword_count = masked_df[output_column].apply(len)
|
438
|
+
|
439
|
+
original_unique_keywords = set(
|
440
|
+
keyword
|
441
|
+
for keywords in masked_df[keyword_column]
|
442
|
+
if isinstance(keywords, list)
|
443
|
+
for keyword in keywords
|
444
|
+
)
|
445
|
+
refined_unique_keywords = set(
|
446
|
+
keyword for keywords in masked_df[output_column] for keyword in keywords
|
447
|
+
)
|
448
|
+
|
449
|
+
print(f"Refinement complete. Min count used: {min_count}")
|
450
|
+
print(f"Original average keywords per row: {original_keyword_count.mean():.2f}")
|
451
|
+
print(f"Refined average keywords per row: {refined_keyword_count.mean():.2f}")
|
452
|
+
print(
|
453
|
+
f"Proportion of rows with keywords after refinement: {(refined_keyword_count > 0).mean():.2%}"
|
454
|
+
)
|
455
|
+
print(
|
456
|
+
f"Total unique keywords before refinement: {len(original_unique_keywords)}"
|
457
|
+
)
|
458
|
+
print(f"Total unique keywords after refinement: {len(refined_unique_keywords)}")
|
459
|
+
print(
|
460
|
+
f"Reduction in unique keywords: {(1 - len(refined_unique_keywords) / len(original_unique_keywords)):.2%}"
|
461
|
+
)
|
462
|
+
|
463
|
+
return df_to_return
|
464
|
+
|
465
|
+
|
466
|
+
@pf.register_dataframe_method
|
467
|
+
def remove_short_comments(
|
468
|
+
df: pd.DataFrame, input_column: str, min_comment_length: int = 5
|
469
|
+
) -> pd.DataFrame:
|
470
|
+
"""Replace comments shorter than the specified minimum length with NaN.
|
471
|
+
|
472
|
+
Parameters
|
473
|
+
----------
|
474
|
+
df : pandas.DataFrame
|
475
|
+
The input DataFrame.
|
476
|
+
input_column : str
|
477
|
+
Name of the column containing text to process.
|
478
|
+
min_comment_length : int, optional
|
479
|
+
Minimum length of comment to keep. Default is 5.
|
480
|
+
|
481
|
+
Returns
|
482
|
+
-------
|
483
|
+
pandas.DataFrame
|
484
|
+
The input DataFrame with short comments replaced by NaN.
|
485
|
+
"""
|
486
|
+
# Create a copy of the DataFrame to avoid modifying the original
|
487
|
+
df_copy = df.copy()
|
488
|
+
|
489
|
+
# Replace short comments with NaN
|
490
|
+
df_copy[input_column] = df_copy[input_column].apply(
|
491
|
+
lambda x: x if isinstance(x, str) and len(x) >= min_comment_length else np.nan
|
492
|
+
)
|
493
|
+
|
494
|
+
return df_copy
|
495
|
+
|
496
|
+
|
497
|
+
@pf.register_dataframe_method
|
498
|
+
def fit_sentence_transformer(
|
499
|
+
df,
|
500
|
+
input_column: str,
|
501
|
+
model_name="all-MiniLM-L6-v2",
|
502
|
+
output_column="sentence_embedding",
|
503
|
+
):
|
504
|
+
"""Add vector embeddings for each string in the input column.
|
505
|
+
|
506
|
+
Creates sentence embeddings that can be used for downstream tasks like clustering.
|
507
|
+
|
508
|
+
Parameters
|
509
|
+
----------
|
510
|
+
df : pandas.DataFrame
|
511
|
+
The input DataFrame.
|
512
|
+
input_column : str
|
513
|
+
Name of the column containing text to embed.
|
514
|
+
model_name : str, optional
|
515
|
+
Name of the sentence transformer model to use. Default is 'all-MiniLM-L6-v2'.
|
516
|
+
output_column : str, optional
|
517
|
+
Name of the column to store embeddings. Default is 'sentence_embedding'.
|
518
|
+
|
519
|
+
Returns
|
520
|
+
-------
|
521
|
+
pandas.DataFrame
|
522
|
+
The input DataFrame with an additional column containing sentence embeddings.
|
523
|
+
"""
|
524
|
+
|
525
|
+
# Initialize the sentence transformer model
|
526
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
527
|
+
model = SentenceTransformer(model_name)
|
528
|
+
|
529
|
+
# Create sentence embeddings
|
530
|
+
embeddings = model.encode(masked_df[input_column].tolist())
|
531
|
+
|
532
|
+
# Convert embeddings to a list of numpy arrays
|
533
|
+
embeddings_list = [embedding for embedding in embeddings]
|
534
|
+
|
535
|
+
# Add the embeddings as a new column in the dataframe
|
536
|
+
masked_df[output_column] = embeddings_list
|
537
|
+
df_to_return = combine_results(df, masked_df, mask, output_column)
|
538
|
+
|
539
|
+
return df_to_return
|
540
|
+
|
541
|
+
|
542
|
+
|
543
|
+
@pf.register_dataframe_method
|
544
|
+
def extract_sentiment(
|
545
|
+
df,
|
546
|
+
input_column: str,
|
547
|
+
output_columns=["positive", "neutral", "negative", "sentiment"],
|
548
|
+
):
|
549
|
+
"""Extract sentiment from text using the cardiffnlp/twitter-roberta-base-sentiment model.
|
550
|
+
|
551
|
+
Parameters
|
552
|
+
----------
|
553
|
+
df : pandas.DataFrame
|
554
|
+
The input DataFrame.
|
555
|
+
input_column : str
|
556
|
+
Name of the column containing text to analyze.
|
557
|
+
output_columns : list, optional
|
558
|
+
List of column names for the output.
|
559
|
+
Default is ["positive", "neutral", "negative", "sentiment"].
|
560
|
+
|
561
|
+
Returns
|
562
|
+
-------
|
563
|
+
pandas.DataFrame
|
564
|
+
The input DataFrame with additional columns for sentiment scores and labels.
|
565
|
+
"""
|
566
|
+
|
567
|
+
MODEL = "cardiffnlp/twitter-roberta-base-sentiment"
|
568
|
+
tokenizer = AutoTokenizer.from_pretrained(MODEL)
|
569
|
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL)
|
570
|
+
|
571
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
572
|
+
|
573
|
+
def analyze_sentiment(text):
|
574
|
+
encoded_input = tokenizer(
|
575
|
+
text, return_tensors="pt", truncation=True, max_length=512, padding=True
|
576
|
+
)
|
577
|
+
output = model(**encoded_input)
|
578
|
+
scores = output.logits[0].detach().numpy()
|
579
|
+
scores = softmax(scores)
|
580
|
+
return scores
|
581
|
+
|
582
|
+
sentiment_scores = masked_df[input_column].apply(analyze_sentiment)
|
583
|
+
|
584
|
+
masked_df[output_columns[0]] = sentiment_scores.apply(lambda x: x[2]) # Positive
|
585
|
+
masked_df[output_columns[1]] = sentiment_scores.apply(lambda x: x[1]) # Neutral
|
586
|
+
masked_df[output_columns[2]] = sentiment_scores.apply(lambda x: x[0]) # Negative
|
587
|
+
|
588
|
+
masked_df[output_columns[3]] = masked_df[
|
589
|
+
[output_columns[0], output_columns[1], output_columns[2]]
|
590
|
+
].idxmax(axis=1)
|
591
|
+
masked_df[output_columns[3]] = masked_df[output_columns[3]].map(
|
592
|
+
{
|
593
|
+
output_columns[0]: "positive",
|
594
|
+
output_columns[1]: "neutral",
|
595
|
+
output_columns[2]: "negative",
|
596
|
+
}
|
597
|
+
)
|
598
|
+
|
599
|
+
df_to_return = combine_results(df, masked_df, mask, output_columns)
|
600
|
+
return df_to_return
|
601
|
+
|
602
|
+
|
603
|
+
@pf.register_dataframe_method
|
604
|
+
def cluster_comments(
|
605
|
+
df: pd.DataFrame,
|
606
|
+
input_column: str,
|
607
|
+
output_columns: str = ["cluster", "cluster_probability"],
|
608
|
+
min_cluster_size=5,
|
609
|
+
cluster_selection_epsilon: float = 0.2,
|
610
|
+
n_neighbors: int = 15,
|
611
|
+
):
|
612
|
+
"""Apply a pipeline for clustering text comments.
|
613
|
+
|
614
|
+
Applies a pipeline of:
|
615
|
+
1) Vector embeddings
|
616
|
+
2) Dimensional reduction
|
617
|
+
3) Clustering
|
618
|
+
|
619
|
+
This assigns each row a cluster ID so that similar free text comments
|
620
|
+
(found in the input_column) can be grouped together.
|
621
|
+
|
622
|
+
Parameters
|
623
|
+
----------
|
624
|
+
df : pandas.DataFrame
|
625
|
+
The input DataFrame.
|
626
|
+
input_column : str
|
627
|
+
Name of the column containing text to cluster.
|
628
|
+
output_columns : list, optional
|
629
|
+
Names for the output columns. Default is ["cluster", "cluster_probability"].
|
630
|
+
min_cluster_size : int, optional
|
631
|
+
The minimum size of clusters for HDBSCAN. Default is 5.
|
632
|
+
cluster_selection_epsilon : float, optional
|
633
|
+
Distance threshold for HDBSCAN. Higher epsilon means fewer, larger clusters.
|
634
|
+
Default is 0.2.
|
635
|
+
n_neighbors : int, optional
|
636
|
+
The size of local neighborhood for UMAP. Default is 15.
|
637
|
+
|
638
|
+
Returns
|
639
|
+
-------
|
640
|
+
pandas.DataFrame
|
641
|
+
The input DataFrame with additional columns for cluster IDs and probabilities.
|
642
|
+
"""
|
643
|
+
|
644
|
+
|
645
|
+
df_temp = (
|
646
|
+
df.fit_sentence_transformer(
|
647
|
+
input_column=input_column, output_column="sentence_embedding"
|
648
|
+
)
|
649
|
+
.fit_umap(
|
650
|
+
input_columns="sentence_embedding",
|
651
|
+
embeddings_in_list=True,
|
652
|
+
n_neighbors=n_neighbors,
|
653
|
+
)
|
654
|
+
.fit_cluster_hdbscan(
|
655
|
+
output_columns=output_columns,
|
656
|
+
min_cluster_size=min_cluster_size,
|
657
|
+
cluster_selection_epsilon=cluster_selection_epsilon,
|
658
|
+
)
|
659
|
+
)
|
660
|
+
|
661
|
+
return df_temp
|
662
|
+
|
663
|
+
@pf.register_dataframe_method
|
664
|
+
def fit_tfidf(
|
665
|
+
df: pd.DataFrame,
|
666
|
+
input_column: str,
|
667
|
+
output_column: str = "keywords",
|
668
|
+
top_n: int = 3,
|
669
|
+
threshold: float = 0.6,
|
670
|
+
append_features: bool = False,
|
671
|
+
ngram_range: Tuple[int, int] = (1, 1),
|
672
|
+
**tfidf_kwargs,
|
673
|
+
) -> pd.DataFrame:
|
674
|
+
"""Apply TF-IDF vectorization to extract top keywords from text.
|
675
|
+
|
676
|
+
Parameters
|
677
|
+
----------
|
678
|
+
df : pandas.DataFrame
|
679
|
+
The input DataFrame.
|
680
|
+
input_column : str
|
681
|
+
Name of the column containing text to vectorize.
|
682
|
+
output_column : str, optional
|
683
|
+
Name of the column to store the extracted keywords. Default is 'keywords'.
|
684
|
+
top_n : int, optional
|
685
|
+
Number of top keywords to extract for each document. Default is 3.
|
686
|
+
threshold : float, optional
|
687
|
+
Minimum TF-IDF score for a keyword to be included. Default is 0.6.
|
688
|
+
append_features : bool, optional
|
689
|
+
If True, append all TF-IDF features to the DataFrame (useful for downstream machine learning tasks). Default is False.
|
690
|
+
ngram_range : tuple, optional
|
691
|
+
The lower and upper boundary of the range of n-values for different
|
692
|
+
n-grams to be extracted. Default is (1, 1) which means only unigrams.
|
693
|
+
Set to (1, 2) for unigrams and bigrams, and so on.
|
694
|
+
**tfidf_kwargs
|
695
|
+
Additional keyword arguments to pass to TfidfVectorizer.
|
696
|
+
|
697
|
+
Returns
|
698
|
+
-------
|
699
|
+
pandas.DataFrame
|
700
|
+
The input DataFrame with an additional column containing the top keywords.
|
701
|
+
"""
|
702
|
+
# Create a masked DataFrame
|
703
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
704
|
+
|
705
|
+
# Ensure ngram_range is included in the TfidfVectorizer parameters
|
706
|
+
tfidf_kwargs["ngram_range"] = ngram_range
|
707
|
+
# Inside fit_tfidf function
|
708
|
+
tfidf_kwargs["min_df"] = tfidf_kwargs.get("min_df", 1)
|
709
|
+
|
710
|
+
# Apply TF-IDF vectorization to the masked DataFrame
|
711
|
+
tfidf_features, _, feature_names = apply_vectorizer(
|
712
|
+
masked_df, input_column, vectorizer_name="TfidfVectorizer", **tfidf_kwargs
|
713
|
+
)
|
714
|
+
|
715
|
+
def extract_top_keywords(row: pd.Series) -> List[str]:
|
716
|
+
# Get indices of top N TF-IDF scores
|
717
|
+
top_indices = row.nlargest(top_n).index
|
718
|
+
|
719
|
+
# Get the original text for this row
|
720
|
+
original_text = masked_df.loc[row.name, input_column].lower()
|
721
|
+
|
722
|
+
# Filter based on threshold, presence in original text, and get the corresponding feature names
|
723
|
+
top_keywords = [
|
724
|
+
feature_names[i]
|
725
|
+
for i, idx in enumerate(tfidf_features.columns)
|
726
|
+
if idx in top_indices
|
727
|
+
and row[idx] >= threshold
|
728
|
+
and feature_names[i].lower() in original_text
|
729
|
+
]
|
730
|
+
|
731
|
+
# Sort keywords based on their order in the original text
|
732
|
+
return sorted(top_keywords, key=lambda x: original_text.index(x.lower()))
|
733
|
+
|
734
|
+
# Extract top keywords for each document
|
735
|
+
masked_df[output_column] = tfidf_features.apply(extract_top_keywords, axis=1)
|
736
|
+
|
737
|
+
# Combine the results back into the original DataFrame
|
738
|
+
result_df = combine_results(df, masked_df, mask, [output_column])
|
739
|
+
|
740
|
+
# Optionally append all TF-IDF features
|
741
|
+
if append_features:
|
742
|
+
# We need to handle NaN values in the features as well
|
743
|
+
feature_columns = tfidf_features.columns.tolist()
|
744
|
+
masked_df = pd.concat([masked_df, tfidf_features], axis=1)
|
745
|
+
result_df = combine_results(result_df, masked_df, mask, feature_columns)
|
746
|
+
|
747
|
+
return result_df
|
748
|
+
|
749
|
+
|
750
|
+
@pf.register_dataframe_method
|
751
|
+
def fit_spacy(df, input_column: str, output_column: str = "spacy_output"):
|
752
|
+
"""Apply the en_core_web_md spaCy model to the specified column.
|
753
|
+
|
754
|
+
Parameters
|
755
|
+
----------
|
756
|
+
df : pandas.DataFrame
|
757
|
+
The input DataFrame.
|
758
|
+
input_column : str
|
759
|
+
Name of the column containing text to analyze.
|
760
|
+
output_column : str, optional
|
761
|
+
Name of the output column. Default is "spacy_output".
|
762
|
+
|
763
|
+
Returns
|
764
|
+
-------
|
765
|
+
pandas.DataFrame
|
766
|
+
The input DataFrame with an additional column containing spaCy doc objects.
|
767
|
+
|
768
|
+
Notes
|
769
|
+
-----
|
770
|
+
If the spaCy model is not already downloaded, this function will attempt
|
771
|
+
to download it automatically.
|
772
|
+
"""
|
773
|
+
|
774
|
+
# Check if the model is downloaded, if not, download it
|
775
|
+
try:
|
776
|
+
nlp = spacy.load("en_core_web_md")
|
777
|
+
except OSError:
|
778
|
+
print("Downloading en_core_web_md model...")
|
779
|
+
spacy.cli.download("en_core_web_md")
|
780
|
+
nlp = spacy.load("en_core_web_md")
|
781
|
+
|
782
|
+
# Create masked DataFrame
|
783
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
784
|
+
|
785
|
+
# Apply spaCy model
|
786
|
+
masked_df[output_column] = masked_df[input_column].apply(nlp)
|
787
|
+
|
788
|
+
# Combine results
|
789
|
+
df_to_return = combine_results(df, masked_df, mask, output_column)
|
790
|
+
|
791
|
+
return df_to_return
|
792
|
+
|
793
|
+
|
794
|
+
@pf.register_dataframe_method
|
795
|
+
def get_lemma(
|
796
|
+
df: pd.DataFrame,
|
797
|
+
input_column: str = "spacy_output",
|
798
|
+
output_column: str = "lemmatized_text",
|
799
|
+
text_pos: List[str] = ["PRON"],
|
800
|
+
remove_punct: bool = True,
|
801
|
+
remove_space: bool = True,
|
802
|
+
remove_stop: bool = True,
|
803
|
+
keep_tokens: Union[List[str], None] = None,
|
804
|
+
keep_pos: Union[List[str], None] = None,
|
805
|
+
keep_dep: Union[List[str], None] = ["neg"],
|
806
|
+
join_tokens: bool = True,
|
807
|
+
) -> pd.DataFrame:
|
808
|
+
"""Extract lemmatized text from spaCy doc objects.
|
809
|
+
|
810
|
+
Parameters
|
811
|
+
----------
|
812
|
+
df : pandas.DataFrame
|
813
|
+
The input DataFrame.
|
814
|
+
input_column : str, optional
|
815
|
+
Name of the column containing spaCy doc objects. Default is 'spacy_output'.
|
816
|
+
output_column : str, optional
|
817
|
+
Name of the output column for lemmatized text. Default is 'lemmatized_text'.
|
818
|
+
text_pos : List[str], optional
|
819
|
+
List of POS tags to exclude from lemmatization and return the text. Default is ['PRON'].
|
820
|
+
remove_punct : bool, optional
|
821
|
+
Whether to remove punctuation. Default is True.
|
822
|
+
remove_space : bool, optional
|
823
|
+
Whether to remove whitespace tokens. Default is True.
|
824
|
+
remove_stop : bool, optional
|
825
|
+
Whether to remove stop words. Default is True.
|
826
|
+
keep_tokens : List[str], optional
|
827
|
+
List of token texts to always keep. Default is None.
|
828
|
+
keep_pos : List[str], optional
|
829
|
+
List of POS tags to always keep. Default is None.
|
830
|
+
keep_dep : List[str], optional
|
831
|
+
List of dependency labels to always keep. Default is ["neg"].
|
832
|
+
join_tokens : bool, optional
|
833
|
+
Whether to join tokens into a string. If False, returns a list of tokens. Default is True.
|
834
|
+
|
835
|
+
Returns
|
836
|
+
-------
|
837
|
+
pandas.DataFrame
|
838
|
+
The input DataFrame with an additional column containing lemmatized text or token list.
|
839
|
+
"""
|
840
|
+
|
841
|
+
# Create masked DataFrame
|
842
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
843
|
+
|
844
|
+
def remove_token(token):
|
845
|
+
"""
|
846
|
+
Returns True if the token should be removed.
|
847
|
+
"""
|
848
|
+
if (
|
849
|
+
(keep_tokens and token.text in keep_tokens)
|
850
|
+
or (keep_pos and token.pos_ in keep_pos)
|
851
|
+
or (keep_dep and token.dep_ in keep_dep)
|
852
|
+
):
|
853
|
+
return False
|
854
|
+
return (
|
855
|
+
(remove_punct and token.is_punct)
|
856
|
+
or (remove_space and token.is_space)
|
857
|
+
or (remove_stop and token.is_stop)
|
858
|
+
)
|
859
|
+
|
860
|
+
def process_text(doc):
|
861
|
+
tokens = [
|
862
|
+
token.text if token.pos_ in text_pos else token.lemma_
|
863
|
+
for token in doc
|
864
|
+
if not remove_token(token)
|
865
|
+
]
|
866
|
+
return " ".join(tokens) if join_tokens else tokens
|
867
|
+
|
868
|
+
# Apply processing
|
869
|
+
masked_df[output_column] = masked_df[input_column].apply(process_text)
|
870
|
+
|
871
|
+
# Combine results
|
872
|
+
df_to_return = combine_results(df, masked_df, mask, output_column)
|
873
|
+
|
874
|
+
return df_to_return
|
875
|
+
|
876
|
+
|
877
|
+
@pf.register_dataframe_method
|
878
|
+
def preprocess_text(
|
879
|
+
df: pd.DataFrame,
|
880
|
+
input_column: str,
|
881
|
+
output_column: str = None,
|
882
|
+
remove_html: bool = True,
|
883
|
+
lower_case: bool = False,
|
884
|
+
normalize_whitespace: bool = True,
|
885
|
+
remove_numbers: bool = False,
|
886
|
+
remove_stopwords: bool = False,
|
887
|
+
flag_short_comments: bool = False,
|
888
|
+
min_comment_length: int = 5,
|
889
|
+
max_comment_length: int = None,
|
890
|
+
remove_punctuation: bool = True,
|
891
|
+
keep_sentence_punctuation: bool = True,
|
892
|
+
comment_length_column: str = None,
|
893
|
+
) -> pd.DataFrame:
|
894
|
+
"""Preprocess text data in the specified column, tailored for survey responses.
|
895
|
+
|
896
|
+
Parameters
|
897
|
+
----------
|
898
|
+
df : pandas.DataFrame
|
899
|
+
The input DataFrame.
|
900
|
+
input_column : str
|
901
|
+
Name of the column containing text to preprocess.
|
902
|
+
output_column : str, optional
|
903
|
+
Name of the output column. If None, overwrites the input column.
|
904
|
+
remove_html : bool, optional
|
905
|
+
Whether to remove unexpected HTML tags. Default is True.
|
906
|
+
lower_case : bool, optional
|
907
|
+
Whether to lowercase all words. Default is False.
|
908
|
+
normalize_whitespace : bool, optional
|
909
|
+
Whether to normalize whitespace. Default is True.
|
910
|
+
remove_numbers : bool, optional
|
911
|
+
Whether to remove numbers. Default is False.
|
912
|
+
remove_stopwords : bool, optional
|
913
|
+
Whether to remove stop words. Default is False.
|
914
|
+
flag_short_comments : bool, optional
|
915
|
+
Whether to flag very short comments. Default is False.
|
916
|
+
min_comment_length : int, optional
|
917
|
+
Minimum length of comment to not be flagged as short. Default is 5.
|
918
|
+
max_comment_length : int, optional
|
919
|
+
Maximum length of comment to keep. If None, keeps full length. Default is None.
|
920
|
+
remove_punctuation : bool, optional
|
921
|
+
Whether to remove punctuation. Default is True.
|
922
|
+
keep_sentence_punctuation : bool, optional
|
923
|
+
Whether to keep sentence-level punctuation. Default is True.
|
924
|
+
comment_length_column : str, optional
|
925
|
+
Name of the column to store comment lengths. If None, no column is added. Default is None.
|
926
|
+
|
927
|
+
Returns
|
928
|
+
-------
|
929
|
+
pandas.DataFrame
|
930
|
+
The input DataFrame with preprocessed text and optionally new columns for
|
931
|
+
short comments, truncation info, and comment length.
|
932
|
+
"""
|
933
|
+
|
934
|
+
output_column = output_column or input_column
|
935
|
+
|
936
|
+
# Create masked DataFrame
|
937
|
+
masked_df, mask = create_masked_df(df, [input_column])
|
938
|
+
|
939
|
+
def process_text(text):
|
940
|
+
if lower_case:
|
941
|
+
text = text.lower()
|
942
|
+
if remove_html:
|
943
|
+
text = strip_tags(text)
|
944
|
+
|
945
|
+
if normalize_whitespace:
|
946
|
+
text = strip_multiple_whitespaces(text)
|
947
|
+
|
948
|
+
if remove_numbers:
|
949
|
+
text = strip_numeric(text)
|
950
|
+
|
951
|
+
if remove_stopwords:
|
952
|
+
text = remove_stopwords(text)
|
953
|
+
|
954
|
+
if remove_punctuation:
|
955
|
+
if keep_sentence_punctuation:
|
956
|
+
# Remove all punctuation except .,!?'" and apostrophes
|
957
|
+
text = re.sub(r"[^\w\s.,!?'\"]", "", text)
|
958
|
+
# Remove spaces before punctuation, but not before apostrophes
|
959
|
+
text = re.sub(r"\s([.,!?\"](?:\s|$))", r"\1", text)
|
960
|
+
else:
|
961
|
+
# Remove all punctuation except apostrophes
|
962
|
+
text = re.sub(r"[^\w\s']", "", text)
|
963
|
+
|
964
|
+
text = text.strip()
|
965
|
+
|
966
|
+
if max_comment_length:
|
967
|
+
text = text[:max_comment_length]
|
968
|
+
|
969
|
+
return text
|
970
|
+
|
971
|
+
# Apply processing
|
972
|
+
masked_df[output_column] = masked_df[input_column].apply(process_text)
|
973
|
+
|
974
|
+
columns_to_combine = [output_column]
|
975
|
+
|
976
|
+
if flag_short_comments:
|
977
|
+
short_comment_col = f"{output_column}_is_short"
|
978
|
+
masked_df[short_comment_col] = (
|
979
|
+
masked_df[output_column].str.len() < min_comment_length
|
980
|
+
)
|
981
|
+
columns_to_combine.append(short_comment_col)
|
982
|
+
|
983
|
+
if max_comment_length:
|
984
|
+
truncated_col = f"{output_column}_was_truncated"
|
985
|
+
masked_df[truncated_col] = (
|
986
|
+
masked_df[input_column].str.len() > max_comment_length
|
987
|
+
)
|
988
|
+
columns_to_combine.append(truncated_col)
|
989
|
+
|
990
|
+
if comment_length_column:
|
991
|
+
masked_df[comment_length_column] = masked_df[output_column].str.len()
|
992
|
+
columns_to_combine.append(comment_length_column)
|
993
|
+
|
994
|
+
# Combine results
|
995
|
+
df_to_return = combine_results(df, masked_df, mask, columns_to_combine)
|
996
|
+
|
997
|
+
return df_to_return
|