language-ninja 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- language_ninja-2.2.0/PKG-INFO +113 -0
- language_ninja-2.2.0/README.md +104 -0
- language_ninja-2.2.0/language_ninja/__init__.py +3 -0
- language_ninja-2.2.0/language_ninja/app.py +420 -0
- language_ninja-2.2.0/language_ninja/ml.py +224 -0
- language_ninja-2.2.0/language_ninja/storage.py +145 -0
- language_ninja-2.2.0/language_ninja.egg-info/PKG-INFO +113 -0
- language_ninja-2.2.0/language_ninja.egg-info/SOURCES.txt +14 -0
- language_ninja-2.2.0/language_ninja.egg-info/dependency_links.txt +1 -0
- language_ninja-2.2.0/language_ninja.egg-info/entry_points.txt +2 -0
- language_ninja-2.2.0/language_ninja.egg-info/requires.txt +2 -0
- language_ninja-2.2.0/language_ninja.egg-info/top_level.txt +1 -0
- language_ninja-2.2.0/pyproject.toml +20 -0
- language_ninja-2.2.0/setup.cfg +4 -0
- language_ninja-2.2.0/tests/test_model.py +26 -0
- language_ninja-2.2.0/tests/test_storage.py +13 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: language-ninja
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Keyboard-first Textual NLP sentiment training, analysis, notes, and export workstation.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: textual>=0.70
|
|
8
|
+
Requires-Dist: scikit-learn>=1.3
|
|
9
|
+
|
|
10
|
+
# 🥷 Language Trainer Ninja v2.2
|
|
11
|
+
|
|
12
|
+
A keyboard-first Textual NLP workstation for sentiment analysis, model training, dataset inspection, notes, history, SQLite queries, and CSV/JSON export.
|
|
13
|
+
|
|
14
|
+
## v2.2 model
|
|
15
|
+
|
|
16
|
+
The bundled model is now a calibrated classical NLP pipeline rather than the earlier small Naive Bayes/logistic baseline:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Word TF-IDF (1–3 grams) ─┐
|
|
20
|
+
├─ FeatureUnion ─ Linear SVM ─ 5-fold sigmoid calibration ─ probabilities
|
|
21
|
+
Char TF-IDF (3–5 grams) ─┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Why both feature families:
|
|
25
|
+
|
|
26
|
+
- Word n-grams capture phrases such as `not good`, `really love`, and longer contrast patterns.
|
|
27
|
+
- Character n-grams improve handling of contractions, punctuation, spelling variation, and unseen word forms.
|
|
28
|
+
- A balanced Linear SVM supplies a strong decision boundary for sparse text.
|
|
29
|
+
- Sigmoid calibration turns SVM margins into class probabilities for the confidence display.
|
|
30
|
+
|
|
31
|
+
The starter corpus contains **1,500 balanced examples**:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
positive 500
|
|
35
|
+
neutral 500
|
|
36
|
+
negative 500
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It includes straightforward sentiment, conversational phrasing, negation, contrast, and neutral operational language.
|
|
40
|
+
|
|
41
|
+
> The built-in holdout score is useful as a regression check, but the starter corpus contains generated/template-assisted examples. Treat its very high holdout accuracy as an internal sanity metric, not as proof of production-level real-world accuracy.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd ~/Downloads/language-trainer-ninja-master
|
|
47
|
+
python3 -m venv .venv
|
|
48
|
+
source .venv/bin/activate
|
|
49
|
+
python -m pip install --upgrade pip
|
|
50
|
+
python -m pip install -e .
|
|
51
|
+
language-ninja
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A trained `models/sentiment_model.pkl` is included, so Analyze can work immediately. Use **Train → Train Model** after changing the dataset.
|
|
55
|
+
|
|
56
|
+
## Confidence
|
|
57
|
+
|
|
58
|
+
The TUI displays all three calibrated probabilities:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
Prediction: NEGATIVE • HIGH CONFIDENCE
|
|
62
|
+
Winning probability: 82.8%
|
|
63
|
+
POS 9.5% NEU 7.6% NEG 82.8%
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
UI guidance:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
HIGH 75–100%
|
|
70
|
+
MODERATE 55–74%
|
|
71
|
+
LOW below 55%
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
These labels describe the model's probability separation, not a guarantee of correctness.
|
|
75
|
+
|
|
76
|
+
## Pages
|
|
77
|
+
|
|
78
|
+
### Analyze
|
|
79
|
+
Paste conversational text and inspect the winning class plus positive, neutral, and negative probabilities. Every prediction is written to History.
|
|
80
|
+
|
|
81
|
+
### Train
|
|
82
|
+
Train from a `text,label` CSV. The status panel reports row counts, algorithm, feature count, holdout accuracy, macro F1, and per-class precision/recall/F1.
|
|
83
|
+
|
|
84
|
+
### Dataset
|
|
85
|
+
Inspect the examples used to train the model and export them to CSV or JSON.
|
|
86
|
+
|
|
87
|
+
### Notes
|
|
88
|
+
Save experiment notes or attach a note to the most recent prediction.
|
|
89
|
+
|
|
90
|
+
### History
|
|
91
|
+
Review every prediction with **POS / NEU / NEG probabilities** and a HIGH/MODERATE/LOW confidence level.
|
|
92
|
+
|
|
93
|
+
### Database
|
|
94
|
+
Run read-only `SELECT`, `WITH`, and `PRAGMA` queries against the SQLite database.
|
|
95
|
+
|
|
96
|
+
### Export
|
|
97
|
+
Export predictions, notes, datasets, and query results to `./exports/`.
|
|
98
|
+
|
|
99
|
+
## Rebuild the bundled corpus
|
|
100
|
+
|
|
101
|
+
The deterministic corpus generator is included:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python tools/build_starter_corpus.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Then retrain from the TUI, or use the Python ML module.
|
|
108
|
+
|
|
109
|
+
## Run tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python -m pytest -q
|
|
113
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# 🥷 Language Trainer Ninja v2.2
|
|
2
|
+
|
|
3
|
+
A keyboard-first Textual NLP workstation for sentiment analysis, model training, dataset inspection, notes, history, SQLite queries, and CSV/JSON export.
|
|
4
|
+
|
|
5
|
+
## v2.2 model
|
|
6
|
+
|
|
7
|
+
The bundled model is now a calibrated classical NLP pipeline rather than the earlier small Naive Bayes/logistic baseline:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Word TF-IDF (1–3 grams) ─┐
|
|
11
|
+
├─ FeatureUnion ─ Linear SVM ─ 5-fold sigmoid calibration ─ probabilities
|
|
12
|
+
Char TF-IDF (3–5 grams) ─┘
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Why both feature families:
|
|
16
|
+
|
|
17
|
+
- Word n-grams capture phrases such as `not good`, `really love`, and longer contrast patterns.
|
|
18
|
+
- Character n-grams improve handling of contractions, punctuation, spelling variation, and unseen word forms.
|
|
19
|
+
- A balanced Linear SVM supplies a strong decision boundary for sparse text.
|
|
20
|
+
- Sigmoid calibration turns SVM margins into class probabilities for the confidence display.
|
|
21
|
+
|
|
22
|
+
The starter corpus contains **1,500 balanced examples**:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
positive 500
|
|
26
|
+
neutral 500
|
|
27
|
+
negative 500
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It includes straightforward sentiment, conversational phrasing, negation, contrast, and neutral operational language.
|
|
31
|
+
|
|
32
|
+
> The built-in holdout score is useful as a regression check, but the starter corpus contains generated/template-assisted examples. Treat its very high holdout accuracy as an internal sanity metric, not as proof of production-level real-world accuracy.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd ~/Downloads/language-trainer-ninja-master
|
|
38
|
+
python3 -m venv .venv
|
|
39
|
+
source .venv/bin/activate
|
|
40
|
+
python -m pip install --upgrade pip
|
|
41
|
+
python -m pip install -e .
|
|
42
|
+
language-ninja
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A trained `models/sentiment_model.pkl` is included, so Analyze can work immediately. Use **Train → Train Model** after changing the dataset.
|
|
46
|
+
|
|
47
|
+
## Confidence
|
|
48
|
+
|
|
49
|
+
The TUI displays all three calibrated probabilities:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
Prediction: NEGATIVE • HIGH CONFIDENCE
|
|
53
|
+
Winning probability: 82.8%
|
|
54
|
+
POS 9.5% NEU 7.6% NEG 82.8%
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
UI guidance:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
HIGH 75–100%
|
|
61
|
+
MODERATE 55–74%
|
|
62
|
+
LOW below 55%
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
These labels describe the model's probability separation, not a guarantee of correctness.
|
|
66
|
+
|
|
67
|
+
## Pages
|
|
68
|
+
|
|
69
|
+
### Analyze
|
|
70
|
+
Paste conversational text and inspect the winning class plus positive, neutral, and negative probabilities. Every prediction is written to History.
|
|
71
|
+
|
|
72
|
+
### Train
|
|
73
|
+
Train from a `text,label` CSV. The status panel reports row counts, algorithm, feature count, holdout accuracy, macro F1, and per-class precision/recall/F1.
|
|
74
|
+
|
|
75
|
+
### Dataset
|
|
76
|
+
Inspect the examples used to train the model and export them to CSV or JSON.
|
|
77
|
+
|
|
78
|
+
### Notes
|
|
79
|
+
Save experiment notes or attach a note to the most recent prediction.
|
|
80
|
+
|
|
81
|
+
### History
|
|
82
|
+
Review every prediction with **POS / NEU / NEG probabilities** and a HIGH/MODERATE/LOW confidence level.
|
|
83
|
+
|
|
84
|
+
### Database
|
|
85
|
+
Run read-only `SELECT`, `WITH`, and `PRAGMA` queries against the SQLite database.
|
|
86
|
+
|
|
87
|
+
### Export
|
|
88
|
+
Export predictions, notes, datasets, and query results to `./exports/`.
|
|
89
|
+
|
|
90
|
+
## Rebuild the bundled corpus
|
|
91
|
+
|
|
92
|
+
The deterministic corpus generator is included:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python tools/build_starter_corpus.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then retrain from the TUI, or use the Python ML module.
|
|
99
|
+
|
|
100
|
+
## Run tests
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m pytest -q
|
|
104
|
+
```
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
|
+
from textual.widgets import (
|
|
9
|
+
Button,
|
|
10
|
+
DataTable,
|
|
11
|
+
Footer,
|
|
12
|
+
Header,
|
|
13
|
+
Input,
|
|
14
|
+
Label,
|
|
15
|
+
Select,
|
|
16
|
+
Static,
|
|
17
|
+
TabbedContent,
|
|
18
|
+
TabPane,
|
|
19
|
+
TextArea,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .ml import load_dataset, load_model, predict, save_model, train_and_evaluate
|
|
23
|
+
from .storage import Storage, export_records, export_table
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ROOT = Path.cwd()
|
|
27
|
+
DATA_DIR = ROOT / "data"
|
|
28
|
+
MODEL_DIR = ROOT / "models"
|
|
29
|
+
EXPORT_DIR = ROOT / "exports"
|
|
30
|
+
DB_PATH = ROOT / "language_ninja.db"
|
|
31
|
+
DEFAULT_DATASET = DATA_DIR / "propositions.csv"
|
|
32
|
+
DEFAULT_MODEL = MODEL_DIR / "sentiment_model.pkl"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LanguageNinja(App):
|
|
36
|
+
TITLE = "🥷 Language Trainer Ninja"
|
|
37
|
+
SUB_TITLE = "NLP Training • Analysis • Notes • Export"
|
|
38
|
+
|
|
39
|
+
CSS = """
|
|
40
|
+
Screen { layout: vertical; }
|
|
41
|
+
TabPane { padding: 1 2; }
|
|
42
|
+
.section { height: auto; margin-bottom: 1; }
|
|
43
|
+
.panel { border: round $primary; padding: 1; height: auto; }
|
|
44
|
+
.instructions { border: round $secondary; padding: 1; margin-bottom: 1; height: auto; }
|
|
45
|
+
.grow { height: 1fr; }
|
|
46
|
+
.status { height: 7; border: round $accent; padding: 1; }
|
|
47
|
+
#train_status { height: 14; }
|
|
48
|
+
.compact { width: 1fr; }
|
|
49
|
+
Button { margin-right: 1; }
|
|
50
|
+
DataTable { height: 1fr; }
|
|
51
|
+
TextArea { height: 10; }
|
|
52
|
+
#analysis_input { height: 8; }
|
|
53
|
+
#note_body { height: 12; }
|
|
54
|
+
#sql_input { height: 5; }
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
BINDINGS = [
|
|
58
|
+
("a", "tab('analyze')", "Analyze"),
|
|
59
|
+
("t", "tab('train')", "Train"),
|
|
60
|
+
("d", "tab('dataset')", "Dataset"),
|
|
61
|
+
("n", "tab('notes')", "Notes"),
|
|
62
|
+
("h", "tab('history')", "History"),
|
|
63
|
+
("e", "tab('export')", "Export"),
|
|
64
|
+
("q", "quit", "Quit"),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.storage = Storage(DB_PATH)
|
|
70
|
+
self.classifier = None
|
|
71
|
+
self.vectorizer = None
|
|
72
|
+
self.active_model = DEFAULT_MODEL
|
|
73
|
+
self.last_prediction_id: int | None = None
|
|
74
|
+
self.query_columns: list[str] = []
|
|
75
|
+
self.query_rows: list[tuple] = []
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
yield Header(show_clock=True)
|
|
79
|
+
with TabbedContent(initial="analyze", id="tabs"):
|
|
80
|
+
with TabPane("Analyze", id="analyze"):
|
|
81
|
+
yield Static(
|
|
82
|
+
"HOW TO USE • Type or paste a sentence below, then choose Analyze. "
|
|
83
|
+
"The highest calibrated probability becomes the prediction. HIGH means 75%+, MODERATE 55–74%, and LOW below 55%. "
|
|
84
|
+
"All three probabilities are saved to History so uncertainty stays visible.",
|
|
85
|
+
classes="instructions",
|
|
86
|
+
)
|
|
87
|
+
yield Label("Text to analyze")
|
|
88
|
+
yield TextArea(id="analysis_input")
|
|
89
|
+
with Horizontal(classes="section"):
|
|
90
|
+
yield Button("Analyze", id="analyze_btn", variant="primary")
|
|
91
|
+
yield Button("Clear", id="clear_analysis")
|
|
92
|
+
yield Static("Ready. Train or load a model to begin.", id="analysis_result", classes="status")
|
|
93
|
+
|
|
94
|
+
with TabPane("Train", id="train"):
|
|
95
|
+
yield Static(
|
|
96
|
+
"HOW TO USE • Choose a CSV containing text,label columns and select Train Model. "
|
|
97
|
+
"The v2.2 trainer uses word/phrase TF-IDF + character TF-IDF feeding a calibrated Linear SVM. "
|
|
98
|
+
"It evaluates on a stratified holdout set, reports per-class quality, then retrains the active model on all rows.",
|
|
99
|
+
classes="instructions",
|
|
100
|
+
)
|
|
101
|
+
yield Label("Dataset path")
|
|
102
|
+
yield Input(str(DEFAULT_DATASET), id="dataset_path")
|
|
103
|
+
yield Label("Model output path")
|
|
104
|
+
yield Input(str(DEFAULT_MODEL), id="model_path")
|
|
105
|
+
with Horizontal(classes="section"):
|
|
106
|
+
yield Button("Train Model", id="train_btn", variant="primary")
|
|
107
|
+
yield Button("Load Model", id="load_model_btn")
|
|
108
|
+
yield Static("No training run yet.", id="train_status", classes="status")
|
|
109
|
+
|
|
110
|
+
with TabPane("Dataset", id="dataset"):
|
|
111
|
+
yield Static(
|
|
112
|
+
"HOW TO USE • Review the examples that teach the model. Each row needs text and a sentiment label. "
|
|
113
|
+
"The bundled corpus contains 1,500 balanced conversational examples, including negation and mixed-language cases. "
|
|
114
|
+
"Refresh after editing the CSV externally, or export a copy for backup.",
|
|
115
|
+
classes="instructions",
|
|
116
|
+
)
|
|
117
|
+
with Horizontal(classes="section"):
|
|
118
|
+
yield Button("Refresh", id="dataset_refresh", variant="primary")
|
|
119
|
+
yield Button("Export CSV", id="dataset_export_csv")
|
|
120
|
+
yield Button("Export JSON", id="dataset_export_json")
|
|
121
|
+
yield DataTable(id="dataset_table", zebra_stripes=True)
|
|
122
|
+
|
|
123
|
+
with TabPane("Notes", id="notes"):
|
|
124
|
+
yield Static(
|
|
125
|
+
"HOW TO USE • Record observations, experiments, or corrections. Save Note stores a general note. "
|
|
126
|
+
"Link to Last Prediction attaches the note to the most recent analysis so you can document mistakes or edge cases.",
|
|
127
|
+
classes="instructions",
|
|
128
|
+
)
|
|
129
|
+
yield Label("Title")
|
|
130
|
+
yield Input(placeholder="Experiment, correction, observation...", id="note_title")
|
|
131
|
+
yield Label("Note")
|
|
132
|
+
yield TextArea(id="note_body")
|
|
133
|
+
with Horizontal(classes="section"):
|
|
134
|
+
yield Button("Save Note", id="save_note", variant="primary")
|
|
135
|
+
yield Button("Link to Last Prediction", id="link_note")
|
|
136
|
+
yield Button("Export Notes", id="notes_export")
|
|
137
|
+
yield DataTable(id="notes_table", zebra_stripes=True)
|
|
138
|
+
|
|
139
|
+
with TabPane("History", id="history"):
|
|
140
|
+
yield Static(
|
|
141
|
+
"HOW TO USE • Review past predictions, confidence scores, and the model used. "
|
|
142
|
+
"Use this page to spot repeated low-confidence cases and export prediction history for deeper analysis.",
|
|
143
|
+
classes="instructions",
|
|
144
|
+
)
|
|
145
|
+
with Horizontal(classes="section"):
|
|
146
|
+
yield Button("Refresh", id="history_refresh", variant="primary")
|
|
147
|
+
yield Button("Export CSV", id="history_export_csv")
|
|
148
|
+
yield Button("Export JSON", id="history_export_json")
|
|
149
|
+
yield DataTable(id="history_table", zebra_stripes=True)
|
|
150
|
+
|
|
151
|
+
with TabPane("Database", id="database"):
|
|
152
|
+
yield Static(
|
|
153
|
+
"HOW TO USE • Run read-only SELECT queries against the app database. "
|
|
154
|
+
"Use this for advanced inspection of predictions and notes. Query results can be exported to CSV or JSON.",
|
|
155
|
+
classes="instructions",
|
|
156
|
+
)
|
|
157
|
+
yield Label("Read-only SQL")
|
|
158
|
+
yield TextArea("SELECT * FROM predictions ORDER BY id DESC LIMIT 25;", id="sql_input")
|
|
159
|
+
with Horizontal(classes="section"):
|
|
160
|
+
yield Button("Run Query", id="query_run", variant="primary")
|
|
161
|
+
yield Button("Export Query CSV", id="query_export_csv")
|
|
162
|
+
yield Button("Export Query JSON", id="query_export_json")
|
|
163
|
+
yield DataTable(id="query_table", zebra_stripes=True)
|
|
164
|
+
|
|
165
|
+
with TabPane("Export", id="export"):
|
|
166
|
+
yield Static(
|
|
167
|
+
"HOW TO USE • Choose what you want to export and select CSV or JSON, then choose Export Now. "
|
|
168
|
+
"Files are written to the project's ./exports/ directory.",
|
|
169
|
+
classes="instructions",
|
|
170
|
+
)
|
|
171
|
+
yield Static(
|
|
172
|
+
"Exports are written to ./exports/\n\n"
|
|
173
|
+
"Available exports:\n"
|
|
174
|
+
"• Prediction history → CSV / JSON\n"
|
|
175
|
+
"• Notes → JSON\n"
|
|
176
|
+
"• Dataset → CSV / JSON\n"
|
|
177
|
+
"• Database query results → CSV / JSON\n",
|
|
178
|
+
classes="panel",
|
|
179
|
+
)
|
|
180
|
+
with Horizontal(classes="section"):
|
|
181
|
+
yield Select(
|
|
182
|
+
[("Predictions", "predictions"), ("Notes", "notes"), ("Dataset", "dataset")],
|
|
183
|
+
value="predictions",
|
|
184
|
+
id="export_source",
|
|
185
|
+
)
|
|
186
|
+
yield Select([("CSV", "csv"), ("JSON", "json")], value="csv", id="export_format")
|
|
187
|
+
yield Button("Export Now", id="export_now", variant="primary")
|
|
188
|
+
yield Static("Nothing exported yet.", id="export_status", classes="status")
|
|
189
|
+
yield Footer()
|
|
190
|
+
|
|
191
|
+
def on_mount(self) -> None:
|
|
192
|
+
self._setup_tables()
|
|
193
|
+
self.refresh_dataset()
|
|
194
|
+
self.refresh_history()
|
|
195
|
+
self.refresh_notes()
|
|
196
|
+
if self.active_model.exists():
|
|
197
|
+
self._load_model(self.active_model)
|
|
198
|
+
|
|
199
|
+
def _setup_tables(self) -> None:
|
|
200
|
+
dataset = self.query_one("#dataset_table", DataTable)
|
|
201
|
+
dataset.add_columns("#", "Text", "Label")
|
|
202
|
+
history = self.query_one("#history_table", DataTable)
|
|
203
|
+
history.add_columns("ID", "Time", "Text", "Result", "POS", "NEU", "NEG", "Level")
|
|
204
|
+
notes = self.query_one("#notes_table", DataTable)
|
|
205
|
+
notes.add_columns("ID", "Time", "Title", "Body", "Prediction")
|
|
206
|
+
|
|
207
|
+
def action_tab(self, tab_id: str) -> None:
|
|
208
|
+
self.query_one("#tabs", TabbedContent).active = tab_id
|
|
209
|
+
|
|
210
|
+
def _notify_error(self, message: str) -> None:
|
|
211
|
+
self.notify(message, severity="error", timeout=5)
|
|
212
|
+
|
|
213
|
+
def _load_model(self, path: Path) -> None:
|
|
214
|
+
try:
|
|
215
|
+
self.classifier, self.vectorizer = load_model(path)
|
|
216
|
+
self.active_model = path
|
|
217
|
+
self.query_one("#train_status", Static).update(f"Loaded model: {path}")
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
self._notify_error(f"Could not load model: {exc}")
|
|
220
|
+
|
|
221
|
+
def refresh_dataset(self) -> None:
|
|
222
|
+
table = self.query_one("#dataset_table", DataTable)
|
|
223
|
+
table.clear()
|
|
224
|
+
path = Path(self.query_one("#dataset_path", Input).value or DEFAULT_DATASET)
|
|
225
|
+
try:
|
|
226
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
227
|
+
reader = csv.reader(handle)
|
|
228
|
+
next(reader, None)
|
|
229
|
+
for index, row in enumerate(reader, start=1):
|
|
230
|
+
if len(row) >= 2:
|
|
231
|
+
table.add_row(str(index), row[0], row[1])
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
self._notify_error(f"Dataset: {exc}")
|
|
234
|
+
|
|
235
|
+
def refresh_history(self) -> None:
|
|
236
|
+
table = self.query_one("#history_table", DataTable)
|
|
237
|
+
table.clear()
|
|
238
|
+
for row in self.storage.recent_predictions():
|
|
239
|
+
table.add_row(
|
|
240
|
+
str(row["id"]),
|
|
241
|
+
row["timestamp"],
|
|
242
|
+
row["text"],
|
|
243
|
+
row["sentiment"].upper(),
|
|
244
|
+
f'{row["positive_probability"]:.1%}',
|
|
245
|
+
f'{row["neutral_probability"]:.1%}',
|
|
246
|
+
f'{row["negative_probability"]:.1%}',
|
|
247
|
+
row["confidence_level"],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def refresh_notes(self) -> None:
|
|
251
|
+
table = self.query_one("#notes_table", DataTable)
|
|
252
|
+
table.clear()
|
|
253
|
+
for row in self.storage.recent_notes():
|
|
254
|
+
table.add_row(
|
|
255
|
+
str(row["id"]), row["timestamp"], row["title"], row["body"], str(row["linked_prediction_id"] or "")
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _export_path(self, stem: str, fmt: str) -> Path:
|
|
259
|
+
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
return EXPORT_DIR / f"{stem}.{fmt}"
|
|
261
|
+
|
|
262
|
+
def export_predictions(self, fmt: str) -> Path:
|
|
263
|
+
return export_records(self.storage.recent_predictions(100000), self._export_path("predictions", fmt), fmt)
|
|
264
|
+
|
|
265
|
+
def export_notes(self, fmt: str) -> Path:
|
|
266
|
+
return export_records(self.storage.recent_notes(100000), self._export_path("notes", fmt), fmt)
|
|
267
|
+
|
|
268
|
+
def export_dataset(self, fmt: str) -> Path:
|
|
269
|
+
path = Path(self.query_one("#dataset_path", Input).value or DEFAULT_DATASET)
|
|
270
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
271
|
+
reader = csv.DictReader(handle)
|
|
272
|
+
records = list(reader)
|
|
273
|
+
return export_records(records, self._export_path("dataset", fmt), fmt)
|
|
274
|
+
|
|
275
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
276
|
+
button_id = event.button.id
|
|
277
|
+
try:
|
|
278
|
+
if button_id == "analyze_btn":
|
|
279
|
+
self.analyze_text()
|
|
280
|
+
elif button_id == "clear_analysis":
|
|
281
|
+
self.query_one("#analysis_input", TextArea).text = ""
|
|
282
|
+
self.query_one("#analysis_result", Static).update("Ready.")
|
|
283
|
+
elif button_id == "train_btn":
|
|
284
|
+
self.train_active_dataset()
|
|
285
|
+
elif button_id == "load_model_btn":
|
|
286
|
+
self._load_model(Path(self.query_one("#model_path", Input).value))
|
|
287
|
+
elif button_id == "dataset_refresh":
|
|
288
|
+
self.refresh_dataset()
|
|
289
|
+
elif button_id == "history_refresh":
|
|
290
|
+
self.refresh_history()
|
|
291
|
+
elif button_id == "save_note":
|
|
292
|
+
self.save_note(link=False)
|
|
293
|
+
elif button_id == "link_note":
|
|
294
|
+
self.save_note(link=True)
|
|
295
|
+
elif button_id == "notes_export":
|
|
296
|
+
path = self.export_notes("json")
|
|
297
|
+
self.notify(f"Exported {path}")
|
|
298
|
+
elif button_id == "dataset_export_csv":
|
|
299
|
+
self.notify(f"Exported {self.export_dataset('csv')}")
|
|
300
|
+
elif button_id == "dataset_export_json":
|
|
301
|
+
self.notify(f"Exported {self.export_dataset('json')}")
|
|
302
|
+
elif button_id == "history_export_csv":
|
|
303
|
+
self.notify(f"Exported {self.export_predictions('csv')}")
|
|
304
|
+
elif button_id == "history_export_json":
|
|
305
|
+
self.notify(f"Exported {self.export_predictions('json')}")
|
|
306
|
+
elif button_id == "query_run":
|
|
307
|
+
self.run_query()
|
|
308
|
+
elif button_id == "query_export_csv":
|
|
309
|
+
self.export_query("csv")
|
|
310
|
+
elif button_id == "query_export_json":
|
|
311
|
+
self.export_query("json")
|
|
312
|
+
elif button_id == "export_now":
|
|
313
|
+
self.export_selected()
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
self._notify_error(str(exc))
|
|
316
|
+
|
|
317
|
+
def train_active_dataset(self) -> None:
|
|
318
|
+
dataset_path = Path(self.query_one("#dataset_path", Input).value)
|
|
319
|
+
model_path = Path(self.query_one("#model_path", Input).value)
|
|
320
|
+
texts, labels = load_dataset(dataset_path)
|
|
321
|
+
classifier, vectorizer, report = train_and_evaluate(texts, labels)
|
|
322
|
+
save_model(model_path, classifier, vectorizer)
|
|
323
|
+
self.classifier, self.vectorizer = classifier, vectorizer
|
|
324
|
+
self.active_model = model_path
|
|
325
|
+
if report.accuracy is None:
|
|
326
|
+
metrics = "Evaluation: add more examples per class for a reliable holdout score"
|
|
327
|
+
per_class = ""
|
|
328
|
+
else:
|
|
329
|
+
metrics = f"Holdout accuracy: {report.accuracy:.1%} • Macro F1: {report.macro_f1:.1%}"
|
|
330
|
+
per_class = "\n".join(
|
|
331
|
+
f"{label.upper():8} P {values['precision']:.1%} R {values['recall']:.1%} F1 {values['f1']:.1%}"
|
|
332
|
+
for label, values in report.class_metrics.items()
|
|
333
|
+
)
|
|
334
|
+
self.query_one("#train_status", Static).update(
|
|
335
|
+
"TRAINING COMPLETE\n"
|
|
336
|
+
f"Examples: {report.rows} • {report.labels}\n"
|
|
337
|
+
f"Model: {report.algorithm}\n"
|
|
338
|
+
f"Features: {report.feature_count:,} • Word TF-IDF 1–3 grams • Char TF-IDF 3–5 grams\n"
|
|
339
|
+
f"{metrics}\n{per_class}\n"
|
|
340
|
+
f"Saved: {model_path}"
|
|
341
|
+
)
|
|
342
|
+
self.notify("Model trained and activated.")
|
|
343
|
+
|
|
344
|
+
def analyze_text(self) -> None:
|
|
345
|
+
text = self.query_one("#analysis_input", TextArea).text.strip()
|
|
346
|
+
if not text:
|
|
347
|
+
raise ValueError("Enter text to analyze.")
|
|
348
|
+
if self.classifier is None or self.vectorizer is None:
|
|
349
|
+
raise ValueError("Train or load a model first.")
|
|
350
|
+
result = predict(text, self.classifier, self.vectorizer)
|
|
351
|
+
probabilities = result.probabilities
|
|
352
|
+
self.query_one("#analysis_result", Static).update(
|
|
353
|
+
f"Prediction: {result.label.upper()} • {result.confidence_level} CONFIDENCE\n"
|
|
354
|
+
f"Winning probability: {result.confidence:.1%}\n"
|
|
355
|
+
f"POS {probabilities.get('positive', 0.0):.1%} "
|
|
356
|
+
f"NEU {probabilities.get('neutral', 0.0):.1%} "
|
|
357
|
+
f"NEG {probabilities.get('negative', 0.0):.1%}"
|
|
358
|
+
)
|
|
359
|
+
self.last_prediction_id = self.storage.add_prediction(
|
|
360
|
+
self.active_model.name,
|
|
361
|
+
text,
|
|
362
|
+
result.label,
|
|
363
|
+
result.confidence,
|
|
364
|
+
probabilities=result.probabilities,
|
|
365
|
+
confidence_level=result.confidence_level,
|
|
366
|
+
)
|
|
367
|
+
self.refresh_history()
|
|
368
|
+
|
|
369
|
+
def save_note(self, link: bool) -> None:
|
|
370
|
+
title = self.query_one("#note_title", Input).value.strip() or "Untitled note"
|
|
371
|
+
body = self.query_one("#note_body", TextArea).text.strip()
|
|
372
|
+
if not body:
|
|
373
|
+
raise ValueError("Write a note before saving.")
|
|
374
|
+
linked = self.last_prediction_id if link else None
|
|
375
|
+
if link and linked is None:
|
|
376
|
+
raise ValueError("There is no prediction to link this note to yet.")
|
|
377
|
+
self.storage.add_note(title, body, linked)
|
|
378
|
+
self.query_one("#note_title", Input).value = ""
|
|
379
|
+
self.query_one("#note_body", TextArea).text = ""
|
|
380
|
+
self.refresh_notes()
|
|
381
|
+
self.notify("Note saved.")
|
|
382
|
+
|
|
383
|
+
def run_query(self) -> None:
|
|
384
|
+
query = self.query_one("#sql_input", TextArea).text.strip()
|
|
385
|
+
columns, rows = self.storage.execute_read_query(query)
|
|
386
|
+
self.query_columns = list(columns)
|
|
387
|
+
self.query_rows = [tuple(row) for row in rows]
|
|
388
|
+
table = self.query_one("#query_table", DataTable)
|
|
389
|
+
table.clear(columns=True)
|
|
390
|
+
if columns:
|
|
391
|
+
table.add_columns(*columns)
|
|
392
|
+
for row in rows:
|
|
393
|
+
table.add_row(*[str(value) for value in row])
|
|
394
|
+
self.notify(f"Query returned {len(rows)} row(s).")
|
|
395
|
+
|
|
396
|
+
def export_query(self, fmt: str) -> None:
|
|
397
|
+
if not self.query_columns:
|
|
398
|
+
self.run_query()
|
|
399
|
+
path = export_table(self.query_columns, self.query_rows, self._export_path("query-results", fmt), fmt)
|
|
400
|
+
self.notify(f"Exported {path}")
|
|
401
|
+
|
|
402
|
+
def export_selected(self) -> None:
|
|
403
|
+
source = self.query_one("#export_source", Select).value
|
|
404
|
+
fmt = self.query_one("#export_format", Select).value
|
|
405
|
+
if source == "predictions":
|
|
406
|
+
path = self.export_predictions(str(fmt))
|
|
407
|
+
elif source == "notes":
|
|
408
|
+
path = self.export_notes(str(fmt))
|
|
409
|
+
else:
|
|
410
|
+
path = self.export_dataset(str(fmt))
|
|
411
|
+
self.query_one("#export_status", Static).update(f"Export complete:\n{path}")
|
|
412
|
+
self.notify(f"Exported {path}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main() -> None:
|
|
416
|
+
LanguageNinja().run()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
main()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import pickle
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from sklearn.calibration import CalibratedClassifierCV
|
|
10
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
11
|
+
from sklearn.linear_model import LogisticRegression
|
|
12
|
+
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score
|
|
13
|
+
from sklearn.model_selection import train_test_split
|
|
14
|
+
from sklearn.pipeline import FeatureUnion
|
|
15
|
+
from sklearn.svm import LinearSVC
|
|
16
|
+
|
|
17
|
+
MODEL_VERSION = "2.2"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class Prediction:
|
|
22
|
+
label: str
|
|
23
|
+
confidence: float
|
|
24
|
+
probabilities: dict[str, float]
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def confidence_level(self) -> str:
|
|
28
|
+
if self.confidence >= 0.75:
|
|
29
|
+
return "HIGH"
|
|
30
|
+
if self.confidence >= 0.55:
|
|
31
|
+
return "MODERATE"
|
|
32
|
+
return "LOW"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class TrainingReport:
|
|
37
|
+
rows: int
|
|
38
|
+
labels: dict[str, int]
|
|
39
|
+
accuracy: float | None
|
|
40
|
+
macro_f1: float | None
|
|
41
|
+
report: str
|
|
42
|
+
feature_count: int
|
|
43
|
+
algorithm: str
|
|
44
|
+
class_metrics: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
45
|
+
confusion: list[list[int]] = field(default_factory=list)
|
|
46
|
+
label_order: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_dataset(path: str | Path) -> tuple[list[str], list[str]]:
|
|
50
|
+
texts: list[str] = []
|
|
51
|
+
labels: list[str] = []
|
|
52
|
+
with Path(path).open("r", encoding="utf-8", newline="") as handle:
|
|
53
|
+
reader = csv.DictReader(handle)
|
|
54
|
+
if not reader.fieldnames or "text" not in reader.fieldnames or "label" not in reader.fieldnames:
|
|
55
|
+
raise ValueError("Dataset must contain text,label columns.")
|
|
56
|
+
for row in reader:
|
|
57
|
+
text = (row.get("text") or "").strip()
|
|
58
|
+
label = (row.get("label") or "").strip().lower()
|
|
59
|
+
if not text or not label:
|
|
60
|
+
continue
|
|
61
|
+
texts.append(text)
|
|
62
|
+
labels.append(label)
|
|
63
|
+
if not texts:
|
|
64
|
+
raise ValueError("Dataset contains no usable text/label rows.")
|
|
65
|
+
if len(set(labels)) < 2:
|
|
66
|
+
raise ValueError("Dataset needs at least two different labels.")
|
|
67
|
+
return texts, labels
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_vectorizer() -> FeatureUnion:
|
|
71
|
+
# Word n-grams capture phrases such as "not good", "really love", and
|
|
72
|
+
# contrast constructions. Character n-grams improve resilience to spelling,
|
|
73
|
+
# contractions, punctuation, and unseen word forms.
|
|
74
|
+
word = TfidfVectorizer(
|
|
75
|
+
lowercase=True,
|
|
76
|
+
analyzer="word",
|
|
77
|
+
ngram_range=(1, 3),
|
|
78
|
+
sublinear_tf=True,
|
|
79
|
+
min_df=1,
|
|
80
|
+
max_df=0.995,
|
|
81
|
+
max_features=30000,
|
|
82
|
+
strip_accents="unicode",
|
|
83
|
+
)
|
|
84
|
+
char = TfidfVectorizer(
|
|
85
|
+
lowercase=True,
|
|
86
|
+
analyzer="char_wb",
|
|
87
|
+
ngram_range=(3, 5),
|
|
88
|
+
sublinear_tf=True,
|
|
89
|
+
min_df=1,
|
|
90
|
+
max_features=30000,
|
|
91
|
+
strip_accents="unicode",
|
|
92
|
+
)
|
|
93
|
+
return FeatureUnion(
|
|
94
|
+
[("word", word), ("char", char)],
|
|
95
|
+
transformer_weights={"word": 1.4, "char": 0.7},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fit_classifier(matrix, labels: list[str]):
|
|
100
|
+
counts = {label: labels.count(label) for label in set(labels)}
|
|
101
|
+
min_class = min(counts.values())
|
|
102
|
+
|
|
103
|
+
if min_class >= 5:
|
|
104
|
+
# Linear SVM gives a strong text decision boundary. Sigmoid calibration
|
|
105
|
+
# converts its margins into probabilities that are more meaningful for
|
|
106
|
+
# the TUI confidence display.
|
|
107
|
+
base = LinearSVC(C=1.5, class_weight="balanced", random_state=42)
|
|
108
|
+
classifier = CalibratedClassifierCV(base, method="sigmoid", cv=5)
|
|
109
|
+
classifier.fit(matrix, labels)
|
|
110
|
+
return classifier, "Calibrated Linear SVM (5-fold sigmoid)"
|
|
111
|
+
|
|
112
|
+
# Small custom datasets cannot support 5-fold calibration. Keep a reliable
|
|
113
|
+
# probability-capable fallback so users can still train their own corpus.
|
|
114
|
+
classifier = LogisticRegression(
|
|
115
|
+
max_iter=3000,
|
|
116
|
+
C=2.0,
|
|
117
|
+
class_weight="balanced",
|
|
118
|
+
solver="lbfgs",
|
|
119
|
+
random_state=42,
|
|
120
|
+
)
|
|
121
|
+
classifier.fit(matrix, labels)
|
|
122
|
+
return classifier, "Balanced Logistic Regression (small-data fallback)"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def train_model(texts: Iterable[str], labels: Iterable[str]):
|
|
126
|
+
texts = list(texts)
|
|
127
|
+
labels = list(labels)
|
|
128
|
+
vectorizer = build_vectorizer()
|
|
129
|
+
matrix = vectorizer.fit_transform(texts)
|
|
130
|
+
classifier, algorithm = _fit_classifier(matrix, labels)
|
|
131
|
+
return classifier, vectorizer, matrix.shape[1], algorithm
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def train_and_evaluate(texts: Iterable[str], labels: Iterable[str]):
|
|
135
|
+
texts = list(texts)
|
|
136
|
+
labels = list(labels)
|
|
137
|
+
label_order = sorted(set(labels))
|
|
138
|
+
label_counts = {label: labels.count(label) for label in label_order}
|
|
139
|
+
|
|
140
|
+
accuracy = None
|
|
141
|
+
macro_f1 = None
|
|
142
|
+
report_text = "Dataset is too small for a reliable holdout evaluation. Add more examples per label."
|
|
143
|
+
class_metrics: dict[str, dict[str, float]] = {}
|
|
144
|
+
matrix_rows: list[list[int]] = []
|
|
145
|
+
|
|
146
|
+
min_class = min(label_counts.values())
|
|
147
|
+
if len(texts) >= 30 and min_class >= 8:
|
|
148
|
+
x_train, x_test, y_train, y_test = train_test_split(
|
|
149
|
+
texts,
|
|
150
|
+
labels,
|
|
151
|
+
test_size=0.20,
|
|
152
|
+
random_state=31415,
|
|
153
|
+
stratify=labels,
|
|
154
|
+
)
|
|
155
|
+
eval_clf, eval_vec, _, _ = train_model(x_train, y_train)
|
|
156
|
+
transformed = eval_vec.transform(x_test)
|
|
157
|
+
predictions = eval_clf.predict(transformed)
|
|
158
|
+
accuracy = float(accuracy_score(y_test, predictions))
|
|
159
|
+
macro_f1 = float(f1_score(y_test, predictions, average="macro", zero_division=0))
|
|
160
|
+
report_text = classification_report(y_test, predictions, labels=label_order, zero_division=0)
|
|
161
|
+
report_dict = classification_report(
|
|
162
|
+
y_test, predictions, labels=label_order, output_dict=True, zero_division=0
|
|
163
|
+
)
|
|
164
|
+
class_metrics = {
|
|
165
|
+
label: {
|
|
166
|
+
"precision": float(report_dict[label]["precision"]),
|
|
167
|
+
"recall": float(report_dict[label]["recall"]),
|
|
168
|
+
"f1": float(report_dict[label]["f1-score"]),
|
|
169
|
+
"support": float(report_dict[label]["support"]),
|
|
170
|
+
}
|
|
171
|
+
for label in label_order
|
|
172
|
+
}
|
|
173
|
+
matrix_rows = confusion_matrix(y_test, predictions, labels=label_order).tolist()
|
|
174
|
+
|
|
175
|
+
classifier, vectorizer, feature_count, algorithm = train_model(texts, labels)
|
|
176
|
+
return classifier, vectorizer, TrainingReport(
|
|
177
|
+
rows=len(texts),
|
|
178
|
+
labels=label_counts,
|
|
179
|
+
accuracy=accuracy,
|
|
180
|
+
macro_f1=macro_f1,
|
|
181
|
+
report=report_text,
|
|
182
|
+
feature_count=feature_count,
|
|
183
|
+
algorithm=algorithm,
|
|
184
|
+
class_metrics=class_metrics,
|
|
185
|
+
confusion=matrix_rows,
|
|
186
|
+
label_order=label_order,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def save_model(path: str | Path, classifier, vectorizer) -> None:
|
|
191
|
+
path = Path(path)
|
|
192
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
payload = {
|
|
194
|
+
"version": MODEL_VERSION,
|
|
195
|
+
"classifier": classifier,
|
|
196
|
+
"vectorizer": vectorizer,
|
|
197
|
+
}
|
|
198
|
+
with path.open("wb") as handle:
|
|
199
|
+
pickle.dump(payload, handle)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_model(path: str | Path):
|
|
203
|
+
with Path(path).open("rb") as handle:
|
|
204
|
+
payload = pickle.load(handle)
|
|
205
|
+
# Backward compatibility with v2.0/v2.1 tuple model files.
|
|
206
|
+
if isinstance(payload, tuple) and len(payload) == 2:
|
|
207
|
+
return payload
|
|
208
|
+
if isinstance(payload, dict) and "classifier" in payload and "vectorizer" in payload:
|
|
209
|
+
return payload["classifier"], payload["vectorizer"]
|
|
210
|
+
raise ValueError("Unsupported model file format.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def predict(text: str, classifier, vectorizer) -> Prediction:
|
|
214
|
+
matrix = vectorizer.transform([text])
|
|
215
|
+
label = str(classifier.predict(matrix)[0])
|
|
216
|
+
probabilities: dict[str, float] = {}
|
|
217
|
+
confidence = 1.0
|
|
218
|
+
if hasattr(classifier, "predict_proba"):
|
|
219
|
+
values = classifier.predict_proba(matrix)[0]
|
|
220
|
+
probabilities = {
|
|
221
|
+
str(name): float(value) for name, value in zip(classifier.classes_, values)
|
|
222
|
+
}
|
|
223
|
+
confidence = probabilities.get(label, max(probabilities.values(), default=1.0))
|
|
224
|
+
return Prediction(label=label, confidence=confidence, probabilities=probabilities)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Storage:
|
|
12
|
+
def __init__(self, db_path: str | Path):
|
|
13
|
+
self.db_path = Path(db_path)
|
|
14
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
self.connection = sqlite3.connect(self.db_path)
|
|
16
|
+
self.connection.row_factory = sqlite3.Row
|
|
17
|
+
self._migrate()
|
|
18
|
+
|
|
19
|
+
def _columns(self, table: str) -> set[str]:
|
|
20
|
+
rows = self.connection.execute(f"PRAGMA table_info({table})").fetchall()
|
|
21
|
+
return {str(row["name"]) for row in rows}
|
|
22
|
+
|
|
23
|
+
def _migrate(self) -> None:
|
|
24
|
+
self.connection.executescript(
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS predictions (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
timestamp TEXT NOT NULL,
|
|
29
|
+
model TEXT NOT NULL,
|
|
30
|
+
text TEXT NOT NULL,
|
|
31
|
+
sentiment TEXT NOT NULL,
|
|
32
|
+
confidence REAL NOT NULL DEFAULT 0
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
timestamp TEXT NOT NULL,
|
|
37
|
+
title TEXT NOT NULL,
|
|
38
|
+
body TEXT NOT NULL,
|
|
39
|
+
linked_prediction_id INTEGER,
|
|
40
|
+
FOREIGN KEY(linked_prediction_id) REFERENCES predictions(id)
|
|
41
|
+
);
|
|
42
|
+
"""
|
|
43
|
+
)
|
|
44
|
+
prediction_columns = self._columns("predictions")
|
|
45
|
+
additions = {
|
|
46
|
+
"positive_probability": "REAL NOT NULL DEFAULT 0",
|
|
47
|
+
"neutral_probability": "REAL NOT NULL DEFAULT 0",
|
|
48
|
+
"negative_probability": "REAL NOT NULL DEFAULT 0",
|
|
49
|
+
"confidence_level": "TEXT NOT NULL DEFAULT 'LOW'",
|
|
50
|
+
}
|
|
51
|
+
for name, definition in additions.items():
|
|
52
|
+
if name not in prediction_columns:
|
|
53
|
+
self.connection.execute(f"ALTER TABLE predictions ADD COLUMN {name} {definition}")
|
|
54
|
+
self.connection.commit()
|
|
55
|
+
|
|
56
|
+
def add_prediction(
|
|
57
|
+
self,
|
|
58
|
+
model: str,
|
|
59
|
+
text: str,
|
|
60
|
+
sentiment: str,
|
|
61
|
+
confidence: float,
|
|
62
|
+
probabilities: Mapping[str, float] | None = None,
|
|
63
|
+
confidence_level: str = "LOW",
|
|
64
|
+
) -> int:
|
|
65
|
+
probabilities = probabilities or {}
|
|
66
|
+
cursor = self.connection.execute(
|
|
67
|
+
"""
|
|
68
|
+
INSERT INTO predictions(
|
|
69
|
+
timestamp, model, text, sentiment, confidence,
|
|
70
|
+
positive_probability, neutral_probability, negative_probability,
|
|
71
|
+
confidence_level
|
|
72
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
""",
|
|
74
|
+
(
|
|
75
|
+
datetime.now().isoformat(timespec="seconds"),
|
|
76
|
+
model,
|
|
77
|
+
text,
|
|
78
|
+
sentiment,
|
|
79
|
+
confidence,
|
|
80
|
+
float(probabilities.get("positive", 0.0)),
|
|
81
|
+
float(probabilities.get("neutral", 0.0)),
|
|
82
|
+
float(probabilities.get("negative", 0.0)),
|
|
83
|
+
confidence_level,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
self.connection.commit()
|
|
87
|
+
return int(cursor.lastrowid)
|
|
88
|
+
|
|
89
|
+
def add_note(self, title: str, body: str, linked_prediction_id: int | None = None) -> int:
|
|
90
|
+
cursor = self.connection.execute(
|
|
91
|
+
"INSERT INTO notes(timestamp, title, body, linked_prediction_id) VALUES (?, ?, ?, ?)",
|
|
92
|
+
(datetime.now().isoformat(timespec="seconds"), title, body, linked_prediction_id),
|
|
93
|
+
)
|
|
94
|
+
self.connection.commit()
|
|
95
|
+
return int(cursor.lastrowid)
|
|
96
|
+
|
|
97
|
+
def recent_predictions(self, limit: int = 250):
|
|
98
|
+
return self.connection.execute(
|
|
99
|
+
"""
|
|
100
|
+
SELECT id, timestamp, model, text, sentiment, confidence,
|
|
101
|
+
positive_probability, neutral_probability, negative_probability,
|
|
102
|
+
confidence_level
|
|
103
|
+
FROM predictions ORDER BY id DESC LIMIT ?
|
|
104
|
+
""",
|
|
105
|
+
(limit,),
|
|
106
|
+
).fetchall()
|
|
107
|
+
|
|
108
|
+
def recent_notes(self, limit: int = 250):
|
|
109
|
+
return self.connection.execute(
|
|
110
|
+
"SELECT id, timestamp, title, body, linked_prediction_id FROM notes ORDER BY id DESC LIMIT ?",
|
|
111
|
+
(limit,),
|
|
112
|
+
).fetchall()
|
|
113
|
+
|
|
114
|
+
def execute_read_query(self, query: str):
|
|
115
|
+
statement = query.strip().lower()
|
|
116
|
+
if not (statement.startswith("select") or statement.startswith("pragma") or statement.startswith("with")):
|
|
117
|
+
raise ValueError("Database tab is read-only. Use SELECT, WITH, or PRAGMA queries.")
|
|
118
|
+
cursor = self.connection.execute(query)
|
|
119
|
+
columns = [item[0] for item in cursor.description or []]
|
|
120
|
+
rows = cursor.fetchall()
|
|
121
|
+
return columns, rows
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def export_records(records: Iterable[Mapping], path: str | Path, fmt: str) -> Path:
|
|
125
|
+
path = Path(path)
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
rows = [dict(row) for row in records]
|
|
128
|
+
fmt = fmt.lower()
|
|
129
|
+
if fmt == "json":
|
|
130
|
+
path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
131
|
+
return path
|
|
132
|
+
if fmt == "csv":
|
|
133
|
+
fields = list(rows[0].keys()) if rows else []
|
|
134
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
135
|
+
writer = csv.DictWriter(handle, fieldnames=fields)
|
|
136
|
+
if fields:
|
|
137
|
+
writer.writeheader()
|
|
138
|
+
writer.writerows(rows)
|
|
139
|
+
return path
|
|
140
|
+
raise ValueError(f"Unsupported export format: {fmt}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def export_table(columns: Sequence[str], rows: Sequence[Sequence], path: str | Path, fmt: str) -> Path:
|
|
144
|
+
dictionaries = [dict(zip(columns, row)) for row in rows]
|
|
145
|
+
return export_records(dictionaries, path, fmt)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: language-ninja
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Keyboard-first Textual NLP sentiment training, analysis, notes, and export workstation.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: textual>=0.70
|
|
8
|
+
Requires-Dist: scikit-learn>=1.3
|
|
9
|
+
|
|
10
|
+
# 🥷 Language Trainer Ninja v2.2
|
|
11
|
+
|
|
12
|
+
A keyboard-first Textual NLP workstation for sentiment analysis, model training, dataset inspection, notes, history, SQLite queries, and CSV/JSON export.
|
|
13
|
+
|
|
14
|
+
## v2.2 model
|
|
15
|
+
|
|
16
|
+
The bundled model is now a calibrated classical NLP pipeline rather than the earlier small Naive Bayes/logistic baseline:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Word TF-IDF (1–3 grams) ─┐
|
|
20
|
+
├─ FeatureUnion ─ Linear SVM ─ 5-fold sigmoid calibration ─ probabilities
|
|
21
|
+
Char TF-IDF (3–5 grams) ─┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Why both feature families:
|
|
25
|
+
|
|
26
|
+
- Word n-grams capture phrases such as `not good`, `really love`, and longer contrast patterns.
|
|
27
|
+
- Character n-grams improve handling of contractions, punctuation, spelling variation, and unseen word forms.
|
|
28
|
+
- A balanced Linear SVM supplies a strong decision boundary for sparse text.
|
|
29
|
+
- Sigmoid calibration turns SVM margins into class probabilities for the confidence display.
|
|
30
|
+
|
|
31
|
+
The starter corpus contains **1,500 balanced examples**:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
positive 500
|
|
35
|
+
neutral 500
|
|
36
|
+
negative 500
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It includes straightforward sentiment, conversational phrasing, negation, contrast, and neutral operational language.
|
|
40
|
+
|
|
41
|
+
> The built-in holdout score is useful as a regression check, but the starter corpus contains generated/template-assisted examples. Treat its very high holdout accuracy as an internal sanity metric, not as proof of production-level real-world accuracy.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd ~/Downloads/language-trainer-ninja-master
|
|
47
|
+
python3 -m venv .venv
|
|
48
|
+
source .venv/bin/activate
|
|
49
|
+
python -m pip install --upgrade pip
|
|
50
|
+
python -m pip install -e .
|
|
51
|
+
language-ninja
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A trained `models/sentiment_model.pkl` is included, so Analyze can work immediately. Use **Train → Train Model** after changing the dataset.
|
|
55
|
+
|
|
56
|
+
## Confidence
|
|
57
|
+
|
|
58
|
+
The TUI displays all three calibrated probabilities:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
Prediction: NEGATIVE • HIGH CONFIDENCE
|
|
62
|
+
Winning probability: 82.8%
|
|
63
|
+
POS 9.5% NEU 7.6% NEG 82.8%
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
UI guidance:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
HIGH 75–100%
|
|
70
|
+
MODERATE 55–74%
|
|
71
|
+
LOW below 55%
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
These labels describe the model's probability separation, not a guarantee of correctness.
|
|
75
|
+
|
|
76
|
+
## Pages
|
|
77
|
+
|
|
78
|
+
### Analyze
|
|
79
|
+
Paste conversational text and inspect the winning class plus positive, neutral, and negative probabilities. Every prediction is written to History.
|
|
80
|
+
|
|
81
|
+
### Train
|
|
82
|
+
Train from a `text,label` CSV. The status panel reports row counts, algorithm, feature count, holdout accuracy, macro F1, and per-class precision/recall/F1.
|
|
83
|
+
|
|
84
|
+
### Dataset
|
|
85
|
+
Inspect the examples used to train the model and export them to CSV or JSON.
|
|
86
|
+
|
|
87
|
+
### Notes
|
|
88
|
+
Save experiment notes or attach a note to the most recent prediction.
|
|
89
|
+
|
|
90
|
+
### History
|
|
91
|
+
Review every prediction with **POS / NEU / NEG probabilities** and a HIGH/MODERATE/LOW confidence level.
|
|
92
|
+
|
|
93
|
+
### Database
|
|
94
|
+
Run read-only `SELECT`, `WITH`, and `PRAGMA` queries against the SQLite database.
|
|
95
|
+
|
|
96
|
+
### Export
|
|
97
|
+
Export predictions, notes, datasets, and query results to `./exports/`.
|
|
98
|
+
|
|
99
|
+
## Rebuild the bundled corpus
|
|
100
|
+
|
|
101
|
+
The deterministic corpus generator is included:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python tools/build_starter_corpus.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Then retrain from the TUI, or use the Python ML module.
|
|
108
|
+
|
|
109
|
+
## Run tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python -m pytest -q
|
|
113
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
language_ninja/__init__.py
|
|
4
|
+
language_ninja/app.py
|
|
5
|
+
language_ninja/ml.py
|
|
6
|
+
language_ninja/storage.py
|
|
7
|
+
language_ninja.egg-info/PKG-INFO
|
|
8
|
+
language_ninja.egg-info/SOURCES.txt
|
|
9
|
+
language_ninja.egg-info/dependency_links.txt
|
|
10
|
+
language_ninja.egg-info/entry_points.txt
|
|
11
|
+
language_ninja.egg-info/requires.txt
|
|
12
|
+
language_ninja.egg-info/top_level.txt
|
|
13
|
+
tests/test_model.py
|
|
14
|
+
tests/test_storage.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
language_ninja
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "language-ninja"
|
|
7
|
+
version = "2.2.0"
|
|
8
|
+
description = "Keyboard-first Textual NLP sentiment training, analysis, notes, and export workstation."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"textual>=0.70",
|
|
13
|
+
"scikit-learn>=1.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
language-ninja = "language_ninja.app:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
include = ["language_ninja*"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from language_ninja.ml import load_dataset, load_model, predict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_bundled_dataset_is_balanced():
|
|
7
|
+
texts, labels = load_dataset(Path('data/propositions.csv'))
|
|
8
|
+
assert len(texts) == 1500
|
|
9
|
+
assert labels.count('positive') == 500
|
|
10
|
+
assert labels.count('neutral') == 500
|
|
11
|
+
assert labels.count('negative') == 500
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_bundled_model_handles_baselines():
|
|
15
|
+
classifier, vectorizer = load_model(Path('models/sentiment_model.pkl'))
|
|
16
|
+
cases = [
|
|
17
|
+
('I absolutely love this application', 'positive'),
|
|
18
|
+
('I absolutely hate this application', 'negative'),
|
|
19
|
+
('The application opened at nine this morning', 'neutral'),
|
|
20
|
+
('You should speak more like a human instead of a robot', 'negative'),
|
|
21
|
+
('You did well, your little business is alright', 'positive'),
|
|
22
|
+
]
|
|
23
|
+
for text, expected in cases:
|
|
24
|
+
result = predict(text, classifier, vectorizer)
|
|
25
|
+
assert result.label == expected
|
|
26
|
+
assert result.confidence >= 0.75
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from language_ninja.storage import Storage, export_records
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_prediction_note_and_export(tmp_path: Path):
|
|
7
|
+
db = Storage(tmp_path / "test.db")
|
|
8
|
+
prediction_id = db.add_prediction("demo.pkl", "great", "positive", 0.9)
|
|
9
|
+
db.add_note("review", "Looks correct", prediction_id)
|
|
10
|
+
assert len(db.recent_predictions()) == 1
|
|
11
|
+
assert len(db.recent_notes()) == 1
|
|
12
|
+
out = export_records(db.recent_predictions(), tmp_path / "predictions.json", "json")
|
|
13
|
+
assert out.exists()
|