BabakScrapes commited on
Commit
48ee936
·
verified ·
1 Parent(s): d08399d

Update to joint app for all classifiers

Browse files
Files changed (7) hide show
  1. README.md +88 -29
  2. app.py +23 -247
  3. common.py +139 -0
  4. generalization.py +520 -0
  5. moralization.py +170 -0
  6. relevance.py +221 -0
  7. requirements.txt +2 -1
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: ISAAC Relevance Filter
3
- emoji: 🔎
4
  colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  python_version: "3.10"
@@ -10,51 +10,110 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # ISAAC Relevance Filter
14
 
15
- A coding-free demo for the **relevance** classifiers from [the Illinois Social
16
- Aggregate Corpus (ISAAC)](https://github.com/BabakHemmatian/Illinois_Social_Attitudes).
17
- Citation information can be found in the repository. Use is subject to [terms](https://github.com/BabakHemmatian/Illinois_Social_Attitudes/blob/main/Data_Use_Agreement.md).
18
 
19
- Each social distinction (ability, age, race, sexuality, skin tone, weight) has
20
- its own fine-tuned classifier; this single Space presents all of them through
21
- a **Social Distinction** dropdown and loads the matching model on demand.
 
 
 
22
 
23
- The Space has two tabs:
24
-
25
- - **Single text** — paste one text, pick a distinction, see the predicted label
26
- and class confidence.
27
- - **Multiple texts (file)** — upload a `.txt` (one text per line) or `.csv`
28
- (text in the first column) and download a labeled results CSV.
29
 
30
  For **race** and **skin tone**, a 0.6 confidence threshold is applied to the
31
  "relevant" class, matching the ISAAC pipeline (`filter_relevance.py`).
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  ## Memory behavior
34
 
35
- Models are loaded lazily and cached (`functools.lru_cache`, capacity 2), so only
36
- the distinction(s) you use are pulled into RAM and at most two stay resident at
37
- once. Peak memory is a single roberta-large (~1.4 GB), well within the free CPU
38
- tier, even though the full set of six models totals ~7.6 GB.
 
39
 
40
  ## Models
41
 
42
- The app resolves each distinction's weights with this order (see `app.py`):
 
 
 
 
 
43
 
44
- 1. `$ISAAC_MODELS_DIR/filter_relevance_<group>` — explicit local override.
45
- 2. `<repo_root>/models/filter_relevance_<group>` — in-repo local run.
46
- 3. The released Hugging Face model repos, named
47
- `$ISAAC_RELEVANCE_REPO_PREFIX<group>`
48
- (default prefix `BabakScrapes/isaac-relevance-`, e.g.
49
- `BabakScrapes/isaac-relevance-race`).
50
 
51
  The same code therefore runs locally against the project's `models/` directory
52
- and on Hugging Face against the published per-distinction model repos.
 
 
 
 
 
53
 
54
  ## Local testing
55
 
56
  ```bash
57
- cd hf_spaces/relevance
58
  pip install -r requirements.txt
59
  export ISAAC_MODELS_DIR=/path/to/Illinois_Social_Attitudes/models
60
  python app.py
 
1
  ---
2
+ title: ISAAC Text Classifiers
3
+ emoji: 🧭
4
  colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.9.0
8
  python_version: "3.10"
 
10
  pinned: false
11
  ---
12
 
13
+ # ISAAC Text Classifiers
14
 
15
+ A single coding-free demo for all three classifier families from the Illinois
16
+ Social Attitudes (ISAAC) project, consolidated into one Space:
 
17
 
18
+ - **Relevance** — is a text relevant to a given social distinction (ability, age,
19
+ race, sexuality, skin tone, weight)? Six fine-tuned classifiers behind a
20
+ distinction dropdown.
21
+ - **Moralization** — does a text frame its subject in moral terms?
22
+ - **Generalization** — clause segmentation plus per-clause discourse labels
23
+ (genericity, eventivity, boundedness/habituality).
24
 
25
+ Each task has a **Single text** tab and a **Multiple texts (file)** tab that
26
+ accepts a `.txt` (one text per line) or `.csv` (text in the first column) and
27
+ returns a labeled results CSV.
 
 
 
28
 
29
  For **race** and **skin tone**, a 0.6 confidence threshold is applied to the
30
  "relevant" class, matching the ISAAC pipeline (`filter_relevance.py`).
31
 
32
+ ## Why one Space
33
+
34
+ Hugging Face limits free personal accounts to **2 ZeroGPU Spaces**, and Gradio
35
+ Spaces now require ZeroGPU or a paid plan — CPU Basic is no longer available on
36
+ the free tier. Consolidating the three demos into one app keeps all of them
37
+ available within that limit, and gives the pre-print a single link to cite.
38
+
39
+ ## Layout
40
+
41
+ | File | Contents |
42
+ | --- | --- |
43
+ | `app.py` | Thin composition layer: three top-level tabs, nothing else. |
44
+ | `common.py` | Device/token resolution, model-path resolution, file I/O, GPU-duration helpers. |
45
+ | `relevance.py` | Six relevance classifiers, thresholding, UI. |
46
+ | `moralization.py` | Moralization classifier and UI. |
47
+ | `generalization.py` | Segmenter + clause classifier, aggregation, pie charts, UI. |
48
+
49
+ The task modules each own their UI via a `build_ui()` function, so `app.py` never
50
+ has to know their internals. They previously lived in three separate Spaces with
51
+ colliding module-level names (`_load`, `_iter_texts`, `classify_file`); the split
52
+ into modules is what makes the merge safe.
53
+
54
+ ## Hardware
55
+
56
+ The Space runs on **ZeroGPU**, which allocates a GPU only for the duration of a
57
+ decorated call. ZeroGPU refuses to start a Space that registers no `@spaces.GPU`
58
+ function. The decorated entry points are:
59
+
60
+ | Function | Duration |
61
+ | --- | --- |
62
+ | `relevance.classify_text` | 60 s (default) |
63
+ | `relevance.classify_file` | dynamic, from row count |
64
+ | `moralization.classify_text` | 60 s (default) |
65
+ | `moralization.classify_file` | dynamic, from row count |
66
+ | `generalization._clause_labels` | dynamic, from word count |
67
+ | `generalization.analyze_file` | dynamic, from row count |
68
+
69
+ `spaces` is imported before `torch` — `common.py` does this once for every
70
+ module. ZeroGPU charges each visitor's daily quota against the duration a call
71
+ *requests*, not the time it actually uses, hence the dynamic durations rather
72
+ than a flat worst case. Overrunning a request kills the call, so the estimates
73
+ assume a cold model load every time.
74
+
75
+ The generalization single-text path deliberately decorates only the model pass:
76
+ `render_pipeline` builds the matplotlib figure afterwards, in the main process,
77
+ from the plain `(clause, attributes)` data the GPU call returns. A decorated call
78
+ runs in a separate process, and a `Figure` is a poor thing to ship across that
79
+ boundary.
80
+
81
  ## Memory behavior
82
 
83
+ Every model is loaded lazily and cached, so only the task(s) actually used are
84
+ pulled into memory. Relevance keeps at most two of its six distinction models
85
+ resident (`functools.lru_cache`, capacity 2) — roughly 1.4 GB rather than the
86
+ ~7.6 GB the full set would need. Moralization and generalization each cache their
87
+ own models at capacity 1.
88
 
89
  ## Models
90
 
91
+ Each model resolves local-first with a Hugging Face Hub fallback
92
+ (`common.resolve_source`):
93
+
94
+ 1. `$ISAAC_MODELS_DIR/<relative path>` — explicit local override.
95
+ 2. `<repo_root>/models/<relative path>` — in-repo local run.
96
+ 3. The released Hugging Face model repo.
97
 
98
+ | Task | Local path under `models/` | Hub default | Env override |
99
+ | --- | --- | --- | --- |
100
+ | Relevance | `filter_relevance_<group>` | `BabakScrapes/isaac-relevance-<group>` | `ISAAC_RELEVANCE_REPO_PREFIX` |
101
+ | Moralization | `label_moralization` | `BabakScrapes/isaac-moralization` | `ISAAC_MORALIZATION_REPO` |
102
+ | Generalization (segmenter) | `label_generalization/label_generalization_segmentation` | `BabakScrapes/isaac-generalization-segmentation` | `ISAAC_GENERALIZATION_SEG_REPO` |
103
+ | Generalization (classifier) | `label_generalization/label_generalization` | `BabakScrapes/isaac-generalization` | `ISAAC_GENERALIZATION_REPO` |
104
 
105
  The same code therefore runs locally against the project's `models/` directory
106
+ and on Hugging Face against the published model repos.
107
+
108
+ **If the model repos are private or gated**, the Space needs a read token: add it
109
+ as a secret named `HF_TOKEN` under Settings → Variables and secrets. Without it
110
+ the build still succeeds — models load lazily, so nothing contacts the Hub at
111
+ startup — and the first classification fails with an `OSError` instead.
112
 
113
  ## Local testing
114
 
115
  ```bash
116
+ cd hf_spaces/isaac
117
  pip install -r requirements.txt
118
  export ISAAC_MODELS_DIR=/path/to/Illinois_Social_Attitudes/models
119
  python app.py
app.py CHANGED
@@ -1,264 +1,40 @@
1
  from __future__ import annotations
2
 
3
- # Must be imported before torch: on ZeroGPU hardware the `spaces` package
4
- # patches torch's CUDA layer at import time. ZeroGPU also refuses to start
5
- # unless at least one @spaces.GPU function is registered (see classify_text /
6
- # classify_file below). The decorator is a no-op off ZeroGPU.
7
- import spaces
8
-
9
- import csv
10
- import os
11
- import tempfile
12
- from functools import lru_cache
13
- from pathlib import Path
14
 
15
  import gradio as gr
16
- import torch
17
- from transformers import AutoModelForSequenceClassification, AutoTokenizer
18
-
19
- # ---------------------------------------------------------------------------
20
- # One Space, six fine-tuned relevance classifiers (one per social distinction).
21
- #
22
- # Models are loaded lazily and cached: only the distinction(s) you actually use
23
- # are pulled into RAM, and at most CACHE_SIZE models stay resident at once.
24
- #
25
- # Resolution order per distinction (local-first, Hub fallback):
26
- # 1. $ISAAC_MODELS_DIR/filter_relevance_<group> (explicit local override)
27
- # 2. <repo_root>/models/filter_relevance_<group> (in-repo local run)
28
- # 3. $ISAAC_RELEVANCE_REPO_PREFIX<group> (released HF model repos)
29
- # ---------------------------------------------------------------------------
30
-
31
- GROUPS = ["ability", "age", "race", "sexuality", "skin_tone", "weight"]
32
-
33
- # Released model-repo naming: prefix + group, e.g. "BabakScrapes/isaac-relevance-race".
34
- HUB_PREFIX = os.environ.get("ISAAC_RELEVANCE_REPO_PREFIX", "BabakScrapes/isaac-relevance-")
35
-
36
- # Number of distinction models kept resident simultaneously.
37
- CACHE_SIZE = 2
38
-
39
- MAX_LENGTH = 512
40
- BATCH_SIZE = 16
41
- # On ZeroGPU a real GPU only exists inside @spaces.GPU functions; outside them
42
- # torch runs in CUDA emulation mode, so "cuda" is the correct target either way.
43
- # Off ZeroGPU (local runs, plain CPU/GPU Spaces) fall back to what's available.
44
- ON_ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
45
- DEVICE = torch.device(
46
- "cuda" if ON_ZERO_GPU or torch.cuda.is_available() else "cpu"
47
- )
48
- # Class index -> human label. The pipeline keeps argmax==1 rows as relevant.
49
- LABELS = {0: "Not relevant", 1: "Relevant"}
50
-
51
- # Confidence thresholding (matches filter_relevance.py): for race and skin_tone
52
- # the model only assigns the rare "relevant" class when its probability clears
53
- # THRESHOLD; otherwise it falls back to the next-most-probable class.
54
- THRESHOLDED_GROUPS = {"race", "skin_tone"}
55
- THRESHOLD_CLASS = 1
56
- THRESHOLD = 0.6
57
-
58
- ALLOWED_EXTENSIONS = {".txt", ".csv"}
59
-
60
-
61
- def _resolve_source(group: str) -> str:
62
- dirname = f"filter_relevance_{group}"
63
-
64
- models_dir = os.environ.get("ISAAC_MODELS_DIR")
65
- if models_dir:
66
- candidate = Path(models_dir) / dirname
67
- if candidate.exists():
68
- return str(candidate)
69
-
70
- sibling = Path(__file__).resolve().parents[2] / "models" / dirname
71
- if sibling.exists():
72
- return str(sibling)
73
-
74
- return f"{HUB_PREFIX}{group}"
75
-
76
-
77
- @lru_cache(maxsize=CACHE_SIZE)
78
- def _load(group: str):
79
- source = _resolve_source(group)
80
- tokenizer = AutoTokenizer.from_pretrained(source)
81
- model = AutoModelForSequenceClassification.from_pretrained(source).to(DEVICE)
82
- model.eval()
83
- return tokenizer, model
84
-
85
-
86
- def _decide(prob, group: str) -> int:
87
- """Map a class-probability vector to a predicted class index, replicating
88
- the thresholding behavior of filter_relevance.py.predict_tokenized."""
89
- if group in THRESHOLDED_GROUPS:
90
- if prob[THRESHOLD_CLASS] > THRESHOLD:
91
- return THRESHOLD_CLASS
92
- masked = prob.clone()
93
- masked[THRESHOLD_CLASS] = -1.0
94
- return int(masked.argmax())
95
- return int(prob.argmax())
96
-
97
 
98
- @torch.no_grad()
99
- def _predict(texts, group):
100
- tokenizer, model = _load(group)
101
- results = []
102
- for start in range(0, len(texts), BATCH_SIZE):
103
- batch = texts[start : start + BATCH_SIZE]
104
- enc = tokenizer(
105
- batch,
106
- padding=True,
107
- truncation=True,
108
- max_length=MAX_LENGTH,
109
- return_tensors="pt",
110
- ).to(DEVICE)
111
- probs = torch.softmax(model(**enc).logits, dim=1).cpu()
112
- for prob in probs:
113
- cls = _decide(prob, group)
114
- results.append((LABELS[cls], float(prob[cls]), float(prob[1])))
115
- return results
116
-
117
-
118
- # ---------------------------------------------------------------------------
119
- # Single-text tab
120
- # ---------------------------------------------------------------------------
121
-
122
- @spaces.GPU(duration=60)
123
- def classify_text(text, group):
124
- if not text or not text.strip():
125
- return {}
126
- tokenizer, model = _load(group)
127
- with torch.no_grad():
128
- enc = tokenizer(
129
- [text],
130
- padding=True,
131
- truncation=True,
132
- max_length=MAX_LENGTH,
133
- return_tensors="pt",
134
- ).to(DEVICE)
135
- prob = torch.softmax(model(**enc).logits, dim=1)[0].cpu()
136
- cls = _decide(prob, group)
137
- # Surface the model's raw class probabilities; the decided label is shown
138
- # in the status line so thresholding effects are visible.
139
- return {
140
- f"{LABELS[1]} (decided)" if cls == 1 else LABELS[1]: float(prob[1]),
141
- f"{LABELS[0]} (decided)" if cls == 0 else LABELS[0]: float(prob[0]),
142
- }
143
-
144
-
145
- # ---------------------------------------------------------------------------
146
- # File tab
147
- # ---------------------------------------------------------------------------
148
-
149
- def _iter_texts(path: Path):
150
- suffix = path.suffix.lower()
151
- if suffix == ".txt":
152
- with path.open("r", encoding="utf-8-sig", errors="ignore") as f:
153
- for line in f:
154
- text = line.strip()
155
- if text:
156
- yield text
157
- elif suffix == ".csv":
158
- with path.open("r", encoding="utf-8-sig", errors="ignore", newline="") as f:
159
- reader = csv.reader(f)
160
- for row in reader:
161
- if row and row[0].strip():
162
- yield row[0].strip()
163
-
164
-
165
- @spaces.GPU(duration=180)
166
- def classify_file(file, group):
167
- if file is None:
168
- return None, "Upload a .txt (one text per line) or .csv (text in the first column) file."
169
-
170
- path = Path(file)
171
- if path.suffix.lower() not in ALLOWED_EXTENSIONS:
172
- return None, "Unsupported file type. Please upload a .txt or .csv file."
173
-
174
- texts = list(_iter_texts(path))
175
- if not texts:
176
- return None, "No text rows found in the uploaded file."
177
-
178
- preds = _predict(texts, group)
179
-
180
- tmp = tempfile.NamedTemporaryFile(
181
- mode="w", delete=False, suffix=".csv", newline="", encoding="utf-8"
182
- )
183
- with tmp as f:
184
- writer = csv.writer(f)
185
- writer.writerow(["text", "social_distinction", "relevance", "confidence"])
186
- for text, (label, conf, _) in zip(texts, preds):
187
- writer.writerow([text, group, label, f"{conf:.4f}"])
188
-
189
- n_relevant = sum(1 for label, _, _ in preds if label == "Relevant")
190
- status = (
191
- f"Processed {len(texts)} text(s) against the '{group}' distinction: "
192
- f"{n_relevant} Relevant, {len(texts) - n_relevant} Not relevant. "
193
- f"Download the full results below."
194
- )
195
- return tmp.name, status
196
-
197
-
198
- # ---------------------------------------------------------------------------
199
- # Interface
200
- # ---------------------------------------------------------------------------
201
 
202
  DESCRIPTION = """
203
- # ISAAC Relevance Filter
204
- Judges whether a text is **relevant to a given social distinction**, that is,
205
- whether it actually discusses people in terms of that attribute. Pick a
206
- distinction below; the app loads the matching fine-tuned classifier on demand.
207
- For **race** and **skin tone**, a 0.6 confidence threshold is applied to the
208
- "relevant" class (matching the ISAAC pipeline), so a text is only marked relevant
209
- when the model is sufficiently confident.
210
- * **Single text** — paste one text and see the predicted label with class confidence.
211
- * **Multiple texts** — upload a `.txt` (one text per line) or `.csv` (text in the
212
- first column) file and download a labeled results CSV.
213
- """
214
 
215
- SINGLE_EXAMPLES = [
216
- ["My grandmother uses a wheelchair and the new ramp finally lets her visit on her own.", "ability"],
217
- ["The recipe calls for two cups of flour and a pinch of salt.", "ability"],
218
- ["People kept assuming things about him because he was Black.", "race"],
219
- ]
220
 
221
- FOOTER = """
222
- ---
223
- By using this tool you agree to ISAAC's
224
- [Use Agreement](https://github.com/BabakHemmatian/Illinois_Social_Attitudes/blob/main/Data_Use_Agreement.md).
225
- For citation information and more details, see the
226
- [Illinois Social Attitudes (ISAAC) repository](https://github.com/BabakHemmatian/Illinois_Social_Attitudes).
227
  """
228
 
229
- with gr.Blocks(title="ISAAC Relevance Filter") as demo:
230
  gr.Markdown(DESCRIPTION)
231
 
232
- group_dd = gr.Dropdown(
233
- choices=GROUPS,
234
- value="ability",
235
- label="Social Distinction",
236
- info="Which fine-tuned relevance classifier to apply.",
237
- )
238
-
239
- with gr.Tab("Single text"):
240
- text_in = gr.Textbox(
241
- label="Input Text", lines=8, placeholder="Paste text here..."
242
- )
243
- text_btn = gr.Button("Classify", variant="primary")
244
- text_out = gr.Label(label="Relevance", num_top_classes=2)
245
- gr.Examples(examples=SINGLE_EXAMPLES, inputs=[text_in, group_dd])
246
- text_btn.click(classify_text, inputs=[text_in, group_dd], outputs=text_out)
247
- text_in.submit(classify_text, inputs=[text_in, group_dd], outputs=text_out)
248
-
249
- with gr.Tab("Multiple texts (file)"):
250
- gr.Markdown(
251
- "Upload a `.txt` file (one text per line) or a `.csv` file "
252
- "(text in the first column). All rows are scored against the "
253
- "selected distinction."
254
- )
255
- file_in = gr.File(label="Input File", file_types=[".txt", ".csv"])
256
- file_btn = gr.Button("Classify file", variant="primary")
257
- file_status = gr.Markdown()
258
- file_out = gr.File(label="Results CSV")
259
- file_btn.click(classify_file, inputs=[file_in, group_dd], outputs=[file_out, file_status])
260
 
261
- gr.Markdown(FOOTER)
262
 
263
 
264
  if __name__ == "__main__":
 
1
  from __future__ import annotations
2
 
3
+ # `common` must be imported first: it imports `spaces` before torch, which is
4
+ # what ZeroGPU requires. The task modules below all import torch transitively.
5
+ import common
 
 
 
 
 
 
 
 
6
 
7
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ import generalization
10
+ import moralization
11
+ import relevance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  DESCRIPTION = """
14
+ # ISAAC Text Classifiers
 
 
 
 
 
 
 
 
 
 
15
 
16
+ Coding-free demos for the classifiers built for the **Illinois Social Attitudes
17
+ (ISAAC)** project. Pick a task below; each one loads its fine-tuned model on
18
+ demand and offers a single-text tab and a bulk file tab.
 
 
19
 
20
+ * **Relevance** — is a text about a given social distinction (ability, age, race,
21
+ sexuality, skin tone, weight)?
22
+ * **Moralization** — does a text frame its subject in moral terms?
23
+ * **Generalization** — how generalized vs. anecdotal is the language, clause by clause?
 
 
24
  """
25
 
26
+ with gr.Blocks(title="ISAAC Text Classifiers") as demo:
27
  gr.Markdown(DESCRIPTION)
28
 
29
+ with gr.Tabs():
30
+ with gr.Tab("Relevance"):
31
+ relevance.build_ui()
32
+ with gr.Tab("Moralization"):
33
+ moralization.build_ui()
34
+ with gr.Tab("Generalization"):
35
+ generalization.build_ui()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ gr.Markdown(common.FOOTER)
38
 
39
 
40
  if __name__ == "__main__":
common.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # Must be imported before torch: on ZeroGPU hardware the `spaces` package
4
+ # patches torch's CUDA layer at import time. Every other module in this Space
5
+ # imports `common` before touching torch, so this one import fixes the ordering
6
+ # for all of them. ZeroGPU also refuses to start a Space that registers no
7
+ # @spaces.GPU function; the decorated entry points live in the task modules.
8
+ import spaces # noqa: F401 -- imported for its import-time side effect
9
+
10
+ import csv
11
+ import os
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ import gradio as gr
16
+ import torch
17
+
18
+ # hf_spaces/isaac/common.py -> repo root, for the in-repo models/ fallback.
19
+ REPO_ROOT = Path(__file__).resolve().parents[2]
20
+
21
+ # On ZeroGPU a real GPU only exists inside @spaces.GPU functions; outside them
22
+ # torch runs in CUDA emulation mode, so "cuda" is the correct target either way.
23
+ # Off ZeroGPU (local runs, plain CPU/GPU Spaces) fall back to what's available.
24
+ ON_ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
25
+ DEVICE = torch.device(
26
+ "cuda" if ON_ZERO_GPU or torch.cuda.is_available() else "cpu"
27
+ )
28
+
29
+ # Private (or gated) model repos need a read token. On Spaces, add it as a
30
+ # secret named HF_TOKEN under Settings -> Variables and secrets. huggingface_hub
31
+ # picks the variable up on its own, but passing it explicitly keeps the failure
32
+ # legible when it is missing: without it the Hub answers 401 and transformers
33
+ # raises a bare OSError at first use, long after the build has gone green.
34
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
35
+
36
+ ALLOWED_EXTENSIONS = {".txt", ".csv"}
37
+
38
+ # ZeroGPU charges quota against the duration a call *requests*, not the time it
39
+ # actually uses, so the file tabs estimate their own budget from the input size
40
+ # instead of always reserving a worst-case slot. Overrunning the request kills
41
+ # the call, so the estimates assume a cold model load every time.
42
+ MAX_DURATION = 180
43
+ EMPTY_INPUT_DURATION = 15
44
+
45
+ UPLOAD_HINT = (
46
+ "Upload a .txt (one text per line) or .csv (text in the first column) file."
47
+ )
48
+ BAD_EXTENSION = "Unsupported file type. Please upload a .txt or .csv file."
49
+ NO_ROWS = "No text rows found in the uploaded file."
50
+
51
+
52
+ def resolve_source(relative: str, hub_id: str) -> str:
53
+ """Local-first, Hub-fallback model resolution, shared by all three tasks.
54
+
55
+ 1. $ISAAC_MODELS_DIR/<relative> (explicit local override)
56
+ 2. <repo_root>/models/<relative> (in-repo local run)
57
+ 3. <hub_id> (the released HF model repo)
58
+ """
59
+ models_dir = os.environ.get("ISAAC_MODELS_DIR")
60
+ if models_dir:
61
+ candidate = Path(models_dir) / relative
62
+ if candidate.exists():
63
+ return str(candidate)
64
+
65
+ sibling = REPO_ROOT / "models" / relative
66
+ if sibling.exists():
67
+ return str(sibling)
68
+
69
+ return hub_id
70
+
71
+
72
+ def load_error(what: str, *sources: str) -> gr.Error:
73
+ """Uniform, actionable message for a model that would not load."""
74
+ return gr.Error(
75
+ f"Could not load the {what} model(s) from {', '.join(sources)}. "
76
+ "If those model repos are private, add a read token as a Space secret "
77
+ "named HF_TOKEN (Settings -> Variables and secrets), then restart the "
78
+ "Space."
79
+ )
80
+
81
+
82
+ def iter_texts(path: Path):
83
+ """Yield the input texts from a .txt (one per line) or .csv (first column)."""
84
+ suffix = path.suffix.lower()
85
+ if suffix == ".txt":
86
+ with path.open("r", encoding="utf-8-sig", errors="ignore") as f:
87
+ for line in f:
88
+ text = line.strip()
89
+ if text:
90
+ yield text
91
+ elif suffix == ".csv":
92
+ with path.open("r", encoding="utf-8-sig", errors="ignore", newline="") as f:
93
+ reader = csv.reader(f)
94
+ for row in reader:
95
+ if row and row[0].strip():
96
+ yield row[0].strip()
97
+
98
+
99
+ def count_rows(file) -> int | None:
100
+ """Row count for duration estimation. None if the file cannot be read, so
101
+ the caller can fall back to the maximum request."""
102
+ try:
103
+ return sum(1 for _ in iter_texts(Path(file)))
104
+ except OSError:
105
+ return None
106
+
107
+
108
+ def duration_for_rows(file, cold_load: int, seconds_per_unit: float, unit: int = 1) -> int:
109
+ """Dynamic @spaces.GPU duration: cold model load plus per-unit inference,
110
+ where a unit is `unit` input rows (e.g. one batch)."""
111
+ if file is None:
112
+ return EMPTY_INPUT_DURATION
113
+ n_rows = count_rows(file)
114
+ if n_rows is None:
115
+ return MAX_DURATION
116
+ n_units = max(1, -(-n_rows // unit))
117
+ return min(MAX_DURATION, int(cold_load + n_units * seconds_per_unit))
118
+
119
+
120
+ def write_results_csv(header, rows) -> str:
121
+ """Write a results CSV to a temp file and return its path for gr.File."""
122
+ tmp = tempfile.NamedTemporaryFile(
123
+ mode="w", delete=False, suffix=".csv", newline="", encoding="utf-8"
124
+ )
125
+ with tmp as f:
126
+ writer = csv.writer(f)
127
+ writer.writerow(header)
128
+ for row in rows:
129
+ writer.writerow(row)
130
+ return tmp.name
131
+
132
+
133
+ FOOTER = """
134
+ ---
135
+ By using this tool you agree to ISAAC's
136
+ [Use Agreement](https://github.com/BabakHemmatian/Illinois_Social_Attitudes/blob/main/Data_Use_Agreement.md).
137
+ For citation information and more details, see the
138
+ [Illinois Social Attitudes (ISAAC) repository](https://github.com/BabakHemmatian/Illinois_Social_Attitudes).
139
+ """
generalization.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Dict, List, Tuple
7
+
8
+ import common # imports `spaces` before torch; see common.py
9
+ import gradio as gr
10
+ import matplotlib
11
+
12
+ matplotlib.use("Agg") # headless backend for Spaces / servers
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import re
16
+ import spaces
17
+ import torch
18
+ from common import (
19
+ ALLOWED_EXTENSIONS,
20
+ BAD_EXTENSION,
21
+ DEVICE,
22
+ HF_TOKEN,
23
+ MAX_DURATION,
24
+ NO_ROWS,
25
+ UPLOAD_HINT,
26
+ duration_for_rows,
27
+ iter_texts,
28
+ load_error,
29
+ resolve_source,
30
+ write_results_csv,
31
+ )
32
+ from transformers import (
33
+ AutoModelForSequenceClassification,
34
+ AutoModelForTokenClassification,
35
+ AutoTokenizer,
36
+ )
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # The generalization pipeline uses TWO fine-tuned models plus the base
40
+ # roberta-base tokenizer:
41
+ # * segmenter -> token classifier that splits text into clauses
42
+ # * classifier -> sequence classifier that labels each clause's discourse type
43
+ # ---------------------------------------------------------------------------
44
+
45
+ SEG_SUBDIR = "label_generalization/label_generalization_segmentation"
46
+ CLF_SUBDIR = "label_generalization/label_generalization"
47
+
48
+ HUB_SEG = os.environ.get(
49
+ "ISAAC_GENERALIZATION_SEG_REPO", "BabakScrapes/isaac-generalization-segmentation"
50
+ )
51
+ HUB_CLF = os.environ.get(
52
+ "ISAAC_GENERALIZATION_REPO", "BabakScrapes/isaac-generalization"
53
+ )
54
+
55
+ MAX_WORDS_PER_SNIPPET = 200
56
+ SEG_MAX_LENGTH = 512
57
+ CLF_MAX_LENGTH = 128
58
+ CLF_BATCH_SIZE = 32
59
+
60
+ # Both models load together, so the cold-start budget is larger than the
61
+ # single-model tasks'.
62
+ COLD_LOAD_SECONDS = 45
63
+ SECONDS_PER_TEXT = 2
64
+
65
+ labels2attrs = {
66
+ "##BOUNDED EVENT (SPECIFIC)": ("specific", "dynamic", "episodic"),
67
+ "##BOUNDED EVENT (GENERIC)": ("generic", "dynamic", "episodic"),
68
+ "##UNBOUNDED EVENT (SPECIFIC)": ("specific", "dynamic", "static"),
69
+ "##UNBOUNDED EVENT (GENERIC)": ("generic", "dynamic", "static"),
70
+ "##BASIC STATE": ("specific", "stative", "static"),
71
+ "##COERCED STATE (SPECIFIC)": ("specific", "dynamic", "static"),
72
+ "##COERCED STATE (GENERIC)": ("generic", "dynamic", "static"),
73
+ "##PERFECT COERCED STATE (SPECIFIC)": ("specific", "dynamic", "episodic"),
74
+ "##PERFECT COERCED STATE (GENERIC)": ("generic", "dynamic", "episodic"),
75
+ "##GENERIC SENTENCE (DYNAMIC)": ("generic", "dynamic", "habitual"),
76
+ "##GENERIC SENTENCE (STATIC)": ("generic", "stative", "static"),
77
+ "##GENERIC SENTENCE (HABITUAL)": ("generic", "stative", "habitual"),
78
+ "##GENERALIZING SENTENCE (DYNAMIC)": ("specific", "dynamic", "habitual"),
79
+ "##GENERALIZING SENTENCE (STATIVE)": ("specific", "stative", "habitual"),
80
+ "##QUESTION": ("NA", "NA", "NA"),
81
+ "##IMPERATIVE": ("NA", "NA", "NA"),
82
+ "##NONSENSE": ("NA", "NA", "NA"),
83
+ "##OTHER": ("NA", "NA", "NA"),
84
+ }
85
+
86
+ label_names = list(labels2attrs.keys())
87
+ index2label = {i: label for i, label in enumerate(label_names)}
88
+
89
+
90
+ @lru_cache(maxsize=1)
91
+ def _load_models():
92
+ seg_source = resolve_source(SEG_SUBDIR, HUB_SEG)
93
+ clf_source = resolve_source(CLF_SUBDIR, HUB_CLF)
94
+ try:
95
+ tokenizer = AutoTokenizer.from_pretrained(
96
+ "roberta-base", use_fast=True, add_prefix_space=True
97
+ )
98
+ clause_model = (
99
+ AutoModelForTokenClassification.from_pretrained(
100
+ seg_source, token=HF_TOKEN
101
+ )
102
+ .to(DEVICE)
103
+ .eval()
104
+ )
105
+ classification_model = (
106
+ AutoModelForSequenceClassification.from_pretrained(
107
+ clf_source, token=HF_TOKEN
108
+ )
109
+ .to(DEVICE)
110
+ .eval()
111
+ )
112
+ except OSError as exc:
113
+ raise load_error("generalization", seg_source, clf_source) from exc
114
+ return tokenizer, clause_model, classification_model
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Text preprocessing
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def split_sentences(text: str) -> List[str]:
122
+ text = re.sub(r"\s+", " ", text).strip()
123
+ if not text:
124
+ return []
125
+ sentences = re.split(r"(?<=[.!?])\s+", text)
126
+ return [s.strip() for s in sentences if s.strip()]
127
+
128
+
129
+ def auto_split(text: str, max_words: int = MAX_WORDS_PER_SNIPPET) -> List[str]:
130
+ sentences = split_sentences(text)
131
+ if not sentences:
132
+ return []
133
+
134
+ snippets: List[str] = []
135
+ current_words: List[str] = []
136
+ for sentence in sentences:
137
+ sent_words = sentence.split()
138
+ if current_words and len(current_words) + len(sent_words) > max_words:
139
+ snippets.append(" ".join(current_words).strip())
140
+ current_words = sent_words[:]
141
+ else:
142
+ current_words.extend(sent_words)
143
+ if current_words:
144
+ snippets.append(" ".join(current_words).strip())
145
+ return snippets
146
+
147
+
148
+ def majority_vote(values: List[int]) -> int:
149
+ if not values:
150
+ return 1
151
+ counts = np.bincount(values)
152
+ return int(np.argmax(counts))
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Clause segmentation
157
+ # ---------------------------------------------------------------------------
158
+
159
+ @torch.inference_mode()
160
+ def get_pred_clause_labels(text: str) -> List[int]:
161
+ tokenizer, clause_model, _ = _load_models()
162
+ words = text.strip().split()
163
+ if not words:
164
+ return []
165
+
166
+ enc = tokenizer(
167
+ words,
168
+ is_split_into_words=True,
169
+ return_tensors="pt",
170
+ truncation=True,
171
+ max_length=SEG_MAX_LENGTH,
172
+ padding="max_length",
173
+ )
174
+ word_ids = enc.word_ids(batch_index=0)
175
+ model_inputs = {k: v.to(DEVICE) for k, v in enc.items()}
176
+ logits = clause_model(**model_inputs).logits[0]
177
+ token_preds = logits.argmax(dim=-1).detach().cpu().tolist()
178
+
179
+ aligned_preds: List[List[int]] = [[] for _ in words]
180
+ for token_idx, word_id in enumerate(word_ids):
181
+ if word_id is None:
182
+ continue
183
+ aligned_preds[word_id].append(token_preds[token_idx])
184
+
185
+ return [majority_vote(preds) if preds else 1 for preds in aligned_preds]
186
+
187
+
188
+ def seg_clause(text: str) -> List[str]:
189
+ words = text.strip().split()
190
+ if not words:
191
+ return []
192
+
193
+ labels = get_pred_clause_labels(text)
194
+ segmented_clauses: List[List[str]] = []
195
+ prev_label = 2
196
+ current_clause: List[str] | None = None
197
+
198
+ for word, label in zip(words, labels):
199
+ if prev_label == 2:
200
+ current_clause = []
201
+ if current_clause is not None:
202
+ current_clause.append(word)
203
+ if label == 2 and prev_label in [0, 1]:
204
+ segmented_clauses.append(current_clause[:])
205
+ current_clause = None
206
+ prev_label = label
207
+
208
+ if current_clause:
209
+ segmented_clauses.append(current_clause[:])
210
+
211
+ return [" ".join(clause) for clause in segmented_clauses if clause]
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Clause classification
216
+ # ---------------------------------------------------------------------------
217
+
218
+ @torch.inference_mode()
219
+ def get_pred_classification_labels(
220
+ clauses: List[str], batch_size: int = CLF_BATCH_SIZE
221
+ ) -> List[Tuple[str, Tuple[str, str, str]]]:
222
+ if not clauses:
223
+ return []
224
+
225
+ tokenizer, _, classification_model = _load_models()
226
+ results: List[Tuple[str, Tuple[str, str, str]]] = []
227
+ for i in range(0, len(clauses), batch_size):
228
+ batch = clauses[i : i + batch_size]
229
+ enc = tokenizer(
230
+ batch,
231
+ return_tensors="pt",
232
+ truncation=True,
233
+ max_length=CLF_MAX_LENGTH,
234
+ padding="max_length",
235
+ )
236
+ model_inputs = {k: v.to(DEVICE) for k, v in enc.items()}
237
+ logits = classification_model(**model_inputs).logits
238
+ pred_ids = logits.argmax(dim=-1).detach().cpu().tolist()
239
+ pred_labels = [index2label[idx] for idx in pred_ids]
240
+ results.extend(
241
+ (clause, labels2attrs[label]) for clause, label in zip(batch, pred_labels)
242
+ )
243
+ return results
244
+
245
+
246
+ def clause_labels_for_text(text: str) -> List[Tuple[str, Tuple[str, str, str]]]:
247
+ """Segment a text into clauses and classify each. Shared by both tabs."""
248
+ text = (text or "").strip()
249
+ if not text:
250
+ return []
251
+ all_clauses: List[str] = []
252
+ for snippet in auto_split(text):
253
+ all_clauses.extend(seg_clause(snippet))
254
+ return get_pred_classification_labels(all_clauses)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Aggregation
259
+ # ---------------------------------------------------------------------------
260
+
261
+ def summarize(clause2labels) -> Tuple[int, Dict, Dict, Dict]:
262
+ """Return (n_clauses, genericity_props, eventivity_props, boundedness_props),
263
+ each proportion taken over the total clause count (NA included), matching the
264
+ pie charts."""
265
+ total = len(clause2labels)
266
+ gen = {"generic": 0, "specific": 0, "NA": 0}
267
+ eve = {"dynamic": 0, "stative": 0, "NA": 0}
268
+ bnd = {"static": 0, "episodic": 0, "habitual": 0, "NA": 0}
269
+ for _, (genericity, eventivity, boundedness) in clause2labels:
270
+ gen[genericity] += 1
271
+ eve[eventivity] += 1
272
+ bnd[boundedness] += 1
273
+
274
+ def prop(d: Dict[str, int]) -> Dict[str, float]:
275
+ return {k: (v / total if total else 0.0) for k, v in d.items()}
276
+
277
+ return total, prop(gen), prop(eve), prop(bnd)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Visualization
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def label_visualization(clause2labels):
285
+ total_clauses = len(clause2labels)
286
+ if total_clauses == 0:
287
+ fig = plt.figure(figsize=(10, 4))
288
+ plt.text(0.5, 0.5, "No clauses detected.", ha="center", va="center")
289
+ plt.axis("off")
290
+ return fig
291
+
292
+ genericity_labels, aspect_labels, boundedness_labels = [], [], []
293
+ for _, attrs in clause2labels:
294
+ genericity_label, aspect_label, boundedness_label = attrs
295
+ genericity_labels.append(genericity_label)
296
+ aspect_labels.append(aspect_label)
297
+ boundedness_labels.append(boundedness_label)
298
+
299
+ genericity_dict = {
300
+ "Generic": genericity_labels.count("generic"),
301
+ "Specific": genericity_labels.count("specific"),
302
+ "NA": genericity_labels.count("NA"),
303
+ }
304
+ aspect_dict = {
305
+ "Dynamic": aspect_labels.count("dynamic"),
306
+ "Stative": aspect_labels.count("stative"),
307
+ "NA": aspect_labels.count("NA"),
308
+ }
309
+ boundedness_dict = {
310
+ "Static": boundedness_labels.count("static"),
311
+ "Episodic": boundedness_labels.count("episodic"),
312
+ "Habitual": boundedness_labels.count("habitual"),
313
+ "NA": boundedness_labels.count("NA"),
314
+ }
315
+
316
+ def proportions(d):
317
+ filtered = {k: v / total_clauses for k, v in d.items() if v > 0}
318
+ return list(filtered.keys()), list(filtered.values())
319
+
320
+ fig, axs = plt.subplots(1, 3, figsize=(10, 5))
321
+ fig.tight_layout(pad=5.0)
322
+
323
+ labels, values = proportions(genericity_dict)
324
+ axs[0].pie(values, labels=labels, autopct="%.0f%%", normalize=True)
325
+ axs[0].set_title("Genericity")
326
+
327
+ labels, values = proportions(aspect_dict)
328
+ axs[1].pie(values, labels=labels, autopct="%.0f%%", normalize=True)
329
+ axs[1].set_title("Eventivity")
330
+
331
+ labels, values = proportions(boundedness_dict)
332
+ axs[2].pie(values, labels=labels, autopct="%.0f%%", normalize=True)
333
+ axs[2].set_title("Boundedness / Habituality")
334
+
335
+ return fig
336
+
337
+
338
+ def render_pipeline(clause2labels):
339
+ """Turn labeled clauses into the three display payloads: numbered clauses,
340
+ attribute-labeled clauses, and the proportion figure.
341
+
342
+ Kept separate from the model pass so the caller can run inference on the GPU
343
+ and build the matplotlib figure afterwards — on ZeroGPU the decorated call
344
+ runs in a separate process, and a Figure is a poor thing to ship across that
345
+ boundary."""
346
+ output_clauses = [
347
+ (clause, str(i + 1)) for i, (clause, _) in enumerate(clause2labels)
348
+ ]
349
+ highlighted_attrs = [(clause, str(attrs)) for clause, attrs in clause2labels]
350
+ figure = label_visualization(clause2labels)
351
+ return output_clauses, highlighted_attrs, figure
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Entry points
356
+ #
357
+ # Only the model pass is GPU-decorated; the figure is built afterwards, in this
358
+ # process, from the plain (clause, attributes) data the GPU call returns.
359
+ # ---------------------------------------------------------------------------
360
+
361
+ def _text_duration(text) -> int:
362
+ n_words = len((text or "").split())
363
+ n_snippets = max(1, -(-n_words // MAX_WORDS_PER_SNIPPET))
364
+ return min(MAX_DURATION, COLD_LOAD_SECONDS + n_snippets * SECONDS_PER_TEXT)
365
+
366
+
367
+ @spaces.GPU(duration=_text_duration)
368
+ def _clause_labels(text):
369
+ return clause_labels_for_text(text)
370
+
371
+
372
+ def analyze_text(text):
373
+ return render_pipeline(_clause_labels(text))
374
+
375
+
376
+ def _file_duration(file) -> int:
377
+ return duration_for_rows(file, COLD_LOAD_SECONDS, SECONDS_PER_TEXT)
378
+
379
+
380
+ @spaces.GPU(duration=_file_duration)
381
+ def analyze_file(file):
382
+ if file is None:
383
+ return None, UPLOAD_HINT
384
+
385
+ path = Path(file)
386
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
387
+ return None, BAD_EXTENSION
388
+
389
+ texts = list(iter_texts(path))
390
+ if not texts:
391
+ return None, NO_ROWS
392
+
393
+ def rows():
394
+ for text in texts:
395
+ clause2labels = clause_labels_for_text(text)
396
+ n, gen, eve, bnd = summarize(clause2labels)
397
+ clauses_joined = "\n".join(
398
+ f"{i + 1}: {clause}" for i, (clause, _) in enumerate(clause2labels)
399
+ )
400
+ attrs_joined = "\n".join("/".join(attrs) for _, attrs in clause2labels)
401
+ yield [
402
+ text, n, clauses_joined, attrs_joined,
403
+ f"{gen['generic']:.4f}", f"{gen['specific']:.4f}", f"{gen['NA']:.4f}",
404
+ f"{eve['dynamic']:.4f}", f"{eve['stative']:.4f}", f"{eve['NA']:.4f}",
405
+ f"{bnd['static']:.4f}", f"{bnd['episodic']:.4f}",
406
+ f"{bnd['habitual']:.4f}", f"{bnd['NA']:.4f}",
407
+ ]
408
+
409
+ out_path = write_results_csv(
410
+ [
411
+ "text",
412
+ "n_clauses",
413
+ "clauses",
414
+ "clause_attributes",
415
+ "genericity_generic", "genericity_specific", "genericity_NA",
416
+ "eventivity_dynamic", "eventivity_stative", "eventivity_NA",
417
+ "boundedness_static", "boundedness_episodic",
418
+ "boundedness_habitual", "boundedness_NA",
419
+ ],
420
+ rows(),
421
+ )
422
+ return out_path, f"Processed {len(texts)} text(s). Download the full results below."
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Interface
427
+ # ---------------------------------------------------------------------------
428
+
429
+ color_panel_1 = [
430
+ "red", "green", "yellow", "DodgerBlue", "orange", "DarkSalmon",
431
+ "pink", "cyan", "gold", "aqua", "violet",
432
+ ]
433
+ # Clause-segmentation panel: each clause gets a rotating color keyed by its
434
+ # 1-based index.
435
+ index_colormap = {
436
+ str(i): color_panel_1[i % len(color_panel_1)] for i in range(1, 100000)
437
+ }
438
+
439
+ color_panel_2 = [
440
+ "Gray", "DodgerBlue", "Wheat", "OliveDrab", "DarkKhaki", "DarkSalmon",
441
+ "Orange", "Gold", "Aqua", "Tomato", "Violet",
442
+ ]
443
+ # Attribute panel: one color per distinct (genericity, eventivity, boundedness)
444
+ # triple.
445
+ str_attrs = sorted({str(v) for v in labels2attrs.values()})
446
+ attr_colormap = {attr: color for attr, color in zip(str_attrs, color_panel_2)}
447
+
448
+ DESCRIPTION = """
449
+ Segments text into **clauses** and labels each clause along three discourse
450
+ dimensions, revealing how generalized vs. anecdotal the language is:
451
+
452
+ * **Genericity** — generic category (e.g., *humanity*) vs. specific instance (e.g., *my cousin*).
453
+ * **Eventivity** — a state (*God is benevolent*) vs. an event (*I went to Nebraska*).
454
+ * **Boundedness / Habituality** — bounded (*I ate this morning*) vs. unbounded (*God loves us*) vs. repeated (*I went there for years*).
455
+
456
+ The most anecdotal content involves specific entities in bounded, non-habitual events.
457
+ """
458
+
459
+ SINGLE_EXAMPLES = [
460
+ ["My cousin was arrested for marijuana possession, and it ruined his life for years."],
461
+ ["Legalizing marijuana would reduce incarceration costs and improve tax revenue."],
462
+ ]
463
+
464
+
465
+ def build_ui():
466
+ gr.Markdown(DESCRIPTION)
467
+
468
+ # Explicit gr.Tabs: these sit inside a top-level task tab in app.py, so the
469
+ # implicit grouping Gradio applies to bare sibling gr.Tab blocks is not
470
+ # something to rely on here.
471
+ with gr.Tabs():
472
+ with gr.Tab("Single text"):
473
+ text_in = gr.Textbox(
474
+ label="Input Text", lines=6, placeholder="Paste text here..."
475
+ )
476
+ text_btn = gr.Button("Analyze", variant="primary")
477
+
478
+ # The two text panels show the SAME text twice: once split into
479
+ # numbered clauses, once colored by discourse attributes.
480
+ # Side-by-side so they can be read in register.
481
+ with gr.Row(equal_height=True):
482
+ seg_out = gr.HighlightedText(
483
+ label="Clause Segmentation",
484
+ color_map=index_colormap,
485
+ combine_adjacent=False,
486
+ show_legend=False,
487
+ )
488
+ attr_out = gr.HighlightedText(
489
+ label="Attribute Classification (genericity, eventivity, boundedness)",
490
+ color_map=attr_colormap,
491
+ combine_adjacent=False,
492
+ show_legend=True,
493
+ )
494
+
495
+ # The proportion charts summarize the whole text; full width below.
496
+ plot_out = gr.Plot(label="Proportion of Attributes")
497
+
498
+ gr.Examples(examples=SINGLE_EXAMPLES, inputs=text_in)
499
+
500
+ text_btn.click(
501
+ analyze_text, inputs=text_in, outputs=[seg_out, attr_out, plot_out]
502
+ )
503
+ text_in.submit(
504
+ analyze_text, inputs=text_in, outputs=[seg_out, attr_out, plot_out]
505
+ )
506
+
507
+ with gr.Tab("Multiple texts (file)"):
508
+ gr.Markdown(
509
+ "Upload a `.txt` file (one text per line) or a `.csv` file "
510
+ "(text in the first column). Each text is segmented and "
511
+ "classified; the results CSV reports the clauses, their "
512
+ "attributes, and the attribute proportions per text."
513
+ )
514
+ file_in = gr.File(label="Input File", file_types=[".txt", ".csv"])
515
+ file_btn = gr.Button("Analyze file", variant="primary")
516
+ file_status = gr.Markdown()
517
+ file_out = gr.File(label="Results CSV")
518
+ file_btn.click(
519
+ analyze_file, inputs=file_in, outputs=[file_out, file_status]
520
+ )
moralization.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ import common # imports `spaces` before torch; see common.py
8
+ import gradio as gr
9
+ import spaces
10
+ import torch
11
+ from common import (
12
+ ALLOWED_EXTENSIONS,
13
+ BAD_EXTENSION,
14
+ DEVICE,
15
+ HF_TOKEN,
16
+ NO_ROWS,
17
+ UPLOAD_HINT,
18
+ duration_for_rows,
19
+ iter_texts,
20
+ load_error,
21
+ resolve_source,
22
+ write_results_csv,
23
+ )
24
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
25
+
26
+ LOCAL_DIRNAME = "label_moralization"
27
+ HUB_ID = os.environ.get("ISAAC_MORALIZATION_REPO", "BabakScrapes/isaac-moralization")
28
+
29
+ MAX_LENGTH = 512
30
+ BATCH_SIZE = 16
31
+ COLD_LOAD_SECONDS = 40
32
+ SECONDS_PER_BATCH = 2
33
+
34
+ # Class index -> human label. The training pipeline (label_moralization.py)
35
+ # treats argmax==1 as the moralized class.
36
+ LABELS = {0: "Non-Moralized", 1: "Moralized"}
37
+
38
+
39
+ @lru_cache(maxsize=1)
40
+ def _load():
41
+ source = resolve_source(LOCAL_DIRNAME, HUB_ID)
42
+ try:
43
+ tokenizer = AutoTokenizer.from_pretrained(source, token=HF_TOKEN)
44
+ model = AutoModelForSequenceClassification.from_pretrained(
45
+ source, token=HF_TOKEN
46
+ ).to(DEVICE)
47
+ except OSError as exc:
48
+ raise load_error("moralization", source) from exc
49
+ model.eval()
50
+ return tokenizer, model
51
+
52
+
53
+ @torch.no_grad()
54
+ def _predict(texts):
55
+ tokenizer, model = _load()
56
+ results = []
57
+ for start in range(0, len(texts), BATCH_SIZE):
58
+ batch = texts[start : start + BATCH_SIZE]
59
+ enc = tokenizer(
60
+ batch,
61
+ padding=True,
62
+ truncation=True,
63
+ max_length=MAX_LENGTH,
64
+ return_tensors="pt",
65
+ ).to(DEVICE)
66
+ probs = torch.softmax(model(**enc).logits, dim=1).cpu()
67
+ for prob in probs:
68
+ cls = int(prob.argmax())
69
+ results.append((LABELS[cls], float(prob[cls]), float(prob[1])))
70
+ return results
71
+
72
+
73
+ @spaces.GPU(duration=60)
74
+ def classify_text(text):
75
+ if not text or not text.strip():
76
+ return {}
77
+ tokenizer, model = _load()
78
+ with torch.no_grad():
79
+ enc = tokenizer(
80
+ [text],
81
+ padding=True,
82
+ truncation=True,
83
+ max_length=MAX_LENGTH,
84
+ return_tensors="pt",
85
+ ).to(DEVICE)
86
+ prob = torch.softmax(model(**enc).logits, dim=1)[0].cpu()
87
+ # gr.Label renders this dict as a ranked bar chart of class confidences.
88
+ return {LABELS[0]: float(prob[0]), LABELS[1]: float(prob[1])}
89
+
90
+
91
+ def _file_duration(file) -> int:
92
+ return duration_for_rows(file, COLD_LOAD_SECONDS, SECONDS_PER_BATCH, BATCH_SIZE)
93
+
94
+
95
+ @spaces.GPU(duration=_file_duration)
96
+ def classify_file(file):
97
+ if file is None:
98
+ return None, UPLOAD_HINT
99
+
100
+ path = Path(file)
101
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
102
+ return None, BAD_EXTENSION
103
+
104
+ texts = list(iter_texts(path))
105
+ if not texts:
106
+ return None, NO_ROWS
107
+
108
+ preds = _predict(texts)
109
+ out_path = write_results_csv(
110
+ ["text", "moralization", "confidence"],
111
+ (
112
+ [text, label, f"{conf:.4f}"]
113
+ for text, (label, conf, _) in zip(texts, preds)
114
+ ),
115
+ )
116
+
117
+ n_moralized = sum(1 for label, _, _ in preds if label == "Moralized")
118
+ status = (
119
+ f"Processed {len(texts)} text(s): "
120
+ f"{n_moralized} Moralized, {len(texts) - n_moralized} Non-Moralized. "
121
+ f"Download the full results below."
122
+ )
123
+ return out_path, status
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Interface
128
+ # ---------------------------------------------------------------------------
129
+
130
+ DESCRIPTION = """
131
+ Detects whether a text frames its subject in **moral** terms (right/wrong, virtue,
132
+ harm, fairness, purity, etc.) versus non-moral terms. This is the document-level
133
+ moralization model from the ISAAC project.
134
+ """
135
+
136
+ SINGLE_EXAMPLES = [
137
+ ["People who cut in line are selfish and should be ashamed of themselves."],
138
+ ["The bus arrives at the corner of 5th and Main every fifteen minutes."],
139
+ ]
140
+
141
+
142
+ def build_ui():
143
+ gr.Markdown(DESCRIPTION)
144
+
145
+ # Explicit gr.Tabs: these sit inside a top-level task tab in app.py, so the
146
+ # implicit grouping Gradio applies to bare sibling gr.Tab blocks is not
147
+ # something to rely on here.
148
+ with gr.Tabs():
149
+ with gr.Tab("Single text"):
150
+ text_in = gr.Textbox(
151
+ label="Input Text", lines=8, placeholder="Paste text here..."
152
+ )
153
+ text_btn = gr.Button("Classify", variant="primary")
154
+ text_out = gr.Label(label="Moralization", num_top_classes=2)
155
+ gr.Examples(examples=SINGLE_EXAMPLES, inputs=text_in)
156
+ text_btn.click(classify_text, inputs=text_in, outputs=text_out)
157
+ text_in.submit(classify_text, inputs=text_in, outputs=text_out)
158
+
159
+ with gr.Tab("Multiple texts (file)"):
160
+ gr.Markdown(
161
+ "Upload a `.txt` file (one text per line) or a `.csv` file "
162
+ "(text in the first column)."
163
+ )
164
+ file_in = gr.File(label="Input File", file_types=[".txt", ".csv"])
165
+ file_btn = gr.Button("Classify file", variant="primary")
166
+ file_status = gr.Markdown()
167
+ file_out = gr.File(label="Results CSV")
168
+ file_btn.click(
169
+ classify_file, inputs=file_in, outputs=[file_out, file_status]
170
+ )
relevance.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ import common # imports `spaces` before torch; see common.py
8
+ import gradio as gr
9
+ import spaces
10
+ import torch
11
+ from common import (
12
+ ALLOWED_EXTENSIONS,
13
+ BAD_EXTENSION,
14
+ DEVICE,
15
+ HF_TOKEN,
16
+ NO_ROWS,
17
+ UPLOAD_HINT,
18
+ duration_for_rows,
19
+ iter_texts,
20
+ load_error,
21
+ resolve_source,
22
+ write_results_csv,
23
+ )
24
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Six fine-tuned relevance classifiers, one per social distinction.
28
+ #
29
+ # Models are loaded lazily and cached: only the distinction(s) actually used are
30
+ # pulled into memory, and at most CACHE_SIZE stay resident at once.
31
+ # ---------------------------------------------------------------------------
32
+
33
+ GROUPS = ["ability", "age", "race", "sexuality", "skin_tone", "weight"]
34
+
35
+ # Released model-repo naming: prefix + group, e.g. "BabakScrapes/isaac-relevance-race".
36
+ HUB_PREFIX = os.environ.get(
37
+ "ISAAC_RELEVANCE_REPO_PREFIX", "BabakScrapes/isaac-relevance-"
38
+ )
39
+
40
+ # Number of distinction models kept resident simultaneously.
41
+ CACHE_SIZE = 2
42
+
43
+ MAX_LENGTH = 512
44
+ BATCH_SIZE = 16
45
+ COLD_LOAD_SECONDS = 40
46
+ SECONDS_PER_BATCH = 2
47
+
48
+ # Class index -> human label. The pipeline keeps argmax==1 rows as relevant.
49
+ LABELS = {0: "Not relevant", 1: "Relevant"}
50
+
51
+ # Confidence thresholding (matches filter_relevance.py): for race and skin_tone
52
+ # the model only assigns the rare "relevant" class when its probability clears
53
+ # THRESHOLD; otherwise it falls back to the next-most-probable class.
54
+ THRESHOLDED_GROUPS = {"race", "skin_tone"}
55
+ THRESHOLD_CLASS = 1
56
+ THRESHOLD = 0.6
57
+
58
+
59
+ @lru_cache(maxsize=CACHE_SIZE)
60
+ def _load(group: str):
61
+ source = resolve_source(f"filter_relevance_{group}", f"{HUB_PREFIX}{group}")
62
+ try:
63
+ tokenizer = AutoTokenizer.from_pretrained(source, token=HF_TOKEN)
64
+ model = AutoModelForSequenceClassification.from_pretrained(
65
+ source, token=HF_TOKEN
66
+ ).to(DEVICE)
67
+ except OSError as exc:
68
+ raise load_error(f"'{group}' relevance", source) from exc
69
+ model.eval()
70
+ return tokenizer, model
71
+
72
+
73
+ def _decide(prob, group: str) -> int:
74
+ """Map a class-probability vector to a predicted class index, replicating
75
+ the thresholding behavior of filter_relevance.py.predict_tokenized."""
76
+ if group in THRESHOLDED_GROUPS:
77
+ if prob[THRESHOLD_CLASS] > THRESHOLD:
78
+ return THRESHOLD_CLASS
79
+ masked = prob.clone()
80
+ masked[THRESHOLD_CLASS] = -1.0
81
+ return int(masked.argmax())
82
+ return int(prob.argmax())
83
+
84
+
85
+ @torch.no_grad()
86
+ def _predict(texts, group):
87
+ tokenizer, model = _load(group)
88
+ results = []
89
+ for start in range(0, len(texts), BATCH_SIZE):
90
+ batch = texts[start : start + BATCH_SIZE]
91
+ enc = tokenizer(
92
+ batch,
93
+ padding=True,
94
+ truncation=True,
95
+ max_length=MAX_LENGTH,
96
+ return_tensors="pt",
97
+ ).to(DEVICE)
98
+ probs = torch.softmax(model(**enc).logits, dim=1).cpu()
99
+ for prob in probs:
100
+ cls = _decide(prob, group)
101
+ results.append((LABELS[cls], float(prob[cls]), float(prob[1])))
102
+ return results
103
+
104
+
105
+ @spaces.GPU(duration=60)
106
+ def classify_text(text, group):
107
+ if not text or not text.strip():
108
+ return {}
109
+ tokenizer, model = _load(group)
110
+ with torch.no_grad():
111
+ enc = tokenizer(
112
+ [text],
113
+ padding=True,
114
+ truncation=True,
115
+ max_length=MAX_LENGTH,
116
+ return_tensors="pt",
117
+ ).to(DEVICE)
118
+ prob = torch.softmax(model(**enc).logits, dim=1)[0].cpu()
119
+ cls = _decide(prob, group)
120
+ # Surface the model's raw class probabilities; the decided label is marked
121
+ # so thresholding effects stay visible.
122
+ return {
123
+ f"{LABELS[1]} (decided)" if cls == 1 else LABELS[1]: float(prob[1]),
124
+ f"{LABELS[0]} (decided)" if cls == 0 else LABELS[0]: float(prob[0]),
125
+ }
126
+
127
+
128
+ def _file_duration(file, group=None) -> int:
129
+ return duration_for_rows(file, COLD_LOAD_SECONDS, SECONDS_PER_BATCH, BATCH_SIZE)
130
+
131
+
132
+ @spaces.GPU(duration=_file_duration)
133
+ def classify_file(file, group):
134
+ if file is None:
135
+ return None, UPLOAD_HINT
136
+
137
+ path = Path(file)
138
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
139
+ return None, BAD_EXTENSION
140
+
141
+ texts = list(iter_texts(path))
142
+ if not texts:
143
+ return None, NO_ROWS
144
+
145
+ preds = _predict(texts, group)
146
+ out_path = write_results_csv(
147
+ ["text", "social_distinction", "relevance", "confidence"],
148
+ (
149
+ [text, group, label, f"{conf:.4f}"]
150
+ for text, (label, conf, _) in zip(texts, preds)
151
+ ),
152
+ )
153
+
154
+ n_relevant = sum(1 for label, _, _ in preds if label == "Relevant")
155
+ status = (
156
+ f"Processed {len(texts)} text(s) against the '{group}' distinction: "
157
+ f"{n_relevant} Relevant, {len(texts) - n_relevant} Not relevant. "
158
+ f"Download the full results below."
159
+ )
160
+ return out_path, status
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Interface
165
+ # ---------------------------------------------------------------------------
166
+
167
+ DESCRIPTION = """
168
+ Judges whether a text is **relevant to a given social distinction**, that is,
169
+ whether it actually discusses people in terms of that attribute. Pick a
170
+ distinction below; the app loads the matching fine-tuned classifier on demand.
171
+ For **race** and **skin tone**, a 0.6 confidence threshold is applied to the
172
+ "relevant" class (matching the ISAAC pipeline), so a text is only marked relevant
173
+ when the model is sufficiently confident.
174
+ """
175
+
176
+ SINGLE_EXAMPLES = [
177
+ ["My grandmother uses a wheelchair and the new ramp finally lets her visit on her own.", "ability"],
178
+ ["The recipe calls for two cups of flour and a pinch of salt.", "ability"],
179
+ ["People kept assuming things about him because he was Black.", "race"],
180
+ ]
181
+
182
+
183
+ def build_ui():
184
+ gr.Markdown(DESCRIPTION)
185
+
186
+ group_dd = gr.Dropdown(
187
+ choices=GROUPS,
188
+ value="ability",
189
+ label="Social Distinction",
190
+ info="Which fine-tuned relevance classifier to apply.",
191
+ )
192
+
193
+ # Explicit gr.Tabs: these sit inside a top-level task tab in app.py, so the
194
+ # implicit grouping Gradio applies to bare sibling gr.Tab blocks is not
195
+ # something to rely on here.
196
+ with gr.Tabs():
197
+ with gr.Tab("Single text"):
198
+ text_in = gr.Textbox(
199
+ label="Input Text", lines=8, placeholder="Paste text here..."
200
+ )
201
+ text_btn = gr.Button("Classify", variant="primary")
202
+ text_out = gr.Label(label="Relevance", num_top_classes=2)
203
+ gr.Examples(examples=SINGLE_EXAMPLES, inputs=[text_in, group_dd])
204
+ text_btn.click(classify_text, inputs=[text_in, group_dd], outputs=text_out)
205
+ text_in.submit(classify_text, inputs=[text_in, group_dd], outputs=text_out)
206
+
207
+ with gr.Tab("Multiple texts (file)"):
208
+ gr.Markdown(
209
+ "Upload a `.txt` file (one text per line) or a `.csv` file "
210
+ "(text in the first column). All rows are scored against the "
211
+ "selected distinction."
212
+ )
213
+ file_in = gr.File(label="Input File", file_types=[".txt", ".csv"])
214
+ file_btn = gr.Button("Classify file", variant="primary")
215
+ file_status = gr.Markdown()
216
+ file_out = gr.File(label="Results CSV")
217
+ file_btn.click(
218
+ classify_file,
219
+ inputs=[file_in, group_dd],
220
+ outputs=[file_out, file_status],
221
+ )
requirements.txt CHANGED
@@ -2,4 +2,5 @@ spaces>=0.30
2
  torch>=2.8,<3
3
  gradio==6.9.0
4
  transformers>=4.38,<5
5
- numpy>=1.26,<3
 
 
2
  torch>=2.8,<3
3
  gradio==6.9.0
4
  transformers>=4.38,<5
5
+ numpy>=1.26,<3
6
+ matplotlib>=3.8,<4