Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
181 lines (148 loc) · 7.22 KB
/
Copy pathapp.py
File metadata and controls
181 lines (148 loc) · 7.22 KB
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
"""Flask web interface. python app.py -> http://127.0.0.1:5000"""
import csv
import io
import math
import os
import uuid
import numpy as np
from flask import Flask, jsonify, render_template, request
from werkzeug.exceptions import RequestEntityTooLarge
from gtopic import GraphTopicModel, Preprocessor
from gtopic.corpus import load_corpus
from gtopic.evaluate import doc_term_binary, evaluate
from gtopic.visualize import network_figure, topic_bars, to_b64
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024
MIN_DOCUMENTS = 20
MAX_DOCUMENTS = 12_000
SAMPLE = os.path.join(os.path.dirname(__file__), "data", "news_corpus.csv")
MODELS: dict[str, GraphTopicModel] = {} # token -> fitted model (in-memory cache)
def parse_parameters(values):
def integer(name, default):
value = values.get(name, default)
if isinstance(value, bool):
raise ValueError
parsed = int(value)
if isinstance(value, float) and value != parsed:
raise ValueError
return parsed
def decimal(name, default):
value = values.get(name, default)
if isinstance(value, bool):
raise ValueError
parsed = float(value)
if not math.isfinite(parsed):
raise ValueError
return parsed
try:
window = integer("window", 8)
resolution = decimal("resolution", 1.0)
topn = integer("topn", 10)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError("Parameters must be valid numbers.") from exc
if not 2 <= window <= 40:
raise ValueError("Window must be between 2 and 40.")
if not 0.1 <= resolution <= 5:
raise ValueError("Resolution must be between 0.1 and 5.")
if not 5 <= topn <= 20:
raise ValueError("Top words must be between 5 and 20.")
return window, resolution, topn
def input_error(message):
return render_template("index.html", sample_exists=os.path.exists(SAMPLE), error=message), 400
def parse_upload(f, text_col="text"):
raw = f.read().decode("utf-8", errors="ignore")
if f.filename.lower().endswith(".csv"):
rows = list(csv.DictReader(io.StringIO(raw)))
if text_col not in (rows[0] if rows else {}):
text_col = next(iter(rows[0]), "text") if rows else "text"
return [r[text_col] for r in rows if r.get(text_col)]
return split_docs(raw)
def split_docs(raw):
docs = [d.strip() for d in raw.split("\n\n") if len(d.split()) >= 5]
if len(docs) < 20: # fall back to one document per line
docs = [d.strip() for d in raw.splitlines() if len(d.split()) >= 5]
return docs
def fit_model(texts, window, resolution, topn):
small = len(texts) < 500
pre = Preprocessor(min_df=2 if small else 5, phrase_min_count=5 if small else 15)
m = GraphTopicModel(window=window, resolution=resolution, topn_words=topn, preprocessor=pre,
min_count=2 if small else 3, min_topic_size=5 if small else 10).fit(texts)
return m
@app.route("/")
def index():
return render_template("index.html", sample_exists=os.path.exists(SAMPLE))
@app.route("/analyze", methods=["POST"])
def analyze():
f = request.form
try:
window, res, topn = parse_parameters(f)
except ValueError as exc:
return input_error(str(exc))
labels = None
if f.get("source") == "sample":
texts, labels = load_corpus(SAMPLE)
elif request.files.get("file") and request.files["file"].filename:
texts = parse_upload(request.files["file"], f.get("text_col", "text") or "text")
else:
texts = split_docs(f.get("text", ""))
if len(texts) > MAX_DOCUMENTS:
return input_error(f"Please provide no more than {MAX_DOCUMENTS:,} documents per analysis.")
if len(texts) < MIN_DOCUMENTS:
return input_error("Please provide at least 20 documents (separate documents with a blank line).")
m = fit_model(texts, window, res, topn)
if not m.communities:
return input_error("No topics could be extracted - try a larger corpus or a lower resolution.")
docs = m.pre.transform(texts); P = m.doc_topic_matrix(docs); pred = P.argmax(1)
ids = {w: i for i, w in enumerate(m.id2word)}
ev = evaluate([[ids[w] for w in ws] for ws in m.top_words()], doc_term_binary(docs, len(m.id2word)),
np.array(labels) if labels else None, pred if labels else None)
tokens = uuid.uuid4().hex[:10]; MODELS[tokens] = m
if len(MODELS) > 5: MODELS.pop(next(iter(MODELS)))
topics = [{**t, "docs": int((pred == t["id"]).sum()), "npmi": round(ev["per_topic_npmi"][t["id"]], 3)} for t in m.topics_]
return render_template("results.html", token=tokens, n_docs=len(texts), s=m.summary(), ev=ev, topics=topics,
net=to_b64(network_figure(m, ncol=3, fontsize=8, width_in=12)), bars=to_b64(topic_bars(m, ncols=4)),
has_labels=labels is not None, bridge=m.bridge_words(8))
@app.route("/infer/<token>", methods=["POST"])
def infer(token):
m = MODELS.get(token)
if m is None:
return jsonify(error="Session expired - re-run the analysis."), 404
body = request.get_json(silent=True)
if not isinstance(body, dict) or not isinstance(body.get("text"), str):
return jsonify(error="Send a JSON object with a text string."), 400
text = body["text"]
if len(m.pre.transform([text])[0]) == 0:
return jsonify(error="No known vocabulary words in this text."), 400
p = m.transform([text])[0]
order = np.argsort(-p)[:5]
return jsonify(topics=[{"id": int(i), "label": m.topics_[i]["label"].replace("_", " "), "p": round(float(p[i]), 3)} for i in order])
@app.route("/api/analyze", methods=["POST"])
def api_analyze():
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify(error="Send a valid JSON object."), 400
texts = body.get("documents")
if not isinstance(texts, list) or any(not isinstance(text, str) for text in texts):
return jsonify(error="'documents' must be a list of strings."), 400
if len(texts) > MAX_DOCUMENTS:
return jsonify(error=f"Send no more than {MAX_DOCUMENTS:,} documents."), 400
texts = [text.strip() for text in texts if text.strip()]
if len(texts) < MIN_DOCUMENTS:
return jsonify(error=f"Send at least {MIN_DOCUMENTS} non-empty documents."), 400
try:
window, resolution, topn = parse_parameters(body)
except ValueError as exc:
return jsonify(error=str(exc)), 400
m = fit_model(texts, window, resolution, topn)
if not m.communities:
return jsonify(error="No topics could be extracted from these documents."), 400
return jsonify(summary=m.summary(), topics=[{"id": t["id"], "words": [w for w, _ in t["words"]]} for t in m.topics_])
@app.errorhandler(RequestEntityTooLarge)
def request_too_large(_error):
message = "Request is too large (maximum 25 MB)."
if request.path.startswith(("/api/", "/infer/")):
return jsonify(error=message), 413
return render_template("index.html", sample_exists=os.path.exists(SAMPLE), error=message), 413
if __name__ == "__main__":
from waitress import serve
serve(app, host=os.environ.get("HOST", "127.0.0.1"), port=int(os.environ.get("PORT", "5000")))