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 +21 -0
- geclens-1.0.0/PKG-INFO +202 -0
- geclens-1.0.0/README.md +168 -0
- geclens-1.0.0/geclens/__init__.py +14 -0
- geclens-1.0.0/geclens/align.py +75 -0
- geclens-1.0.0/geclens/cli.py +131 -0
- geclens-1.0.0/geclens/config.py +52 -0
- geclens-1.0.0/geclens/data.py +82 -0
- geclens-1.0.0/geclens/m2.py +84 -0
- geclens-1.0.0/geclens/pipeline.py +97 -0
- geclens-1.0.0/geclens/profile.py +63 -0
- geclens-1.0.0/geclens/report.py +168 -0
- geclens-1.0.0/geclens/score.py +209 -0
- geclens-1.0.0/geclens/stats.py +154 -0
- geclens-1.0.0/geclens/types.py +61 -0
- geclens-1.0.0/geclens.egg-info/PKG-INFO +202 -0
- geclens-1.0.0/geclens.egg-info/SOURCES.txt +29 -0
- geclens-1.0.0/geclens.egg-info/dependency_links.txt +1 -0
- geclens-1.0.0/geclens.egg-info/entry_points.txt +2 -0
- geclens-1.0.0/geclens.egg-info/requires.txt +14 -0
- geclens-1.0.0/geclens.egg-info/top_level.txt +1 -0
- geclens-1.0.0/pyproject.toml +51 -0
- geclens-1.0.0/setup.cfg +4 -0
- geclens-1.0.0/tests/test_cli.py +22 -0
- geclens-1.0.0/tests/test_data.py +17 -0
- geclens-1.0.0/tests/test_edge_cases.py +73 -0
- geclens-1.0.0/tests/test_m2.py +16 -0
- geclens-1.0.0/tests/test_pipeline.py +32 -0
- geclens-1.0.0/tests/test_score.py +54 -0
- geclens-1.0.0/tests/test_stats.py +30 -0
- geclens-1.0.0/tests/test_types.py +16 -0
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
|
+
[](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml)
|
|
40
|
+
 
|
|
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.
|
geclens-1.0.0/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# GECLens
|
|
2
|
+
|
|
3
|
+
**Error-type-targeted evaluation of grammatical error correction (GEC) systems.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/prakhargautam-ggits/GECLens/actions/workflows/ci.yml)
|
|
6
|
+
 
|
|
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
|