geclens 1.0.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.
geclens-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prakhar Gautam, Jitendra Singh Thakur, Ashish Mishra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
geclens-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: geclens
3
+ Version: 1.0.0
4
+ Summary: Error-type-targeted evaluation of grammatical error correction systems
5
+ Author: Jitendra Singh Thakur, Ashish Mishra
6
+ Author-email: Prakhar Gautam <prakhargautam@jecjabalpur.ac.in>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/prakhargautam-ggits/GECLens
9
+ Project-URL: Documentation, https://prakhargautam-ggits.github.io/GECLens/
10
+ Project-URL: Issues, https://github.com/prakhargautam-ggits/GECLens/issues
11
+ Keywords: grammatical error correction,evaluation,ERRANT,error types,significance testing,NLP
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Text Processing :: Linguistic
17
+ Classifier: Intended Audience :: Science/Research
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: errant==3.0.2
22
+ Requires-Dist: spacy<3.8,>=3.4
23
+ Requires-Dist: numpy>=1.21
24
+ Requires-Dist: matplotlib>=3.5
25
+ Requires-Dist: PyYAML>=6.0
26
+ Requires-Dist: click>=8.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4; extra == "dev"
30
+ Provides-Extra: docs
31
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
32
+ Requires-Dist: mkdocs-material>=9; extra == "docs"
33
+ Dynamic: license-file
34
+
35
+ # GECLens
36
+
37
+ **Error-type-targeted evaluation of grammatical error correction (GEC) systems.**
38
+
39
+ [![tests](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml/badge.svg)](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml)
40
+ ![python](https://img.shields.io/badge/python-3.9%E2%80%933.12-blue) ![license](https://img.shields.io/badge/license-MIT-green)
41
+
42
+ Overall F0.5 hides how a system handles a single kind of error. Subject–verb agreement, articles, prepositions and tense
43
+ each make up only a few per cent of all edits. GECLens answers two questions for any ERRANT error type:
44
+
45
+ 1. **Does the system fix these errors?** Type F0.5, type-only F0.5, sentence-level detection and correction success.
46
+ 2. **What does it break while trying?** Over-correction on sentences the references leave unchanged.
47
+
48
+ Every measure comes with bootstrap confidence intervals and paired significance tests (approximate randomisation with
49
+ Holm correction), for any number of systems. Results are written as CSV, Markdown and LaTeX tables and as figures.
50
+
51
+ GECLens builds on [ERRANT](https://github.com/chrisjbryant/errant) for alignment and edit typing. Its edit-level scores
52
+ are **identical** to `errant_compare`. This is checked in the test suite.
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install geclens # or, from source: pip install -e .
60
+ python -m spacy download en_core_web_sm
61
+ ```
62
+
63
+ Requirements: Python 3.9–3.12, ERRANT 3.0.2, spaCy 3.4–3.7 with `en_core_web_sm`, NumPy, Matplotlib and PyYAML. GECLens
64
+ runs on Linux, macOS and Windows.
65
+
66
+ ## Quick start (toy data included)
67
+
68
+ ```bash
69
+ cd examples/toy
70
+ geclens run config.yaml # about 5 seconds
71
+ ```
72
+
73
+ This scores three toy systems on 30 sentences for three error types and writes `results/`. The toy data were written by
74
+ the GECLens authors and are released under CC0.
75
+
76
+ | System | Overall F0.5 | Type F0.5 | Type-only F0.5 | Detection F1 | Success | Over-corr. |
77
+ |---|---|---|---|---|---|---|
78
+ | careful | 0.882 | 0.972 | 0.972 | 0.933 | 87.5% | 0.0% |
79
+ | eager | 0.649 | 0.987 | 0.987 | 0.968 | 93.8% | 100.0% |
80
+ | cautious | 0.312 | 0.435 | 0.435 | 0.222 | 12.5% | 0.0% |
81
+
82
+ *eager* fixes agreement errors as well as *careful*, but it also rewrites every correct sentence. Type-level F0.5 alone
83
+ would not show this.
84
+
85
+ ## What GECLens measures
86
+
87
+ For an error-type selector **T** (for example `R:VERB:SVA`):
88
+
89
+ | Measure | Definition |
90
+ |---|---|
91
+ | `overall` | ERRANT precision, recall and F0.5 over all edits |
92
+ | `type` | ERRANT P/R/F0.5 for edits of type T, for the annotator ERRANT selects per sentence (the one that maximises overall F0.5) |
93
+ | `type_only` | P/R/F0.5 after restricting hypothesis **and** reference edits to T, so the annotator choice depends on T edits only. Use it to compare systems that make different amounts of other edits. |
94
+ | `detection` | Sentence-level P/R/F1 for "the sentence contains a T error" |
95
+ | `success` | Share of T-positive sentences where every T edit of at least one annotator was produced exactly |
96
+ | `overcorrection` | Share of **unedited** reference sentences that the system changed (whitespace and quote style ignored) |
97
+ | `change_rate` | Share of all sentences the system changed |
98
+
99
+ **Subsets** (`geclens subset`): `positive` (some annotator made a T edit), `positive_all`, `unedited` (no annotator made
100
+ any edit), `unedited_any`, and `other`.
101
+
102
+ **Error-type selectors:**
103
+ - an exact type (`R:VERB:SVA`);
104
+ - a category, which matches its M/R/U variants (`DET`);
105
+ - a pattern (`R:VERB*`);
106
+ - a comma-separated list (`PREP,DET`).
107
+
108
+ ## Command line
109
+
110
+ ```bash
111
+ # subsets and reference statistics
112
+ geclens subset --ref ref.m2 --type R:VERB:SVA --out subsets.json
113
+
114
+ # scores with 95% bootstrap confidence intervals
115
+ geclens score --src src.txt --ref ref.m2 --type DET \
116
+ --hyp sysA=a.txt sysB=b.txt --ci 2000 --out-dir results
117
+
118
+ # paired approximate randomisation, Holm-adjusted, every system vs a baseline
119
+ geclens compare --src src.txt --ref ref.m2 --type PREP --hyp sysA=a.txt sysB=b.txt base=c.txt \
120
+ --baseline base --measures type_only overcorrection --shuffles 10000
121
+
122
+ # system x error-type heatmap of ERRANT F0.5
123
+ geclens profile --src src.txt --ref ref.m2 --hyp a.txt b.txt c.txt --group category --top 12
124
+
125
+ # everything from one configuration file
126
+ geclens run config.yaml
127
+ ```
128
+
129
+ Inputs:
130
+ - `--src`: tokenised source sentences, one per line.
131
+ - `--ref`: an ERRANT-typed reference M2 file. If you only have corrected text, create it with
132
+ `errant_parallel -orig src.txt -cor ref1.txt [ref2.txt ...] -out ref.m2`.
133
+ - `--hyp`: system outputs as text (one sentence per line) or as ready-made `.m2` files.
134
+
135
+ Hypothesis M2 files are built once with ERRANT and cached.
136
+
137
+ ## Python API
138
+
139
+ ```python
140
+ from geclens import score_many, bootstrap_ci, compare_systems, profile_table, evaluate
141
+
142
+ scores = score_many("src.txt", "ref.m2", {"A": "a.txt", "B": "b.txt"}, selector="R:VERB:SVA")
143
+ scores["A"].metrics["type_only"] # {'tp':…, 'fp':…, 'fn':…, 'P':…, 'R':…, 'F0.5':…}
144
+ bootstrap_ci(scores["A"], resamples=2000) # {'overall': (lo, hi), 'type': …, …}
145
+ compare_systems(scores, ["type_only", "overcorrection"], baseline="B")
146
+
147
+ evaluate("src.txt", "ref.m2", {"A": "a.txt", "B": "b.txt"}, selector="PREP", out_dir="results")
148
+ ```
149
+
150
+ ## Configuration file
151
+
152
+ ```yaml
153
+ source: src.txt
154
+ reference: ref.m2
155
+ out_dir: results
156
+ selectors: [R:VERB:SVA, PREP, DET] # one evaluation per selector
157
+ systems: {careful: careful.txt, eager: eager.txt, cautious: cautious.txt}
158
+ statistics: {bootstrap_resamples: 2000, shuffles: 10000, measures: [type_only, overcorrection], baseline: cautious, seed: 13}
159
+ profile: {group: category, top: 12}
160
+ ```
161
+
162
+ ## Output files
163
+
164
+ | File | Content |
165
+ |---|---|
166
+ | `scores.csv` / `.md` / `.tex` | One row per system; the Markdown and LaTeX tables include 95% CIs |
167
+ | `<system>.json` | All measures and per-type ERRANT counts |
168
+ | `confidence_intervals.json` | Bootstrap intervals |
169
+ | `comparisons.md` / `.json` | Differences, paired-bootstrap CIs, p-values and Holm-adjusted p-values |
170
+ | `scatter.png` | Type-only F0.5 against over-correction |
171
+ | `profile.png` / `.json` | System × error-type heatmap |
172
+ | `subsets.json`, `reference_stats.json` | Subset indices and reference statistics |
173
+
174
+ ## Correctness
175
+
176
+ - Hypothesis M2 files are byte-identical to `errant_parallel`, overall and per-type TP/FP/FN equal
177
+ `errant_compare -cat 3`, and the per-sentence counts equal those of the annotator that `errant_compare -v` reports
178
+ as chosen for each sentence (see `tests/test_score.py`
179
+ and `tests/test_edge_cases.py`). Check this on your own data with
180
+ `python scripts/check_against_errant.py src.txt ref.m2 sys1.txt [sys2.txt ...]`.
181
+ - A hand-built case with two annotators covers missed, unnecessary and replacement edits, two target edits in one
182
+ sentence, an unedited sentence and an empty subset; expected values were worked out by hand.
183
+ - Statistical routines are tested on known cases: identical systems give p = 1, Holm values are checked by hand, and
184
+ bootstrap intervals contain the point estimate.
185
+ - The CI runs the suite on Python 3.9–3.12.
186
+
187
+ ```bash
188
+ pip install -e ".[dev]" && pytest
189
+ ```
190
+
191
+ ## Citation
192
+
193
+ If you use GECLens, please cite the software (see `CITATION.cff`):
194
+
195
+ > P. Gautam, J. S. Thakur, A. Mishra. *GECLens: A Python toolkit for error-type-targeted evaluation of grammatical error
196
+ > correction systems.* Version 1.0.0, 2026. https://github.com/prakhargautam-ggits/GECLens
197
+
198
+ ## Licence and support
199
+
200
+ - MIT licence (`LICENSE`, `Licence.txt`).
201
+ - Questions and bug reports: [GitHub issues](https://github.com/prakhargautam-ggits/GECLens/issues) or
202
+ prakhargautam@jecjabalpur.ac.in.
@@ -0,0 +1,168 @@
1
+ # GECLens
2
+
3
+ **Error-type-targeted evaluation of grammatical error correction (GEC) systems.**
4
+
5
+ [![tests](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml/badge.svg)](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml)
6
+ ![python](https://img.shields.io/badge/python-3.9%E2%80%933.12-blue) ![license](https://img.shields.io/badge/license-MIT-green)
7
+
8
+ Overall F0.5 hides how a system handles a single kind of error. Subject–verb agreement, articles, prepositions and tense
9
+ each make up only a few per cent of all edits. GECLens answers two questions for any ERRANT error type:
10
+
11
+ 1. **Does the system fix these errors?** Type F0.5, type-only F0.5, sentence-level detection and correction success.
12
+ 2. **What does it break while trying?** Over-correction on sentences the references leave unchanged.
13
+
14
+ Every measure comes with bootstrap confidence intervals and paired significance tests (approximate randomisation with
15
+ Holm correction), for any number of systems. Results are written as CSV, Markdown and LaTeX tables and as figures.
16
+
17
+ GECLens builds on [ERRANT](https://github.com/chrisjbryant/errant) for alignment and edit typing. Its edit-level scores
18
+ are **identical** to `errant_compare`. This is checked in the test suite.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install geclens # or, from source: pip install -e .
26
+ python -m spacy download en_core_web_sm
27
+ ```
28
+
29
+ Requirements: Python 3.9–3.12, ERRANT 3.0.2, spaCy 3.4–3.7 with `en_core_web_sm`, NumPy, Matplotlib and PyYAML. GECLens
30
+ runs on Linux, macOS and Windows.
31
+
32
+ ## Quick start (toy data included)
33
+
34
+ ```bash
35
+ cd examples/toy
36
+ geclens run config.yaml # about 5 seconds
37
+ ```
38
+
39
+ This scores three toy systems on 30 sentences for three error types and writes `results/`. The toy data were written by
40
+ the GECLens authors and are released under CC0.
41
+
42
+ | System | Overall F0.5 | Type F0.5 | Type-only F0.5 | Detection F1 | Success | Over-corr. |
43
+ |---|---|---|---|---|---|---|
44
+ | careful | 0.882 | 0.972 | 0.972 | 0.933 | 87.5% | 0.0% |
45
+ | eager | 0.649 | 0.987 | 0.987 | 0.968 | 93.8% | 100.0% |
46
+ | cautious | 0.312 | 0.435 | 0.435 | 0.222 | 12.5% | 0.0% |
47
+
48
+ *eager* fixes agreement errors as well as *careful*, but it also rewrites every correct sentence. Type-level F0.5 alone
49
+ would not show this.
50
+
51
+ ## What GECLens measures
52
+
53
+ For an error-type selector **T** (for example `R:VERB:SVA`):
54
+
55
+ | Measure | Definition |
56
+ |---|---|
57
+ | `overall` | ERRANT precision, recall and F0.5 over all edits |
58
+ | `type` | ERRANT P/R/F0.5 for edits of type T, for the annotator ERRANT selects per sentence (the one that maximises overall F0.5) |
59
+ | `type_only` | P/R/F0.5 after restricting hypothesis **and** reference edits to T, so the annotator choice depends on T edits only. Use it to compare systems that make different amounts of other edits. |
60
+ | `detection` | Sentence-level P/R/F1 for "the sentence contains a T error" |
61
+ | `success` | Share of T-positive sentences where every T edit of at least one annotator was produced exactly |
62
+ | `overcorrection` | Share of **unedited** reference sentences that the system changed (whitespace and quote style ignored) |
63
+ | `change_rate` | Share of all sentences the system changed |
64
+
65
+ **Subsets** (`geclens subset`): `positive` (some annotator made a T edit), `positive_all`, `unedited` (no annotator made
66
+ any edit), `unedited_any`, and `other`.
67
+
68
+ **Error-type selectors:**
69
+ - an exact type (`R:VERB:SVA`);
70
+ - a category, which matches its M/R/U variants (`DET`);
71
+ - a pattern (`R:VERB*`);
72
+ - a comma-separated list (`PREP,DET`).
73
+
74
+ ## Command line
75
+
76
+ ```bash
77
+ # subsets and reference statistics
78
+ geclens subset --ref ref.m2 --type R:VERB:SVA --out subsets.json
79
+
80
+ # scores with 95% bootstrap confidence intervals
81
+ geclens score --src src.txt --ref ref.m2 --type DET \
82
+ --hyp sysA=a.txt sysB=b.txt --ci 2000 --out-dir results
83
+
84
+ # paired approximate randomisation, Holm-adjusted, every system vs a baseline
85
+ geclens compare --src src.txt --ref ref.m2 --type PREP --hyp sysA=a.txt sysB=b.txt base=c.txt \
86
+ --baseline base --measures type_only overcorrection --shuffles 10000
87
+
88
+ # system x error-type heatmap of ERRANT F0.5
89
+ geclens profile --src src.txt --ref ref.m2 --hyp a.txt b.txt c.txt --group category --top 12
90
+
91
+ # everything from one configuration file
92
+ geclens run config.yaml
93
+ ```
94
+
95
+ Inputs:
96
+ - `--src`: tokenised source sentences, one per line.
97
+ - `--ref`: an ERRANT-typed reference M2 file. If you only have corrected text, create it with
98
+ `errant_parallel -orig src.txt -cor ref1.txt [ref2.txt ...] -out ref.m2`.
99
+ - `--hyp`: system outputs as text (one sentence per line) or as ready-made `.m2` files.
100
+
101
+ Hypothesis M2 files are built once with ERRANT and cached.
102
+
103
+ ## Python API
104
+
105
+ ```python
106
+ from geclens import score_many, bootstrap_ci, compare_systems, profile_table, evaluate
107
+
108
+ scores = score_many("src.txt", "ref.m2", {"A": "a.txt", "B": "b.txt"}, selector="R:VERB:SVA")
109
+ scores["A"].metrics["type_only"] # {'tp':…, 'fp':…, 'fn':…, 'P':…, 'R':…, 'F0.5':…}
110
+ bootstrap_ci(scores["A"], resamples=2000) # {'overall': (lo, hi), 'type': …, …}
111
+ compare_systems(scores, ["type_only", "overcorrection"], baseline="B")
112
+
113
+ evaluate("src.txt", "ref.m2", {"A": "a.txt", "B": "b.txt"}, selector="PREP", out_dir="results")
114
+ ```
115
+
116
+ ## Configuration file
117
+
118
+ ```yaml
119
+ source: src.txt
120
+ reference: ref.m2
121
+ out_dir: results
122
+ selectors: [R:VERB:SVA, PREP, DET] # one evaluation per selector
123
+ systems: {careful: careful.txt, eager: eager.txt, cautious: cautious.txt}
124
+ statistics: {bootstrap_resamples: 2000, shuffles: 10000, measures: [type_only, overcorrection], baseline: cautious, seed: 13}
125
+ profile: {group: category, top: 12}
126
+ ```
127
+
128
+ ## Output files
129
+
130
+ | File | Content |
131
+ |---|---|
132
+ | `scores.csv` / `.md` / `.tex` | One row per system; the Markdown and LaTeX tables include 95% CIs |
133
+ | `<system>.json` | All measures and per-type ERRANT counts |
134
+ | `confidence_intervals.json` | Bootstrap intervals |
135
+ | `comparisons.md` / `.json` | Differences, paired-bootstrap CIs, p-values and Holm-adjusted p-values |
136
+ | `scatter.png` | Type-only F0.5 against over-correction |
137
+ | `profile.png` / `.json` | System × error-type heatmap |
138
+ | `subsets.json`, `reference_stats.json` | Subset indices and reference statistics |
139
+
140
+ ## Correctness
141
+
142
+ - Hypothesis M2 files are byte-identical to `errant_parallel`, overall and per-type TP/FP/FN equal
143
+ `errant_compare -cat 3`, and the per-sentence counts equal those of the annotator that `errant_compare -v` reports
144
+ as chosen for each sentence (see `tests/test_score.py`
145
+ and `tests/test_edge_cases.py`). Check this on your own data with
146
+ `python scripts/check_against_errant.py src.txt ref.m2 sys1.txt [sys2.txt ...]`.
147
+ - A hand-built case with two annotators covers missed, unnecessary and replacement edits, two target edits in one
148
+ sentence, an unedited sentence and an empty subset; expected values were worked out by hand.
149
+ - Statistical routines are tested on known cases: identical systems give p = 1, Holm values are checked by hand, and
150
+ bootstrap intervals contain the point estimate.
151
+ - The CI runs the suite on Python 3.9–3.12.
152
+
153
+ ```bash
154
+ pip install -e ".[dev]" && pytest
155
+ ```
156
+
157
+ ## Citation
158
+
159
+ If you use GECLens, please cite the software (see `CITATION.cff`):
160
+
161
+ > P. Gautam, J. S. Thakur, A. Mishra. *GECLens: A Python toolkit for error-type-targeted evaluation of grammatical error
162
+ > correction systems.* Version 1.0.0, 2026. https://github.com/prakhargautam-ggits/GECLens
163
+
164
+ ## Licence and support
165
+
166
+ - MIT licence (`LICENSE`, `Licence.txt`).
167
+ - Questions and bug reports: [GitHub issues](https://github.com/prakhargautam-ggits/GECLens/issues) or
168
+ prakhargautam@jecjabalpur.ac.in.
@@ -0,0 +1,14 @@
1
+ """GECLens: error-type-targeted evaluation of grammatical error correction systems."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .types import TypeSelector # noqa: E402,F401
6
+ from .data import Subsets, build_subsets, reference_stats # noqa: E402,F401
7
+ from .score import SystemScore, score_system # noqa: E402,F401
8
+ from .stats import approx_randomisation, bootstrap_ci, compare_systems, holm, paired_bootstrap_ci # noqa: E402,F401
9
+ from .profile import profile_table # noqa: E402,F401
10
+ from .pipeline import evaluate, score_many # noqa: E402,F401
11
+
12
+ __all__ = ["TypeSelector", "Subsets", "build_subsets", "reference_stats", "SystemScore", "score_system",
13
+ "approx_randomisation", "bootstrap_ci", "compare_systems", "holm", "paired_bootstrap_ci", "profile_table",
14
+ "evaluate", "score_many", "__version__"]
@@ -0,0 +1,75 @@
1
+ """Convert system output text into hypothesis M2 with ERRANT.
2
+
3
+ The loop follows ``errant_parallel`` line by line, so the resulting M2 is identical to the command-line tool. The ERRANT
4
+ annotator (spaCy model) is loaded once and reused for every system, and results are cached on disk by content hash.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import os
10
+ from typing import List, Optional
11
+
12
+ from .m2 import NOOP_LINE, read_lines
13
+
14
+ _ANNOTATOR = None
15
+
16
+
17
+ def get_annotator(lang: str = "en"):
18
+ global _ANNOTATOR
19
+ if _ANNOTATOR is None:
20
+ import errant # imported lazily: loading spaCy takes a few seconds
21
+
22
+ _ANNOTATOR = errant.load(lang)
23
+ return _ANNOTATOR
24
+
25
+
26
+ def align_lines(src: List[str], hyp: List[str], tokenise: bool = False, lev: bool = False,
27
+ merge: str = "rules", annotator=None) -> str:
28
+ """Return the M2 text for one system (one annotator, id 0)."""
29
+ if len(src) != len(hyp):
30
+ raise ValueError(f"source has {len(src)} lines but the system output has {len(hyp)}")
31
+ ann = annotator or get_annotator()
32
+ out = []
33
+ for i, (o, c) in enumerate(zip(src, hyp)):
34
+ o, c = o.strip(), c.strip()
35
+ if not o:
36
+ raise ValueError(f"empty source line {i + 1}: ERRANT would skip it and misalign the files")
37
+ orig = ann.parse(o, tokenise)
38
+ lines = ["S " + " ".join(t.text for t in orig)]
39
+ if orig.text.strip() == c:
40
+ lines.append(NOOP_LINE.format(a=0))
41
+ else:
42
+ cor = ann.parse(c, tokenise)
43
+ for edit in ann.annotate(orig, cor, lev, merge):
44
+ lines.append(edit.to_m2(0))
45
+ out.append("\n".join(lines))
46
+ return "".join(block + "\n\n" for block in out) # byte-identical to errant_parallel
47
+
48
+
49
+ def _digest(*parts: str) -> str:
50
+ h = hashlib.sha256()
51
+ for p in parts:
52
+ h.update(p.encode("utf-8"))
53
+ h.update(b"\0")
54
+ return h.hexdigest()[:20]
55
+
56
+
57
+ def hypothesis_m2(src_path: str, hyp_path: str, cache_dir: Optional[str] = None, tokenise: bool = False) -> str:
58
+ """Path of the hypothesis M2 for ``hyp_path``; built with ERRANT (and cached) if needed.
59
+
60
+ If ``hyp_path`` already ends in ``.m2`` it is returned unchanged.
61
+ """
62
+ if hyp_path.endswith(".m2"):
63
+ return hyp_path
64
+ src, hyp = read_lines(src_path), read_lines(hyp_path)
65
+ cache_dir = cache_dir or os.path.join(os.path.dirname(os.path.abspath(hyp_path)), ".geclens_cache")
66
+ os.makedirs(cache_dir, exist_ok=True)
67
+ key = _digest("align-v2", "\n".join(src), "\n".join(hyp), str(tokenise))
68
+ out = os.path.join(cache_dir, f"{os.path.splitext(os.path.basename(hyp_path))[0]}.{key}.m2")
69
+ if not os.path.exists(out):
70
+ text = align_lines(src, hyp, tokenise=tokenise)
71
+ tmp = out + ".tmp"
72
+ with open(tmp, "w", encoding="utf-8") as fh:
73
+ fh.write(text)
74
+ os.replace(tmp, out)
75
+ return out
@@ -0,0 +1,131 @@
1
+ """Command-line interface: ``geclens subset | score | compare | profile | run``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Dict, List
9
+
10
+ from . import __version__, report
11
+ from .data import build_subsets, reference_stats
12
+
13
+
14
+ def _systems(items: List[str]) -> Dict[str, str]:
15
+ out = {}
16
+ for it in items:
17
+ if "=" in it:
18
+ name, path = it.split("=", 1)
19
+ else:
20
+ path = it
21
+ name = os.path.splitext(os.path.basename(path))[0]
22
+ if name in out:
23
+ raise SystemExit(f"duplicate system name: {name}")
24
+ out[name] = path
25
+ return out
26
+
27
+
28
+ def _common(p: argparse.ArgumentParser, hyp: bool = True) -> None:
29
+ p.add_argument("--src", required=hyp, help="tokenised source sentences, one per line")
30
+ p.add_argument("--ref", required=True, help="reference M2 file (ERRANT edit types)")
31
+ if hyp:
32
+ p.add_argument("--hyp", nargs="+", required=True, metavar="NAME=PATH",
33
+ help="system outputs (text, one sentence per line, or .m2); NAME= is optional")
34
+ p.add_argument("--type", default="R:VERB:SVA", help="error-type selector, e.g. R:VERB:SVA, DET, 'R:VERB*', 'PREP,DET'")
35
+ p.add_argument("--tokenise", action="store_true", help="let ERRANT tokenise the inputs (default: pre-tokenised)")
36
+
37
+
38
+ def main(argv=None) -> int:
39
+ ap = argparse.ArgumentParser(prog="geclens", description="Error-type-targeted evaluation of GEC systems.")
40
+ ap.add_argument("--version", action="version", version=f"geclens {__version__}")
41
+ sp = ap.add_subparsers(dest="cmd", required=True)
42
+
43
+ p = sp.add_parser("subset", help="build type-targeted subsets and reference statistics")
44
+ _common(p, hyp=False)
45
+ p.add_argument("--out", default="subsets.json")
46
+
47
+ p = sp.add_parser("score", help="score systems (point estimates and optional bootstrap CIs)")
48
+ _common(p)
49
+ p.add_argument("--out-dir", default="geclens_out")
50
+ p.add_argument("--ci", type=int, default=0, metavar="B", help="bootstrap resamples for 95%% CIs (0 = off)")
51
+ p.add_argument("--seed", type=int, default=13)
52
+
53
+ p = sp.add_parser("compare", help="paired significance tests between systems (Holm-adjusted)")
54
+ _common(p)
55
+ p.add_argument("--measures", nargs="+", default=["type_only", "overcorrection"],
56
+ choices=["overall", "type", "type_only", "detection", "success", "overcorrection", "change_rate"])
57
+ p.add_argument("--baseline", help="compare every system with this one (default: all pairs)")
58
+ p.add_argument("--shuffles", type=int, default=10000)
59
+ p.add_argument("--resamples", type=int, default=2000, help="paired bootstrap resamples for CIs of differences")
60
+ p.add_argument("--seed", type=int, default=13)
61
+ p.add_argument("--out-dir", default="geclens_out")
62
+
63
+ p = sp.add_parser("profile", help="system x error-type heatmap of ERRANT F0.5")
64
+ _common(p)
65
+ p.add_argument("--group", choices=["type", "category"], default="category")
66
+ p.add_argument("--top", type=int, default=12, help="most frequent reference types/categories to show")
67
+ p.add_argument("--out-dir", default="geclens_out")
68
+
69
+ p = sp.add_parser("run", help="run a full evaluation from a YAML configuration file")
70
+ p.add_argument("config")
71
+
72
+ a = ap.parse_args(argv)
73
+
74
+ if a.cmd == "subset":
75
+ sub = build_subsets(a.ref, a.type)
76
+ sub.to_json(a.out)
77
+ print(json.dumps({**sub.sizes(), "selector": sub.selector}, indent=1))
78
+ return 0
79
+
80
+ if a.cmd == "run":
81
+ from .config import run_config
82
+ run_config(a.config)
83
+ return 0
84
+
85
+ from .pipeline import score_many
86
+ systems = _systems(a.hyp)
87
+ os.makedirs(a.out_dir, exist_ok=True)
88
+ scores = score_many(a.src, a.ref, systems, a.type, os.path.join(a.out_dir, "m2_cache"), a.tokenise)
89
+
90
+ if a.cmd == "score":
91
+ cis = None
92
+ if a.ci:
93
+ from .stats import bootstrap_ci
94
+ cis = {n: bootstrap_ci(s, resamples=a.ci, seed=a.seed) for n, s in scores.items()}
95
+ table = report.rows(scores, cis=cis)
96
+ report.write_csv(os.path.join(a.out_dir, "scores.csv"), scores)
97
+ md = report.markdown_table(table)
98
+ with open(os.path.join(a.out_dir, "scores.md"), "w", encoding="utf-8") as fh:
99
+ fh.write(md)
100
+ for n, s in scores.items():
101
+ s.to_json(os.path.join(a.out_dir, f"{n}.json"))
102
+ print(md)
103
+ return 0
104
+
105
+ if a.cmd == "compare":
106
+ from .stats import compare_systems
107
+ comps = compare_systems(scores, a.measures, baseline=a.baseline, shuffles=a.shuffles,
108
+ resamples=a.resamples, seed=a.seed)
109
+ md = report.comparisons_markdown(comps)
110
+ with open(os.path.join(a.out_dir, "comparisons.md"), "w", encoding="utf-8") as fh:
111
+ fh.write(md)
112
+ with open(os.path.join(a.out_dir, "comparisons.json"), "w", encoding="utf-8") as fh:
113
+ json.dump([c.as_dict() for c in comps], fh, indent=1)
114
+ print(md)
115
+ return 0
116
+
117
+ if a.cmd == "profile":
118
+ from .profile import profile_table, ranked_types
119
+ types = ranked_types(a.ref, a.group, a.top)
120
+ prof = profile_table(scores, group=a.group, types=types)
121
+ prof = {t: prof[t] for t in types if t in prof}
122
+ with open(os.path.join(a.out_dir, "profile.json"), "w", encoding="utf-8") as fh:
123
+ json.dump(prof, fh, indent=1)
124
+ report.heatmap(prof, os.path.join(a.out_dir, "profile.png"), systems=list(scores))
125
+ print(f"profile written to {a.out_dir}/profile.png")
126
+ return 0
127
+ return 1
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ """YAML configuration runner: one file describes data, systems, error types and statistics.
2
+
3
+ Example::
4
+
5
+ source: data/src.txt
6
+ reference: data/ref.m2
7
+ out_dir: results
8
+ selectors: [R:VERB:SVA, DET, PREP] # one evaluation per selector
9
+ systems:
10
+ baseline: outputs/baseline.txt
11
+ improved: outputs/improved.txt
12
+ statistics:
13
+ bootstrap_resamples: 2000
14
+ shuffles: 10000
15
+ measures: [type_only, overcorrection]
16
+ baseline: baseline
17
+ seed: 13
18
+ profile: {group: category, top: 12}
19
+
20
+ Relative paths are resolved against the directory of the configuration file.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+
26
+ import yaml
27
+
28
+ from .pipeline import evaluate
29
+
30
+
31
+ def run_config(path: str) -> dict:
32
+ with open(path, encoding="utf-8") as fh:
33
+ cfg = yaml.safe_load(fh)
34
+ base = os.path.dirname(os.path.abspath(path))
35
+ res = lambda p: p if os.path.isabs(p) else os.path.join(base, p)
36
+ selectors = cfg.get("selectors") or [cfg.get("selector", "R:VERB:SVA")]
37
+ stats = cfg.get("statistics", {}) or {}
38
+ prof = cfg.get("profile", {}) or {}
39
+ out_root = res(cfg.get("out_dir", "geclens_out"))
40
+ systems = {k: res(v) for k, v in cfg["systems"].items()}
41
+ results = {}
42
+ for i, sel in enumerate(selectors):
43
+ sub = out_root if len(selectors) == 1 else os.path.join(out_root, sel.replace(":", "_").replace(",", "+").replace("*", "x"))
44
+ results[sel] = evaluate(
45
+ res(cfg["source"]), res(cfg["reference"]), systems, selector=sel, out_dir=sub,
46
+ ci=stats.get("bootstrap_resamples", 2000) > 0, resamples=stats.get("bootstrap_resamples", 2000),
47
+ compare=stats.get("compare", True), measures=stats.get("measures", ["type_only", "overcorrection"]),
48
+ baseline=stats.get("baseline"), shuffles=stats.get("shuffles", 10000), seed=stats.get("seed", 13),
49
+ profile=prof.get("enabled", True) and i == 0, profile_group=prof.get("group", "category"),
50
+ profile_top=prof.get("top", 12), cache_dir=os.path.join(out_root, "m2_cache"),
51
+ tokenise=cfg.get("tokenise", False))
52
+ return results