File size: 7,898 Bytes
399944f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from pydoc_data import topics
from typing import List, Optional, Tuple, Literal, TypedDict

from bertopic import BERTopic

from sklearn.feature_extraction.text import CountVectorizer
from .logging import save_topic_modeling_results


@dataclass(frozen=True)
class TopicModelConfig:
    """
    Configuration for topic modeling with BERTopic.

    Parameters
    ----------
    embedding_model: str or model
        The sentence transformer or embedding model used to vectorize texts.

    min_topic_size: int
        Minimum size of a topic. Used by HDBSCAN to avoid very small, noisy clusters.

    top_n_words: int
        Number of representative keywords to extract per topic.

    language: str
        Language used by CountVectorizer for stopword removal.

    seed: int
        Random seed for reproducibility.
    """
    # embedding_model: str = "all-MiniLM-L6-v2" # dimension: 384
    embedding_model: str = "all-mpnet-base-v2" # dimension: 768
    min_topic_size: int = 10
    top_n_words: int = 16
    language: str = "english"
    seed: int = 42


class TopicResult(TypedDict):
    topic_id: int
    keyword_frequency: float
    keywords: List[str]
    paragraph_indices: List[int]
    matched: Literal["exact", "synonym", "none"]


def extract_topics_from_paragraphs(
    paragraphs: List[str],
    keyword: str,
    synonyms: Optional[List[str]] = None,
    config: Optional[TopicModelConfig] = None,
) -> Tuple[
        List[TopicResult], 
        BERTopic
    ]:
    """
    Run BERTopic over the provided paragraphs and detect topics matching a given keyword.

    Parameters
    ----------
    paragraphs:
        List of clean paragraph strings to analyze.

    keyword:
        The user-selected topic of interest (e.g., "explainability").

    synonyms:
        Optional backup list of synonyms (used if exact keyword fails).

    config:
        Topic modeling hyperparameters. Defaults to sensible values.

    Returns
    -------
    - topic_matches: List of topic match metadata (see TopicResult).
    - model: Trained BERTopic model for further inspection/plotting.
    """
    cfg = config or TopicModelConfig()

    # Step 1: Instantiate BERTopic with configuration

    # Custom vectorizer with stopword removal
    vectorizer_model = CountVectorizer(stop_words=cfg.language)
    topic_model = BERTopic(
        embedding_model=cfg.embedding_model,
        vectorizer_model=vectorizer_model,
        top_n_words=cfg.top_n_words,
        language=cfg.language,
        calculate_probabilities=True,
        verbose=True,
        seed_topic_list=None,
    )

    # Step 2: Fit the model to input paragraphs
    topics, _probs = topic_model.fit_transform(paragraphs)
    # topics is List[int] of topic IDs per paragraph
    # propbs is List[List[float]] of topic probabilities per paragraph

    # cast the topics to a list of ints
    topics = list(map(int, topics))

    # Access the frequent topics that were generated:
    topic_info_df = topic_model.get_topic_info()
    # e.g.
    """
    >>> topic_info_df
    Topic   Count   Name
    -1      4630    -1_can_your_will_any
    0       693     49_windows_drive_dos_file
    1       466     32_jesus_bible_christian_faith
    2       441     2_space_launch_orbit_lunar
    3       381     22_key_encryption_keys_encrypted
    ...
    """

    # Step 3: Collect match info for all topics
    topic_matches: List[TopicResult] = []
    keyword_lower = keyword.lower()
    synonym_set = set(s.lower() for s in synonyms) if synonyms else set()

    matched_topic_ids: List[int] = [] # e.g. [3, 7]
    matched_synonyms: dict[int, set[str]] = {} # e.g. {7: {"explainable AI"}}
    match_type_by_topic: dict[int, str] = {} # e.g. {3: "exact", 7: "synonym"}


    for topic_id in topic_info_df["Topic"]:
        if topic_id == -1:
            # -1 is outlier cluster in HDBSCAN
            # Topic -1 refers to all outliers and should typically be ignored.
            continue

        # Get topic keywords and lowercased form for matching
        """
        >>> topic_model.get_topic(topic_id) # topic_id is an int
        BERTopic’s get_topic method returns the top-N highest TF-IDF keywords per topic.

        [('windows', 0.006152228076250982),
        ('drive', 0.004982897610645755),
        ('dos', 0.004845038866360651),
        ('file', 0.004140142872194834),
        ('disk', 0.004131678774810884),
        ('mac', 0.003624848635985097),
        ('memory', 0.0034840976976789903),
        ('software', 0.0034415334250699077),
        ('email', 0.0034239554442333257),
        ('pc', 0.003047105930670237)]
        """
        topic_words = [w for w, w_prob in topic_model.get_topic(topic_id)]
        topic_words_lower = [w.lower() for w in topic_words]

        match_type: Literal["exact", "synonym", "none"]
        # Matching logic
        if keyword_lower in topic_words_lower:
            # 💪 The user-chosen keyword was mentioned in the topic's keywords list
            match_type = "exact"
            matched_topic_ids.append(topic_id)
        else:
            # The exact keyword was not found, check synonyms if available
            intersecting = synonym_set.intersection(topic_words_lower)
            if intersecting:
                match_type = "synonym"
                matched_topic_ids.append(topic_id)
                # matched_synonyms[topic_id] = list(intersecting)[0] # store one matched synonym
                matched_synonyms[topic_id] = intersecting
            else:
                continue # skip the topic if no match

        match_type_by_topic[topic_id] = match_type

        # Find all paragraphs belonging to this topic
        paragraph_indices = [i for i, t in enumerate(topics) if t == topic_id]

        # Compute how often the user keyword appears in the topic's top keywords.
        # Usually 0 or 1, since BERTopic keywords are typically unique, but may repeat in rare cases.
        keyword_frequency = topic_words_lower.count(keyword_lower)

        topic_matches.append(
            TopicResult(
                topic_id=topic_id,
                keyword_frequency=keyword_frequency,
                keywords=topic_words,
                paragraph_indices=paragraph_indices,
                matched=match_type,
            )
        )

    # # Using .get_document_info, we can also extract information on a document level, 
    # # such as their corresponding topics, probabilities, whether they are representative documents for a topic, etc.   
    # """
    # >>> topic_model.get_document_info(docs)

    # Document                               Topic    Name                        Top_n_words                     Probability    ...
    # I am sure some bashers of Pens...       0       0_game_team_games_season    game - team - games...          0.200010       ...
    # My brother is in the market for...      -1     -1_can_your_will_any         can - your - will...            0.420668       ...
    # Finally you said what you dream...      -1     -1_can_your_will_any         can - your - will...            0.807259       ...
    # Think! It is the SCSI card doing...     49     49_windows_drive_dos_file    windows - drive - docs...       0.071746       ...
    # 1) I have an old Jasmine drive...       49     49_windows_drive_dos_file    windows - drive - docs...       0.038983       ...
    # """
    # topic_model.get_topic_info()

    # Save logs for this topic modeling run
    save_topic_modeling_results(
        topic_model=topic_model,
        documents=paragraphs,
        # document_chunks=...,
        keyword=keyword,
        matched_topic_ids=matched_topic_ids,
        match_type_by_topic=match_type_by_topic,
        matched_synonyms=matched_synonyms,
        generated_synonyms=synonyms,
    )

    return topic_matches, topic_model