pryyyynz commited on
Commit
66d7c1e
·
verified ·
1 Parent(s): 37a83c9

Basic files, no model

Browse files
Files changed (3) hide show
  1. app.py +414 -0
  2. explainability.py +95 -0
  3. requirements.txt +11 -0
app.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone Gradio dashboard for contract classification with LIME explanations.
3
+
4
+ Features:
5
+ - Upload single or multiple documents (PDF, DOCX, DOC, TXT)
6
+ - Show prediction and confidence
7
+ - Show class probability chart
8
+ - Highlight influential text via LIME HTML
9
+ - Download CSV for batch results
10
+
11
+ This app loads the same enhanced TF-IDF model used by the API if available.
12
+ For a fully standalone setup, place the model file under web/models/.
13
+ """
14
+
15
+ import os
16
+ import io
17
+ import csv
18
+ import tempfile
19
+ import shutil
20
+ import logging
21
+ from typing import List, Dict, Any, Tuple
22
+ import mimetypes
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ # Document processing deps
28
+ import pdfplumber
29
+ from docx import Document as DocxDocument
30
+ from PIL import Image
31
+ import pytesseract
32
+
33
+ # Optional OCR PDF rasterization
34
+ try:
35
+ import fitz # PyMuPDF
36
+ PYMUPDF_AVAILABLE = True
37
+ except Exception:
38
+ PYMUPDF_AVAILABLE = False
39
+
40
+ import gradio as gr
41
+
42
+ from explainability import ContractExplainer
43
+ import pickle
44
+
45
+
46
+ logging.basicConfig(level=logging.INFO)
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # ------------------------------
51
+ # Model loading
52
+ # ------------------------------
53
+
54
+ MODEL = None
55
+ VECTORIZER = None
56
+ CLASS_NAMES: List[str] = []
57
+ FEATURE_SELECTOR = None
58
+ EXPLAINER: ContractExplainer | None = None
59
+
60
+
61
+ def _candidate_model_paths() -> List[str]:
62
+ return [
63
+ os.path.join(os.path.dirname(__file__), "models",
64
+ "enhanced_tfidf_gradient_boosting_model.pkl"),
65
+ os.path.join(os.path.dirname(__file__), "..", "enhanced_models_output",
66
+ "models", "enhanced_tfidf_gradient_boosting_model.pkl"),
67
+ os.path.join(os.path.dirname(__file__), "..",
68
+ "models_output", "models", "random_forest_model.pkl"),
69
+ ]
70
+
71
+
72
+ def load_model_if_needed() -> Tuple[bool, str]:
73
+ global MODEL, VECTORIZER, CLASS_NAMES, FEATURE_SELECTOR, EXPLAINER
74
+ if EXPLAINER is not None:
75
+ return True, "Model already loaded"
76
+
77
+ last_error = ""
78
+ for path in _candidate_model_paths():
79
+ try:
80
+ if not os.path.exists(path):
81
+ continue
82
+ with open(path, "rb") as f:
83
+ data = pickle.load(f)
84
+ MODEL = data["classifier"]
85
+ VECTORIZER = data["vectorizer"]
86
+ CLASS_NAMES = data["class_names"]
87
+ FEATURE_SELECTOR = data.get("feature_selector")
88
+ EXPLAINER = ContractExplainer(
89
+ MODEL, VECTORIZER, CLASS_NAMES, FEATURE_SELECTOR)
90
+ logger.info(f"Loaded model from: {path}")
91
+ return True, f"Loaded model: {os.path.basename(path)}"
92
+ except Exception as e:
93
+ last_error = str(e)
94
+ logger.exception("Failed loading model")
95
+ return False, last_error or "Model file not found. Place model under web/models/."
96
+
97
+
98
+ # ------------------------------
99
+ # Text extraction
100
+ # ------------------------------
101
+
102
+ def extract_text_from_pdf(file_path: str) -> str:
103
+ text = ""
104
+ try:
105
+ with pdfplumber.open(file_path) as pdf:
106
+ for page in pdf.pages:
107
+ page_text = page.extract_text()
108
+ if page_text:
109
+ text += page_text + "\n"
110
+ except Exception as e:
111
+ logger.warning(f"pdfplumber failed: {e}")
112
+
113
+ if text.strip():
114
+ return text.strip()
115
+
116
+ # OCR fallback
117
+ if not PYMUPDF_AVAILABLE:
118
+ return text.strip()
119
+ try:
120
+ doc = fitz.open(file_path)
121
+ for page_index in range(len(doc)):
122
+ page = doc.load_page(page_index)
123
+ pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
124
+ img = Image.open(io.BytesIO(pix.tobytes("png")))
125
+ text += pytesseract.image_to_string(img, lang="eng") + "\n"
126
+ doc.close()
127
+ except Exception as e:
128
+ logger.warning(f"OCR fallback failed: {e}")
129
+ return text.strip()
130
+
131
+
132
+ def extract_text_from_docx(file_path: str) -> str:
133
+ try:
134
+ doc = DocxDocument(file_path)
135
+ return "\n".join(p.text for p in doc.paragraphs).strip()
136
+ except Exception as e:
137
+ logger.warning(f"DOCX extraction failed: {e}")
138
+ return ""
139
+
140
+
141
+ def extract_text_from_doc(file_path: str) -> str:
142
+ # Best-effort: try antiword
143
+ try:
144
+ import subprocess
145
+ result = subprocess.run(["antiword", file_path],
146
+ capture_output=True, text=True)
147
+ if result.returncode == 0:
148
+ return result.stdout.strip()
149
+ except Exception:
150
+ pass
151
+ return ""
152
+
153
+
154
+ def preprocess_text(text: str) -> str:
155
+ if not text:
156
+ raise ValueError("Empty text")
157
+ text = text.strip()
158
+ text = " ".join(text.split())
159
+ if len(text) < 10:
160
+ raise ValueError("Text too short for classification")
161
+ return text
162
+
163
+
164
+ # ------------------------------
165
+ # Inference and explanation
166
+ # ------------------------------
167
+
168
+ def classify_text(text: str, num_features: int = 1) -> Dict[str, Any]:
169
+ ok, msg = load_model_if_needed()
170
+ if not ok:
171
+ raise RuntimeError(f"Model not available: {msg}")
172
+
173
+ text = preprocess_text(text)
174
+
175
+ explanation = EXPLAINER.explain_prediction(text, num_features=num_features)
176
+ if not explanation.get("success"):
177
+ raise RuntimeError(explanation.get("error", "Explanation failed"))
178
+
179
+ # Compute prediction using the same preprocessing as the model (no full probs for speed)
180
+ features = VECTORIZER.transform([text])
181
+ if FEATURE_SELECTOR is not None:
182
+ features = FEATURE_SELECTOR.transform(features)
183
+ probs = MODEL.predict_proba(features)[0]
184
+ # Align predicted class using model.classes_
185
+ model_classes = list(getattr(MODEL, "classes_", CLASS_NAMES))
186
+ predicted_index = int(np.argmax(probs))
187
+ explanation["prediction"] = model_classes[predicted_index]
188
+ explanation["confidence"] = float(probs[predicted_index])
189
+ return explanation
190
+
191
+
192
+ def classify_text_fast(text: str) -> Dict[str, Any]:
193
+ """Fast prediction without LIME (used for batch)."""
194
+ ok, msg = load_model_if_needed()
195
+ if not ok:
196
+ raise RuntimeError(f"Model not available: {msg}")
197
+ text = preprocess_text(text)
198
+ features = VECTORIZER.transform([text])
199
+ if FEATURE_SELECTOR is not None:
200
+ features = FEATURE_SELECTOR.transform(features)
201
+ probs = MODEL.predict_proba(features)[0]
202
+ # Use model-provided class order to avoid misalignment
203
+ model_classes = list(getattr(MODEL, "classes_", CLASS_NAMES))
204
+ predicted_index = int(np.argmax(probs))
205
+ predicted_class = model_classes[predicted_index]
206
+ confidence = float(probs[predicted_index])
207
+ return {
208
+ "prediction": predicted_class,
209
+ "confidence": confidence,
210
+ "class_probabilities": {cls: float(probs[i]) for i, cls in enumerate(model_classes)},
211
+ "text": text[:200] + "..." if len(text) > 200 else text,
212
+ }
213
+
214
+
215
+ def classify_file(tmp_path: str, mime_type: str, num_features: int = 10) -> Dict[str, Any]:
216
+ if mime_type == "application/pdf":
217
+ text = extract_text_from_pdf(tmp_path)
218
+ elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
219
+ text = extract_text_from_docx(tmp_path)
220
+ elif mime_type == "application/msword":
221
+ text = extract_text_from_doc(tmp_path)
222
+ else:
223
+ # Treat as plain text
224
+ with open(tmp_path, "r", encoding="utf-8", errors="ignore") as f:
225
+ text = f.read()
226
+ return classify_text(text, num_features=num_features)
227
+
228
+
229
+ def _extract_key_phrase_fast(text: str) -> str:
230
+ """Approximate influential phrase quickly using top TF-IDF term and context."""
231
+ try:
232
+ tokens = VECTORIZER.transform([text])
233
+ if hasattr(tokens, "toarray"):
234
+ arr = tokens.toarray()[0]
235
+ else:
236
+ arr = tokens.A[0]
237
+ if arr.sum() == 0:
238
+ return ""
239
+ top_idx = int(arr.argmax())
240
+ feature_names = getattr(VECTORIZER, "get_feature_names_out", None)
241
+ if feature_names is None:
242
+ return ""
243
+ feat = VECTORIZER.get_feature_names_out()[top_idx]
244
+ # Build phrase around first occurrence
245
+ words = text.split()
246
+ feat_lower = feat.lower()
247
+ for i, w in enumerate(words):
248
+ if feat_lower in w.lower():
249
+ start_idx = max(0, i - 2)
250
+ end_idx = min(len(words), i + 4)
251
+ phrase = " ".join(words[start_idx:end_idx]).strip(
252
+ '.,!?;:"()[]{}')
253
+ if len(phrase.split()) >= 3:
254
+ return phrase
255
+ # fallback to sentence-level
256
+ break
257
+ # fallback: first sentence
258
+ for sep in [". ", "\n", "? ", "! "]:
259
+ if sep in text:
260
+ return text.split(sep, 1)[0].strip()
261
+ return text[:120]
262
+ except Exception:
263
+ return ""
264
+
265
+
266
+ def classify_file_fast(tmp_path: str, mime_type: str) -> Dict[str, Any]:
267
+ if mime_type == "application/pdf":
268
+ text = extract_text_from_pdf(tmp_path)
269
+ elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
270
+ text = extract_text_from_docx(tmp_path)
271
+ elif mime_type == "application/msword":
272
+ text = extract_text_from_doc(tmp_path)
273
+ else:
274
+ with open(tmp_path, "r", encoding="utf-8", errors="ignore") as f:
275
+ text = f.read()
276
+ result = classify_text_fast(text)
277
+ # Add fast key phrase extraction
278
+ result["key_phrase"] = _extract_key_phrase_fast(text)
279
+ return result
280
+
281
+
282
+ # ------------------------------
283
+ # Gradio UI callbacks
284
+ # ------------------------------
285
+
286
+ def predict_single(file_path: str):
287
+ if not file_path:
288
+ return "No file uploaded", None, None, None
289
+
290
+ try:
291
+ mime, _ = mimetypes.guess_type(file_path)
292
+ mime = mime or "text/plain"
293
+ result = classify_file(file_path, mime, num_features=1)
294
+ pred = f"Prediction: {result['prediction']} (confidence: {result['confidence']:.3f})"
295
+
296
+ # One-line influential statement
297
+ top_feats = result.get("important_features", [])
298
+ key_phrase = top_feats[0][0] if top_feats else _extract_key_phrase_fast(
299
+ result.get("full_text", ""))
300
+ html = result.get("explanation_html", "")
301
+ key_line = key_phrase
302
+ return pred, html, key_line
303
+ except Exception as e:
304
+ return f"Error: {e}", None, None
305
+
306
+
307
+ def predict_batch(file_paths: List[str], num_features: int):
308
+ if not file_paths:
309
+ return None, None
310
+
311
+ rows = []
312
+ for fp in file_paths:
313
+ try:
314
+ mime, _ = mimetypes.guess_type(fp)
315
+ mime = mime or "text/plain"
316
+ # Use fast prediction (no LIME) for batch speed
317
+ result = classify_file_fast(fp, mime)
318
+ key_phrase = ""
319
+ rows.append({
320
+ "filename": os.path.basename(fp),
321
+ "prediction": result["prediction"],
322
+ "confidence": float(result["confidence"]),
323
+ "key_phrase": result.get("key_phrase", key_phrase),
324
+ })
325
+ except Exception as e:
326
+ rows.append({
327
+ "filename": os.path.basename(fp),
328
+ "prediction": "",
329
+ "confidence": 0.0,
330
+ "key_phrase": f"Error: {e}",
331
+ })
332
+
333
+ df = pd.DataFrame(rows)
334
+ # Write CSV to a temporary file and return the path for DownloadButton
335
+ tmp_csv = tempfile.NamedTemporaryFile(
336
+ delete=False, suffix="_batch_results.csv")
337
+ try:
338
+ with open(tmp_csv.name, "w", encoding="utf-8", newline="") as f:
339
+ df.to_csv(f, index=False)
340
+ finally:
341
+ pass
342
+ return df, tmp_csv.name
343
+
344
+
345
+ # ------------------------------
346
+ # Build UI
347
+ # ------------------------------
348
+
349
+ with gr.Blocks(title="Contract Classifier") as demo:
350
+ gr.Markdown("""
351
+ **Contract Classification Dashboard**
352
+
353
+ - Upload single or multiple documents
354
+ - View prediction, probabilities, and highlighted influential text
355
+ - Download CSV for batch results
356
+ """)
357
+
358
+ with gr.Tab("Single Document"):
359
+ with gr.Row():
360
+ file_in = gr.File(
361
+ label="Upload document (PDF/DOCX/DOC/TXT)", type="filepath")
362
+ with gr.Row():
363
+ predict_btn = gr.Button("Predict")
364
+ with gr.Row():
365
+ pred_out = gr.Textbox(label="Prediction", lines=1)
366
+ with gr.Row():
367
+ html_out = gr.HTML(label="LIME Explanation (highlighted text)")
368
+ with gr.Row():
369
+ preview_out = gr.Textbox(label="Text Preview", lines=6)
370
+
371
+ predict_btn.click(
372
+ predict_single,
373
+ inputs=[file_in],
374
+ outputs=[pred_out, html_out, preview_out]
375
+ )
376
+
377
+ with gr.Tab("Batch"):
378
+ with gr.Row():
379
+ files_in = gr.File(
380
+ label="Upload multiple documents", file_count="multiple", type="filepath")
381
+ with gr.Row():
382
+ batch_btn = gr.Button("Run Batch")
383
+ with gr.Row():
384
+ table_out = gr.Dataframe(label="Batch Results", interactive=False)
385
+ with gr.Row():
386
+ download_btn = gr.DownloadButton(
387
+ label="Download CSV")
388
+
389
+ def _batch_and_prepare(files):
390
+ df, csv_path = predict_batch(files, num_features=3)
391
+ return df, gr.update(value=csv_path)
392
+
393
+ batch_btn.click(
394
+ _batch_and_prepare,
395
+ inputs=[files_in],
396
+ outputs=[table_out, download_btn]
397
+ )
398
+
399
+ # Ensure model loads at launch for quicker first prediction
400
+ def _warmup():
401
+ ok, msg = load_model_if_needed()
402
+ return f"Model: {'ready' if ok else 'not ready'} — {msg}"
403
+
404
+ warmup_status = gr.Markdown()
405
+ demo.load(
406
+ _warmup,
407
+ inputs=None,
408
+ outputs=warmup_status
409
+ )
410
+
411
+
412
+ if __name__ == "__main__":
413
+ # Let Gradio pick an available port automatically
414
+ demo.launch(server_name="0.0.0.0", show_api=False)
explainability.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone copy of LIME-based explainability used by the dashboard."""
2
+
3
+ import logging
4
+ from typing import Dict, List, Any, Optional, Tuple
5
+
6
+ import numpy as np
7
+
8
+ try:
9
+ from lime.lime_text import LimeTextExplainer
10
+ LIME_AVAILABLE = True
11
+ except ImportError:
12
+ LIME_AVAILABLE = False
13
+
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ContractExplainer:
19
+ def __init__(self, model, vectorizer, class_names: List[str], feature_selector=None, random_state: int = 42):
20
+ if not LIME_AVAILABLE:
21
+ raise ImportError(
22
+ "LIME not available. Install with: pip install lime")
23
+ self.model = model
24
+ self.vectorizer = vectorizer
25
+ self.feature_selector = feature_selector
26
+ self.class_names = class_names
27
+ self.random_state = random_state
28
+ self.explainer = LimeTextExplainer(
29
+ class_names=class_names, random_state=random_state)
30
+
31
+ def explain_prediction(self, text: str, num_features: int = 10, num_samples: int = 500) -> Dict[str, Any]:
32
+ try:
33
+ def predict_proba_wrapper(texts):
34
+ features = self.vectorizer.transform(texts)
35
+ if self.feature_selector is not None:
36
+ features = self.feature_selector.transform(features)
37
+ return self.model.predict_proba(features)
38
+
39
+ exp = self.explainer.explain_instance(
40
+ text,
41
+ predict_proba_wrapper,
42
+ num_features=num_features,
43
+ num_samples=num_samples,
44
+ top_labels=1,
45
+ )
46
+
47
+ all_probs = predict_proba_wrapper([text])[0]
48
+ predicted_index = int(np.argmax(all_probs))
49
+ predicted_class = self.class_names[predicted_index]
50
+ confidence = float(all_probs[predicted_index])
51
+
52
+ important_features = exp.as_list(label=predicted_index)
53
+ processed_features = self._get_best_phrase_feature(
54
+ important_features, text)
55
+
56
+ return {
57
+ "text": text[:200] + "..." if len(text) > 200 else text,
58
+ "full_text": text,
59
+ "prediction": predicted_class,
60
+ "confidence": confidence,
61
+ "important_features": processed_features,
62
+ "explanation_html": exp.as_html(),
63
+ "num_features": num_features,
64
+ "success": True,
65
+ "explanation_object": exp,
66
+ }
67
+ except Exception as e:
68
+ logger.exception("Explain failed")
69
+ return {"success": False, "error": str(e), "text": text[:200] + "..." if len(text) > 200 else text, "full_text": text}
70
+
71
+ def _get_best_phrase_feature(self, important_features: List[Tuple[str, float]], text: str) -> List[Tuple[str, float]]:
72
+ text_lower = text.lower()
73
+ candidate_phrases: List[Tuple[str, float]] = []
74
+
75
+ for feature, score in important_features:
76
+ if " " in feature and len(feature.split()) >= 3:
77
+ candidate_phrases.append((feature, abs(float(score))))
78
+ else:
79
+ feature_lower = feature.lower()
80
+ words = text_lower.split()
81
+ for i, word in enumerate(words):
82
+ if feature_lower in word.lower():
83
+ start_idx = max(0, i - 2)
84
+ end_idx = min(len(words), i + 4)
85
+ context_phrase = " ".join(
86
+ words[start_idx:end_idx]).strip('.,!?;:"()[]{}')
87
+ if len(context_phrase.split()) >= 3:
88
+ candidate_phrases.append(
89
+ (context_phrase, abs(float(score))))
90
+ break
91
+
92
+ if candidate_phrases:
93
+ best = max(candidate_phrases, key=lambda x: x[1])
94
+ return [best]
95
+ return [important_features[0]] if important_features else []
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.25.0
2
+ numpy
3
+ pandas
4
+ pdfplumber
5
+ python-docx
6
+ pytesseract
7
+ pillow
8
+ PyMuPDF
9
+ lime
10
+ scikit-learn
11
+