File size: 9,128 Bytes
8ff1b66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
"""
Text preprocessing with n-gram detection using gensim.Phrases.

Pipeline:
1. Tokenization (jieba for Chinese, regex for English/mixed)
2. Build Phrases models (bigrams, trigrams)
3. Apply frozen n-grams from existing dictionary
4. Apply detected phrases

This ensures that multi-word concepts like "帧率" or "加载画面"
are treated as single tokens during FastText training.

For Chinese text:
- Uses jieba for word segmentation (Chinese has no spaces)
- Keeps English words intact (common in gaming reviews: fps, bug, dlc)
- Removes punctuation but preserves Chinese characters
"""

import logging
import pickle
import re
from collections import Counter
from pathlib import Path

import jieba
from gensim.models import Phrases
from gensim.models.phrases import Phraser

from .config import MODELS_DIR, SETTINGS

logger = logging.getLogger(__name__)


class Preprocessor:
    """
    Text preprocessor with n-gram detection.

    Uses gensim Phrases for automatic phrase detection plus
    frozen n-grams from the existing keyword dictionary.
    """

    def __init__(self, existing_ngrams: list[str] | None = None):
        """
        Initialize preprocessor.

        Args:
            existing_ngrams: Multi-word phrases from existing keywords.py
                            (e.g., "frame rate", "loading screen")
        """
        self.frozen_ngrams: set[tuple[str, ...]] = set()
        if existing_ngrams:
            self.frozen_ngrams = self._normalize_ngrams(existing_ngrams)
            logger.info(f"Loaded {len(self.frozen_ngrams)} frozen n-grams")

        self.bigram_model: Phraser | None = None
        self.trigram_model: Phraser | None = None
        self.word_frequencies: Counter = Counter()

    def _normalize_ngrams(self, ngrams: list[str]) -> set[tuple[str, ...]]:
        """Convert n-grams to lowercase tuple format for fast lookup."""
        result = set()
        for ng in ngrams:
            if " " in ng:
                tokens = tuple(ng.lower().split())
                result.add(tokens)
        return result

    def tokenize(self, text: str) -> list[str]:
        """
        Tokenization for Chinese/mixed text using jieba.

        - Uses jieba for Chinese word segmentation
        - Keeps English words intact (common in gaming: fps, bug, dlc)
        - Removes punctuation (both Chinese and English)
        - Lowercases English text
        """
        # Remove URLs
        text = re.sub(r'https?://\S+', ' ', text)

        # Remove punctuation (Chinese and English) but keep Chinese chars and alphanumeric
        # Chinese punctuation: 。!?,、;:""''()【】《》
        text = re.sub(r'[^\u4e00-\u9fff\u3400-\u4dbfa-zA-Z0-9\s]', ' ', text)

        # Lowercase English text
        text = text.lower()

        # Use jieba to segment Chinese text
        # jieba handles mixed Chinese/English text well
        tokens = list(jieba.cut(text))

        # Filter: remove empty strings and single spaces
        tokens = [t.strip() for t in tokens if t.strip()]

        return tokens

    def build_phrase_models(
        self,
        corpus: list[list[str]],
        min_count: int | None = None,
        threshold: float | None = None,
    ) -> None:
        """
        Build Phrases models for automatic n-gram detection.

        Args:
            corpus: List of tokenized documents
            min_count: Minimum phrase occurrences (default from settings)
            threshold: Scoring threshold (higher = fewer phrases)
        """
        min_count = min_count or SETTINGS["phrase_min_count"]
        threshold = threshold or SETTINGS["phrase_threshold"]

        logger.info(f"Building phrase models (min_count={min_count}, threshold={threshold})")

        # Build bigram model: "frame rate" -> "frame_rate"
        bigram_phrases = Phrases(
            corpus,
            min_count=min_count,
            threshold=threshold,
            delimiter="_",
        )
        self.bigram_model = Phraser(bigram_phrases)

        # Apply bigramy to create input for trigram detection
        bigram_corpus = [self.bigram_model[doc] for doc in corpus]

        # Build trigram model: "dark_souls like" -> "dark_souls_like"
        trigram_phrases = Phrases(
            bigram_corpus,
            min_count=min_count,
            threshold=threshold,
            delimiter="_",
        )
        self.trigram_model = Phraser(trigram_phrases)

        # Log detected phrases
        bigram_count = len(bigram_phrases.export_phrases())
        trigram_count = len(trigram_phrases.export_phrases())
        logger.info(f"Detected {bigram_count} bigrams, {trigram_count} trigrams")

    def _apply_frozen_ngrams(self, tokens: list[str]) -> list[str]:
        """
        Apply frozen n-grams from existing dictionary.

        These are always joined, even if not detected by Phrases.
        """
        result = []
        i = 0

        while i < len(tokens):
            matched = False

            # Try trigrams first (longer matches preferred)
            if i + 2 < len(tokens):
                trigram = (tokens[i], tokens[i + 1], tokens[i + 2])
                if trigram in self.frozen_ngrams:
                    result.append("_".join(trigram))
                    i += 3
                    matched = True

            # Try bigrams
            if not matched and i + 1 < len(tokens):
                bigram = (tokens[i], tokens[i + 1])
                if bigram in self.frozen_ngrams:
                    result.append("_".join(bigram))
                    i += 2
                    matched = True

            if not matched:
                result.append(tokens[i])
                i += 1

        return result

    def apply_phrases(self, tokens: list[str]) -> list[str]:
        """
        Apply phrase models and frozen n-grams to tokens.

        Order:
        1. Frozen n-grams (from existing dictionary)
        2. Automatic Phrases (bigrams then trigrams)
        """
        # Apply frozen n-grams first
        tokens = self._apply_frozen_ngrams(tokens)

        # Apply automatic phrase models
        if self.bigram_model:
            tokens = list(self.bigram_model[tokens])
        if self.trigram_model:
            tokens = list(self.trigram_model[tokens])

        return tokens

    def preprocess_corpus(
        self,
        reviews: list[str],
        build_phrases: bool = True,
    ) -> list[list[str]]:
        """
        Full preprocessing pipeline.

        Args:
            reviews: Raw review texts
            build_phrases: Whether to build phrase models (skip if loading)

        Returns:
            List of tokenized documents with phrases applied
        """
        logger.info(f"Preprocessing {len(reviews)} reviews...")

        # Step 1: Tokenize all reviews
        tokenized = [self.tokenize(review) for review in reviews]
        logger.info("Tokenization complete")

        # Step 2: Build phrase models
        if build_phrases:
            self.build_phrase_models(tokenized)

        # Step 3: Apply phrases and count frequencies
        processed = []
        for tokens in tokenized:
            phrased = self.apply_phrases(tokens)
            processed.append(phrased)
            self.word_frequencies.update(phrased)

        logger.info(f"Vocabulary size: {len(self.word_frequencies)}")
        return processed

    def get_word_frequencies(self) -> dict[str, int]:
        """Get word frequency dictionary."""
        return dict(self.word_frequencies)

    def save(self, path: Path | None = None) -> None:
        """Save preprocessor state (phrase models, frequencies)."""
        path = path or MODELS_DIR / "preprocessor.pkl"

        data = {
            "frozen_ngrams": self.frozen_ngrams,
            "bigram_model": self.bigram_model,
            "trigram_model": self.trigram_model,
            "word_frequencies": self.word_frequencies,
        }

        with open(path, "wb") as f:
            pickle.dump(data, f)

        logger.info(f"Saved preprocessor to {path}")

    def load(self, path: Path | None = None) -> None:
        """Load preprocessor state."""
        path = path or MODELS_DIR / "preprocessor.pkl"

        if not path.exists():
            raise FileNotFoundError(f"Preprocessor not found at {path}")

        with open(path, "rb") as f:
            data = pickle.load(f)

        self.frozen_ngrams = data["frozen_ngrams"]
        self.bigram_model = data["bigram_model"]
        self.trigram_model = data["trigram_model"]
        self.word_frequencies = data["word_frequencies"]

        logger.info(f"Loaded preprocessor from {path}")


def extract_ngrams_from_keywords(keywords: dict[str, list[str]]) -> list[str]:
    """
    Extract multi-word phrases from keywords dictionary.

    Args:
        keywords: TOPIC_KEYWORDS dictionary from keywords.py

    Returns:
        List of multi-word phrases (e.g., ["frame rate", "loading screen"])
    """
    ngrams = []
    for category_words in keywords.values():
        for word in category_words:
            if " " in word:
                ngrams.append(word)
    return ngrams