Spaces:
Sleeping
Sleeping
| """Load the trained models and run predictions. | |
| This is the single bridge between the artifacts saved by the notebook and the | |
| Streamlit app. Every model is fed exactly the way it was trained: the sklearn | |
| models and the feedforward net read TF-IDF, the LSTM and CNN read integer token | |
| sequences. Raw text is always passed through the same `clean_text` used in | |
| training, so the app and the notebook agree. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import joblib | |
| import numpy as np | |
| from .preprocessing import PROJECT_ROOT, clean_text | |
| MODELS_DIR = PROJECT_ROOT / "models" | |
| SEQ_LEN = 300 # must match training (Section 3) | |
| class Detector: | |
| """Holds every trained model and produces probabilities from raw text.""" | |
| def __init__(self, models_dir: Path | str = MODELS_DIR): | |
| self.models_dir = Path(models_dir) | |
| with open(self.models_dir / "model_index.json") as f: | |
| self.index = json.load(f) | |
| self.tfidf = joblib.load(self.models_dir / "tfidf_vectorizer.pkl") | |
| self.feature_names = self.tfidf.get_feature_names_out() | |
| # Keras and TensorFlow are imported only when a Keras model is listed in | |
| # the index, so a scikit-learn-only setup never pays the import cost. | |
| keras = None | |
| if any(m["kind"].startswith("keras") for m in self.index.values()): | |
| import keras | |
| self.models = {} | |
| for name, meta in self.index.items(): | |
| path = self.models_dir / meta["file"] | |
| self.models[name] = (joblib.load(path) if meta["kind"] == "sklearn" | |
| else keras.models.load_model(path)) | |
| # The sequence vectorizer is only needed for the LSTM and CNN. | |
| self.text_vectorizer = None | |
| if any(m["kind"] == "keras_seq" for m in self.index.values()): | |
| with open(self.models_dir / "keras_text_vocab.json") as f: | |
| vocab = json.load(f) | |
| real_tokens = [t for t in vocab if t not in ("", "[UNK]")] | |
| self.text_vectorizer = keras.layers.TextVectorization( | |
| max_tokens=len(vocab), output_mode="int", | |
| output_sequence_length=SEQ_LEN) | |
| self.text_vectorizer.set_vocabulary(real_tokens) | |
| def model_names(self) -> list[str]: | |
| return list(self.index) | |
| def _proba(self, model_name: str, cleaned_texts: list[str]) -> np.ndarray: | |
| """P(AI) for a list of already-cleaned passages.""" | |
| kind = self.index[model_name]["kind"] | |
| model = self.models[model_name] | |
| if kind == "sklearn": | |
| X = self.tfidf.transform(cleaned_texts) | |
| return model.predict_proba(X)[:, 1] | |
| if kind == "keras_tfidf": | |
| X = self.tfidf.transform(cleaned_texts).toarray().astype("float32") | |
| return model.predict(X, verbose=0).ravel() | |
| seq = self.text_vectorizer(np.array(cleaned_texts)).numpy() # keras_seq | |
| return model.predict(seq, verbose=0).ravel() | |
| def predict(self, text: str, model_name: str) -> dict: | |
| """Single prediction with a label and confidence.""" | |
| p = float(self._proba(model_name, [clean_text(text)])[0]) | |
| return { | |
| "model": model_name, | |
| "p_ai": p, | |
| "label": "AI" if p >= 0.5 else "Human", | |
| "confidence": max(p, 1.0 - p), | |
| } | |
| def predict_all(self, text: str) -> dict[str, float]: | |
| """P(AI) from every model, for the side-by-side comparison.""" | |
| cleaned = [clean_text(text)] | |
| return {name: float(self._proba(name, cleaned)[0]) for name in self.index} | |
| def proba_from_raw(self, model_name: str, texts: list[str]) -> np.ndarray: | |
| """Probabilities for raw (uncleaned) texts. Used by LIME.""" | |
| return self._proba(model_name, [clean_text(t) for t in texts]) | |
| def sentence_scores(self, model_name: str, text: str, | |
| min_words: int = 4) -> list[tuple[str, float]]: | |
| """Score each sentence so the app can highlight which parts read as AI.""" | |
| from nltk.tokenize import sent_tokenize | |
| sentences = [s.strip() for s in sent_tokenize(clean_text(text)) if s.strip()] | |
| scored = [s for s in sentences if len(s.split()) >= min_words] | |
| if not scored: | |
| return [] | |
| probs = self._proba(model_name, scored) | |
| return list(zip(scored, (float(p) for p in probs))) | |