datamatters24 commited on
Commit
eb3d640
·
verified ·
1 Parent(s): fc3a6a0

Upload ml/06_extract_keywords.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. ml/06_extract_keywords.py +185 -0
ml/06_extract_keywords.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Phase 3: TF-IDF Keyword Extraction
4
+
5
+ Extracts top keywords per document using TF-IDF vectorization.
6
+ Processes in collection-level batches to get meaningful IDF weights.
7
+ Stores results in document_keywords table.
8
+
9
+ Runs on: Hetzner CPU (scikit-learn)
10
+ """
11
+
12
+ import logging
13
+ import sys
14
+ from collections import defaultdict
15
+
16
+ import numpy as np
17
+ from sklearn.feature_extraction.text import TfidfVectorizer
18
+
19
+ import psycopg2.extras
20
+ from db import get_conn
21
+
22
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s")
23
+ log = logging.getLogger(__name__)
24
+
25
+ TOP_K = 15 # keywords per document
26
+ BATCH_SIZE = 5000 # docs per TF-IDF batch
27
+ MIN_DF = 3 # minimum document frequency
28
+ MAX_DF = 0.85 # max document frequency (fraction)
29
+ INSERT_BATCH = 1000
30
+
31
+
32
+ def get_collections(conn):
33
+ with conn.cursor() as cur:
34
+ cur.execute("SELECT DISTINCT source_section FROM documents ORDER BY source_section")
35
+ return [r[0] for r in cur.fetchall()]
36
+
37
+
38
+ def get_doc_texts(conn, collection, offset, limit):
39
+ """Get concatenated OCR text (first 5 pages) for a batch of documents."""
40
+ with conn.cursor() as cur:
41
+ cur.execute("""
42
+ SELECT d.id, string_agg(p.ocr_text, ' ' ORDER BY p.page_number)
43
+ FROM documents d
44
+ JOIN pages p ON p.document_id = d.id AND p.page_number <= 5
45
+ WHERE d.source_section = %s
46
+ AND d.id NOT IN (SELECT DISTINCT document_id FROM document_keywords)
47
+ GROUP BY d.id
48
+ ORDER BY d.id
49
+ OFFSET %s LIMIT %s
50
+ """, (collection, offset, limit))
51
+ return cur.fetchall()
52
+
53
+
54
+ def extract_keywords_batch(doc_ids, texts):
55
+ """Run TF-IDF on a batch and return top keywords per document."""
56
+ if not texts:
57
+ return {}
58
+
59
+ vectorizer = TfidfVectorizer(
60
+ max_features=10000,
61
+ min_df=min(MIN_DF, max(1, len(texts) // 10)),
62
+ max_df=MAX_DF,
63
+ stop_words='english',
64
+ ngram_range=(1, 2),
65
+ token_pattern=r'(?u)\b[a-zA-Z][a-zA-Z]{2,}\b', # 3+ letter words only
66
+ sublinear_tf=True,
67
+ )
68
+
69
+ try:
70
+ tfidf_matrix = vectorizer.fit_transform(texts)
71
+ except ValueError as e:
72
+ log.warning(f"TF-IDF failed for batch: {e}")
73
+ return {}
74
+
75
+ feature_names = vectorizer.get_feature_names_out()
76
+ results = {}
77
+
78
+ for i, doc_id in enumerate(doc_ids):
79
+ row = tfidf_matrix[i].toarray().flatten()
80
+ top_indices = row.argsort()[-TOP_K:][::-1]
81
+ keywords = []
82
+ for idx in top_indices:
83
+ score = row[idx]
84
+ if score > 0:
85
+ keywords.append((feature_names[idx], float(score)))
86
+ results[doc_id] = keywords
87
+
88
+ return results
89
+
90
+
91
+ def flush_keywords(conn, rows):
92
+ with conn.cursor() as cur:
93
+ psycopg2.extras.execute_batch(
94
+ cur,
95
+ """INSERT INTO document_keywords (document_id, keyword, score, method)
96
+ VALUES (%s, %s, %s, 'tfidf')
97
+ ON CONFLICT DO NOTHING""",
98
+ rows,
99
+ page_size=1000,
100
+ )
101
+ conn.commit()
102
+
103
+
104
+ def main():
105
+ conn = get_conn()
106
+ collections = get_collections(conn)
107
+
108
+ total_docs = 0
109
+ total_keywords = 0
110
+
111
+ for collection in collections:
112
+ # Count remaining docs for this collection
113
+ with conn.cursor() as cur:
114
+ cur.execute("""
115
+ SELECT COUNT(DISTINCT d.id) FROM documents d
116
+ JOIN pages p ON p.document_id = d.id AND p.page_number <= 5
117
+ WHERE d.source_section = %s
118
+ AND d.id NOT IN (SELECT DISTINCT document_id FROM document_keywords)
119
+ """, (collection,))
120
+ remaining = cur.fetchone()[0]
121
+
122
+ if remaining == 0:
123
+ log.info(f" {collection}: already done, skipping")
124
+ continue
125
+
126
+ log.info(f"Processing {collection}: {remaining} documents")
127
+ offset = 0
128
+ insert_buffer = []
129
+
130
+ while True:
131
+ rows = get_doc_texts(conn, collection, 0, BATCH_SIZE) # offset=0 since we exclude already-done
132
+ if not rows:
133
+ break
134
+
135
+ doc_ids = [r[0] for r in rows]
136
+ texts = [r[1] or '' for r in rows]
137
+
138
+ # Mark all docs as processed (even short/failed ones) with a placeholder
139
+ skip_ids = []
140
+
141
+ # Filter out very short texts
142
+ valid = [(did, txt) for did, txt in zip(doc_ids, texts) if len(txt.strip()) > 50]
143
+ short_ids = [did for did, txt in zip(doc_ids, texts) if len(txt.strip()) <= 50]
144
+ skip_ids.extend(short_ids)
145
+
146
+ if valid:
147
+ v_ids, v_texts = zip(*valid)
148
+ keywords = extract_keywords_batch(list(v_ids), list(v_texts))
149
+
150
+ if not keywords:
151
+ # TF-IDF failed for entire batch
152
+ skip_ids.extend(list(v_ids))
153
+ else:
154
+ for doc_id, kws in keywords.items():
155
+ if kws:
156
+ for kw, score in kws:
157
+ insert_buffer.append((doc_id, kw, score))
158
+ else:
159
+ skip_ids.append(doc_id)
160
+ else:
161
+ skip_ids.extend(doc_ids)
162
+
163
+ # Insert placeholder for skipped docs so they don't get re-fetched
164
+ if skip_ids:
165
+ placeholder_rows = [(did, '_no_keywords_', 0.0) for did in skip_ids]
166
+ flush_keywords(conn, placeholder_rows)
167
+
168
+ if len(insert_buffer) >= INSERT_BATCH:
169
+ flush_keywords(conn, insert_buffer)
170
+ total_keywords += len(insert_buffer)
171
+ insert_buffer = []
172
+
173
+ total_docs += len(rows)
174
+ log.info(f" {collection}: {total_docs} docs processed, {total_keywords} keywords stored (skipped {len(skip_ids)})")
175
+
176
+ if insert_buffer:
177
+ flush_keywords(conn, insert_buffer)
178
+ total_keywords += len(insert_buffer)
179
+
180
+ conn.close()
181
+ log.info(f"Done. {total_docs} documents, {total_keywords} keywords extracted.")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()