kocer 0.1.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.
@@ -0,0 +1,19 @@
1
+ # v로 시작하는 태그(v0.1.0 등)를 푸시하면 테스트 후 PyPI에 올린다. 토큰 없이 Trusted Publishing 사용.
2
+ name: publish
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install -e ".[dev]" build && python -m pytest -q && python -m build
19
+ - uses: pypa/gh-action-pypi-publish@release/v1
kocer-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
kocer-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Choi Kwan-u
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.
kocer-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: kocer
3
+ Version: 0.1.0
4
+ Summary: 한국어 STT를 공정하게 채점하는 CER/WER — 띄어쓰기·숫자·문장부호 차이를 오류로 세지 않음
5
+ Project-URL: Repository, https://github.com/Choi-kwan-u/kocer
6
+ Author: Choi Kwan-u
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: asr,cer,evaluation,korean,stt,wer
10
+ Classifier: Natural Language :: Korean
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.9
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # kocer
19
+
20
+ 한국어 STT를 공정하게 채점하는 CER — 띄어쓰기·숫자·문장부호·영문 약어 표기 차이를 오류로 세지 않습니다.
21
+
22
+ *Fair CER/WER for Korean speech recognition. Spacing, punctuation, number notation (3개 ↔ 세 개) and Latin acronyms (AI ↔ 에이아이) are not counted as errors. Pure Python, zero dependencies, `jiwer`-compatible API.*
23
+
24
+ ```
25
+ ref: 사과 세 개 주세요
26
+ hyp: 사과 3개 주세요
27
+ jiwer CER 0.200 nlptutti CER 0.143 kocer CER 0.000
28
+ ```
29
+
30
+ ## 설치
31
+
32
+ ```bash
33
+ pip install git+https://github.com/Choi-kwan-u/kocer.git
34
+ ```
35
+
36
+ ## 사용
37
+
38
+ ```python
39
+ import kocer
40
+
41
+ kocer.cer("사과 세 개 주세요", "사과 3개 주세요") # 0.0
42
+ kocer.cer(refs, hyps) # 리스트도 됨 (jiwer와 같은 코퍼스 집계)
43
+ kocer.wer(ref, hyp)
44
+
45
+ r = kocer.measure("세 시 삼십 분", "3시 30분")
46
+ r.cer # 0.0 정규화 후
47
+ r.raw_cer # 0.625 정규화 전 (jiwer와 같은 값). 둘의 차이가 "가짜 오류"의 크기
48
+ r.ops # {'S': 0, 'D': 0, 'I': 0}
49
+ r.alignment # [('H', '세', '세'), ...]
50
+ r.chosen_ref # 숫자 후보 중 실제로 채택된 정답
51
+ r.chosen_hyp # '세시삼십분'
52
+ ```
53
+
54
+ ```bash
55
+ kocer ref.txt hyp.txt # 줄 단위 쌍, raw CER과 kocer CER을 같이 출력
56
+ kocer ref.txt hyp.txt --diff # 틀린 글자만 색으로 표시
57
+ ```
58
+
59
+ ## 무엇을 무시하고, 무엇을 세는가
60
+
61
+ 정규화는 순서대로 적용되고 각 단계를 끌 수 있습니다.
62
+
63
+ | 단계 | 기본 | 끄기 | 예 |
64
+ |---|---|---|---|
65
+ | 유니코드 NFC | 켬 | - | 자모 분리 입력 |
66
+ | 문장부호 제거 | 켬 | `punct=False` | `네, 알겠습니다.` = `네 알겠습니다` |
67
+ | 필러 제거 (음, 어, 그) | **끔** | `fillers=True` | 실제 단어 "그"도 지워질 수 있어 기본은 끔 |
68
+ | 영문 약어·단위 | 켬 | `letters=False` | `AI` = `에이아이`, `50%` = `50퍼센트` |
69
+ | 숫자 후보 전개 | 켬 | `numbers=False` | `3개` = `세 개`, `3층` = `삼 층` |
70
+ | 공백 제거 | CER만 | - | WER은 공백 유지 |
71
+
72
+ `normalize=False`면 jiwer와 같은 순수 CER을 돌려줍니다.
73
+
74
+ ### 숫자: 정답 하나가 아니라 후보 집합
75
+
76
+ 숫자는 조수사에 따라 읽기가 달라서(3개=세 개, 3층=삼 층, 3시 30분=세 시 삼십 분) 규칙 하나로 정답을 만들 수 없습니다. kocer는 숫자마다 `{원문 숫자, 한자어 읽기, 고유어 읽기, 자릿수 읽기}` 후보를 만들고 CER이 최소가 되는 조합을 고릅니다.
77
+
78
+ - 조수사 사전([counters.py](src/kocer/counters.py))에 있으면 그 읽기만, **없으면 두 읽기 모두 허용**합니다. 틀리게 감점하는 것보다 관대한 쪽이 안전하다는 정책입니다.
79
+ - 반대 방향(ref가 한글 수사, hyp가 숫자)도 처리합니다.
80
+ - 전화번호·연도는 자릿수 읽기(010 = 공일공, 2024 = 이공이사)도 후보에 들어갑니다.
81
+ - 고유어 읽기는 1~99까지. `100개`는 `백 개`만 허용합니다.
82
+
83
+ ### "너무 관대하다"는 비판에 대해
84
+
85
+ 정규화는 실제 오류를 숨길 수 있습니다. 그래서 `raw_cer`를 항상 같이 돌려주고, 모든 단계를 끌 수 있게 했습니다. 두 값을 같이 보고하세요.
86
+
87
+ ## 기존 도구 비교 (2026-10-23 실측)
88
+
89
+ nlptutti 0.0.0.24, jiwer 4.0.0. [tests/cases.tsv](tests/cases.tsv)의 케이스 일부.
90
+
91
+ | ref | hyp | jiwer | nlptutti | kocer |
92
+ |---|---|---|---|---|
93
+ | 사과 세 개 주세요 | 사과 3개 주세요 | 0.200 | 0.143 | 0.000 |
94
+ | 삼 층으로 가세요 | 3층으로 가세요 | 0.222 | 0.143 | 0.000 |
95
+ | 세 시 삼십 분 | 3시 30분 | 0.625 | 0.600 | 0.000 |
96
+ | 에이아이 모델 | AI 모델 | 0.571 | 0.667 | 0.000 |
97
+ | 네, 알겠습니다. | 네 알겠습니다 | 0.222 | 0.000 | 0.000 |
98
+ | 오늘날씨가좋다 | 오늘 날씨가 좋다 | 0.286 | 0.000 | 0.000 |
99
+
100
+ nlptutti는 띄어쓰기·문장부호를 이미 처리하고, CLI·코퍼스 비교 리포트·개체명 평가 등 kocer에 없는 기능이 많습니다. kocer의 차이는 숫자·영문 약어 정규화와 `raw_cer` 대비 표시입니다.
101
+
102
+ ## 한계
103
+
104
+ - WER은 띄어쓰기 차이를 오류로 셉니다. 한국어 WER의 본질적 한계이고, CER을 권합니다.
105
+ - 조수사 사전은 완전하지 않습니다. 사전에 없는 조수사는 두 읽기를 다 허용합니다. 케이스를 [tests/cases.tsv](tests/cases.tsv)에 추가하는 PR을 환영합니다.
106
+ - 입력은 텍스트 쌍만 받습니다. 음성 파일·모델 실행은 다루지 않습니다.
107
+
108
+ ## 라이선스
109
+
110
+ MIT
kocer-0.1.0/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # kocer
2
+
3
+ 한국어 STT를 공정하게 채점하는 CER — 띄어쓰기·숫자·문장부호·영문 약어 표기 차이를 오류로 세지 않습니다.
4
+
5
+ *Fair CER/WER for Korean speech recognition. Spacing, punctuation, number notation (3개 ↔ 세 개) and Latin acronyms (AI ↔ 에이아이) are not counted as errors. Pure Python, zero dependencies, `jiwer`-compatible API.*
6
+
7
+ ```
8
+ ref: 사과 세 개 주세요
9
+ hyp: 사과 3개 주세요
10
+ jiwer CER 0.200 nlptutti CER 0.143 kocer CER 0.000
11
+ ```
12
+
13
+ ## 설치
14
+
15
+ ```bash
16
+ pip install git+https://github.com/Choi-kwan-u/kocer.git
17
+ ```
18
+
19
+ ## 사용
20
+
21
+ ```python
22
+ import kocer
23
+
24
+ kocer.cer("사과 세 개 주세요", "사과 3개 주세요") # 0.0
25
+ kocer.cer(refs, hyps) # 리스트도 됨 (jiwer와 같은 코퍼스 집계)
26
+ kocer.wer(ref, hyp)
27
+
28
+ r = kocer.measure("세 시 삼십 분", "3시 30분")
29
+ r.cer # 0.0 정규화 후
30
+ r.raw_cer # 0.625 정규화 전 (jiwer와 같은 값). 둘의 차이가 "가짜 오류"의 크기
31
+ r.ops # {'S': 0, 'D': 0, 'I': 0}
32
+ r.alignment # [('H', '세', '세'), ...]
33
+ r.chosen_ref # 숫자 후보 중 실제로 채택된 정답
34
+ r.chosen_hyp # '세시삼십분'
35
+ ```
36
+
37
+ ```bash
38
+ kocer ref.txt hyp.txt # 줄 단위 쌍, raw CER과 kocer CER을 같이 출력
39
+ kocer ref.txt hyp.txt --diff # 틀린 글자만 색으로 표시
40
+ ```
41
+
42
+ ## 무엇을 무시하고, 무엇을 세는가
43
+
44
+ 정규화는 순서대로 적용되고 각 단계를 끌 수 있습니다.
45
+
46
+ | 단계 | 기본 | 끄기 | 예 |
47
+ |---|---|---|---|
48
+ | 유니코드 NFC | 켬 | - | 자모 분리 입력 |
49
+ | 문장부호 제거 | 켬 | `punct=False` | `네, 알겠습니다.` = `네 알겠습니다` |
50
+ | 필러 제거 (음, 어, 그) | **끔** | `fillers=True` | 실제 단어 "그"도 지워질 수 있어 기본은 끔 |
51
+ | 영문 약어·단위 | 켬 | `letters=False` | `AI` = `에이아이`, `50%` = `50퍼센트` |
52
+ | 숫자 후보 전개 | 켬 | `numbers=False` | `3개` = `세 개`, `3층` = `삼 층` |
53
+ | 공백 제거 | CER만 | - | WER은 공백 유지 |
54
+
55
+ `normalize=False`면 jiwer와 같은 순수 CER을 돌려줍니다.
56
+
57
+ ### 숫자: 정답 하나가 아니라 후보 집합
58
+
59
+ 숫자는 조수사에 따라 읽기가 달라서(3개=세 개, 3층=삼 층, 3시 30분=세 시 삼십 분) 규칙 하나로 정답을 만들 수 없습니다. kocer는 숫자마다 `{원문 숫자, 한자어 읽기, 고유어 읽기, 자릿수 읽기}` 후보를 만들고 CER이 최소가 되는 조합을 고릅니다.
60
+
61
+ - 조수사 사전([counters.py](src/kocer/counters.py))에 있으면 그 읽기만, **없으면 두 읽기 모두 허용**합니다. 틀리게 감점하는 것보다 관대한 쪽이 안전하다는 정책입니다.
62
+ - 반대 방향(ref가 한글 수사, hyp가 숫자)도 처리합니다.
63
+ - 전화번호·연도는 자릿수 읽기(010 = 공일공, 2024 = 이공이사)도 후보에 들어갑니다.
64
+ - 고유어 읽기는 1~99까지. `100개`는 `백 개`만 허용합니다.
65
+
66
+ ### "너무 관대하다"는 비판에 대해
67
+
68
+ 정규화는 실제 오류를 숨길 수 있습니다. 그래서 `raw_cer`를 항상 같이 돌려주고, 모든 단계를 끌 수 있게 했습니다. 두 값을 같이 보고하세요.
69
+
70
+ ## 기존 도구 비교 (2026-10-23 실측)
71
+
72
+ nlptutti 0.0.0.24, jiwer 4.0.0. [tests/cases.tsv](tests/cases.tsv)의 케이스 일부.
73
+
74
+ | ref | hyp | jiwer | nlptutti | kocer |
75
+ |---|---|---|---|---|
76
+ | 사과 세 개 주세요 | 사과 3개 주세요 | 0.200 | 0.143 | 0.000 |
77
+ | 삼 층으로 가세요 | 3층으로 가세요 | 0.222 | 0.143 | 0.000 |
78
+ | 세 시 삼십 분 | 3시 30분 | 0.625 | 0.600 | 0.000 |
79
+ | 에이아이 모델 | AI 모델 | 0.571 | 0.667 | 0.000 |
80
+ | 네, 알겠습니다. | 네 알겠습니다 | 0.222 | 0.000 | 0.000 |
81
+ | 오늘날씨가좋다 | 오늘 날씨가 좋다 | 0.286 | 0.000 | 0.000 |
82
+
83
+ nlptutti는 띄어쓰기·문장부호를 이미 처리하고, CLI·코퍼스 비교 리포트·개체명 평가 등 kocer에 없는 기능이 많습니다. kocer의 차이는 숫자·영문 약어 정규화와 `raw_cer` 대비 표시입니다.
84
+
85
+ ## 한계
86
+
87
+ - WER은 띄어쓰기 차이를 오류로 셉니다. 한국어 WER의 본질적 한계이고, CER을 권합니다.
88
+ - 조수사 사전은 완전하지 않습니다. 사전에 없는 조수사는 두 읽기를 다 허용합니다. 케이스를 [tests/cases.tsv](tests/cases.tsv)에 추가하는 PR을 환영합니다.
89
+ - 입력은 텍스트 쌍만 받습니다. 음성 파일·모델 실행은 다루지 않습니다.
90
+
91
+ ## 라이선스
92
+
93
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kocer"
7
+ version = "0.1.0"
8
+ description = "한국어 STT를 공정하게 채점하는 CER/WER — 띄어쓰기·숫자·문장부호 차이를 오류로 세지 않음"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Choi Kwan-u" }]
13
+ keywords = ["korean", "stt", "asr", "cer", "wer", "evaluation"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Natural Language :: Korean",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/Choi-kwan-u/kocer"
23
+
24
+ [project.scripts]
25
+ kocer = "kocer.cli:main"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/kocer"]
@@ -0,0 +1,80 @@
1
+ """kocer — 한국어 STT를 공정하게 채점하는 CER/WER.
2
+ 띄어쓰기·문장부호·숫자 표기·영문 약어 차이를 오류로 세지 않는다."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Sequence, Union
7
+
8
+ from . import numbers as _numbers
9
+ from .align import Op, align, distance
10
+ from .normalize import normalize as _normalize
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["cer", "wer", "measure", "Result"]
14
+
15
+ Text = Union[str, Sequence[str]]
16
+
17
+
18
+ @dataclass
19
+ class Result:
20
+ cer: float # 정규화 후 오류율 (unit="word"면 WER)
21
+ raw_cer: float # 정규화 전 오류율 (jiwer와 같은 값)
22
+ hits: int
23
+ substitutions: int
24
+ deletions: int
25
+ insertions: int
26
+ alignment: List[Op] = field(repr=False)
27
+ chosen_ref: str = "" # 숫자 후보 중 실제로 채택된 정답
28
+ chosen_hyp: str = ""
29
+
30
+ @property
31
+ def ops(self):
32
+ return {"S": self.substitutions, "D": self.deletions, "I": self.insertions}
33
+
34
+ @property
35
+ def errors(self) -> int:
36
+ return self.substitutions + self.deletions + self.insertions
37
+
38
+
39
+ def _units(text: str, unit: str) -> List[str]:
40
+ return text.split() if unit == "word" else list(text.replace(" ", ""))
41
+
42
+
43
+ def measure(reference: str, hypothesis: str, *, unit: str = "char", normalize: bool = True,
44
+ numbers: bool = True, punct: bool = True, fillers: bool = False, letters: bool = True) -> Result:
45
+ raw_ref, raw_hyp = (reference.split(), hypothesis.split()) if unit == "word" else (list(reference), list(hypothesis))
46
+ raw = distance(raw_ref, raw_hyp) / max(len(raw_ref), 1)
47
+
48
+ ref, hyp = reference, hypothesis
49
+ if normalize:
50
+ ref = _normalize(reference, punct, fillers, letters)
51
+ hyp = _normalize(hypothesis, punct, fillers, letters)
52
+ if numbers:
53
+ # shortcut: hyp 후보를 먼저 ref 원문에 맞춰 고르고, 그다음 ref 후보를 고른 hyp에 맞춤(양쪽 곱집합 아님), 혼합 표기에서 틀리면 곱집합으로 올릴 것
54
+ hyp = _numbers.best(hyp, lambda c: distance(_units(ref, unit), _units(c, unit)))
55
+ ref = _numbers.best(ref, lambda c: distance(_units(c, unit), _units(hyp, unit)))
56
+ r, h = (_units(ref, unit), _units(hyp, unit)) if normalize else (raw_ref, raw_hyp)
57
+ ops = align(r, h)
58
+ counts = {k: sum(op == k for op, _, _ in ops) for k in "HSDI"}
59
+ err = counts["S"] + counts["D"] + counts["I"]
60
+ return Result(err / max(len(r), 1), raw, counts["H"], counts["S"], counts["D"], counts["I"], ops, ref, hyp)
61
+
62
+
63
+ def _rate(reference: Text, hypothesis: Text, unit: str, **opts) -> float:
64
+ if isinstance(reference, str):
65
+ reference, hypothesis = [reference], [hypothesis]
66
+ if len(reference) != len(hypothesis):
67
+ raise ValueError("reference and hypothesis must have the same length")
68
+ results = [measure(r, h, unit=unit, **opts) for r, h in zip(reference, hypothesis)]
69
+ total = sum(x.hits + x.substitutions + x.deletions for x in results)
70
+ return sum(x.errors for x in results) / max(total, 1)
71
+
72
+
73
+ def cer(reference: Text, hypothesis: Text, **opts) -> float:
74
+ """jiwer.cer 호환. 문자열 하나 또는 리스트."""
75
+ return _rate(reference, hypothesis, "char", **opts)
76
+
77
+
78
+ def wer(reference: Text, hypothesis: Text, **opts) -> float:
79
+ """jiwer.wer 호환. 띄어쓰기 차이는 오류로 센다 (한국어 WER의 한계)."""
80
+ return _rate(reference, hypothesis, "word", **opts)
@@ -0,0 +1,33 @@
1
+ """편집거리 + 정렬. 문자 리스트든 단어 리스트든 같은 코드."""
2
+ from typing import List, Sequence, Tuple
3
+
4
+ Op = Tuple[str, str, str] # ('H'|'S'|'D'|'I', ref_item, hyp_item)
5
+
6
+
7
+ def align(ref: Sequence[str], hyp: Sequence[str]) -> List[Op]:
8
+ n, m = len(ref), len(hyp)
9
+ d = [[0] * (m + 1) for _ in range(n + 1)]
10
+ for i in range(1, n + 1):
11
+ d[i][0] = i
12
+ for j in range(1, m + 1):
13
+ d[0][j] = j
14
+ for i in range(1, n + 1):
15
+ for j in range(1, m + 1):
16
+ d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (ref[i - 1] != hyp[j - 1]))
17
+ ops: List[Op] = []
18
+ i, j = n, m
19
+ while i or j:
20
+ if i and j and d[i][j] == d[i - 1][j - 1] + (ref[i - 1] != hyp[j - 1]):
21
+ ops.append(("H" if ref[i - 1] == hyp[j - 1] else "S", ref[i - 1], hyp[j - 1]))
22
+ i, j = i - 1, j - 1
23
+ elif i and d[i][j] == d[i - 1][j] + 1:
24
+ ops.append(("D", ref[i - 1], ""))
25
+ i -= 1
26
+ else:
27
+ ops.append(("I", "", hyp[j - 1]))
28
+ j -= 1
29
+ return ops[::-1]
30
+
31
+
32
+ def distance(ref: Sequence[str], hyp: Sequence[str]) -> int:
33
+ return sum(op != "H" for op, _, _ in align(ref, hyp))
@@ -0,0 +1,51 @@
1
+ """kocer ref.txt hyp.txt [--diff] — 줄 단위 쌍을 채점."""
2
+ import argparse
3
+ import sys
4
+
5
+ from . import __version__, measure
6
+
7
+ RED, GREEN, STRIKE, END = "\x1b[31m", "\x1b[32m", "\x1b[9;31m", "\x1b[0m"
8
+
9
+
10
+ def render(alignment) -> str:
11
+ out = []
12
+ for op, r, h in alignment:
13
+ if op == "H":
14
+ out.append(r)
15
+ elif op == "S":
16
+ out.append(f"{RED}{h}{END}")
17
+ elif op == "D":
18
+ out.append(f"{STRIKE}{r}{END}")
19
+ else:
20
+ out.append(f"{GREEN}{h}{END}")
21
+ return "".join(out)
22
+
23
+
24
+ def main(argv=None) -> int:
25
+ p = argparse.ArgumentParser(prog="kocer", description=__doc__)
26
+ p.add_argument("ref")
27
+ p.add_argument("hyp")
28
+ p.add_argument("--diff", action="store_true", help="틀린 글자를 색으로 표시 (빨강=치환/삭제, 초록=삽입)")
29
+ p.add_argument("--no-numbers", action="store_true", help="숫자 정규화 끔")
30
+ p.add_argument("--version", action="version", version=__version__)
31
+ a = p.parse_args(argv)
32
+ refs = open(a.ref, encoding="utf-8").read().splitlines()
33
+ hyps = open(a.hyp, encoding="utf-8").read().splitlines()
34
+ if len(refs) != len(hyps):
35
+ sys.exit(f"줄 수가 다릅니다: ref {len(refs)}, hyp {len(hyps)}")
36
+ err = raw_err = total = 0
37
+ for i, (r, h) in enumerate(zip(refs, hyps), 1):
38
+ m = measure(r, h, numbers=not a.no_numbers)
39
+ n = m.hits + m.substitutions + m.deletions
40
+ err += m.errors
41
+ raw_err += round(m.raw_cer * max(len(r), 1))
42
+ total += n
43
+ if a.diff and m.errors:
44
+ print(f"{i}: {render(m.alignment)} (cer {m.cer:.3f})")
45
+ print(f"raw CER {raw_err / max(sum(map(len, refs)), 1):.4f}")
46
+ print(f"kocer CER {err / max(total, 1):.4f} ({len(refs)} lines)")
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ """조수사 사전. 고유어 읽기(세 개) / 한자어 읽기(삼 층) 중 어느 쪽이 쓰이는지.
2
+ 사전에 없는 조수사는 두 읽기를 모두 후보로 넣는다 (틀리게 감점하는 것보다 관대한 쪽이 안전)."""
3
+
4
+ NATIVE = {
5
+ "개", "명", "시", "시간", "살", "마리", "번", "번째", "잔", "병", "권", "장", "대", "그루",
6
+ "송이", "사람", "켤레", "벌", "채", "척", "통", "자루", "줄", "곳", "군데", "가지", "달",
7
+ "끼", "판", "조각", "알", "포기", "다발", "봉지", "상자", "컵", "접시", "쌍", "팀", "배",
8
+ "편", "곡", "줄기", "개월", # 개월은 "삼 개월"이 표준이지만 "세 개월"도 흔히 쓰임 → 아래 SINO에도 넣어 둘 다 허용
9
+ }
10
+ SINO = {
11
+ "층", "원", "년", "월", "일", "호", "초", "세", "도", "퍼센트", "미터", "킬로미터", "킬로그램",
12
+ "그램", "리터", "번지", "학년", "반", "쪽", "페이지", "교시", "차", "회", "주", "개월", "세기",
13
+ "등", "위", "점", "인분", "인", "호선", "분기", "단계", "차선", "번길", "동", "급", "차례",
14
+ }
15
+ # 둘 다에 있으면 lookup은 None(둘 다 허용)으로 처리된다
16
+ _BOTH = NATIVE & SINO
17
+ MAX_LEN = max(map(len, NATIVE | SINO))
18
+
19
+
20
+ def lookup(following: str):
21
+ """숫자 뒤에 오는 글자들에서 가장 긴 조수사를 찾아 'native' | 'sino' | None 반환."""
22
+ for n in range(min(MAX_LEN, len(following)), 0, -1):
23
+ word = following[:n]
24
+ if word in _BOTH:
25
+ return None
26
+ if word in NATIVE:
27
+ return "native"
28
+ if word in SINO:
29
+ return "sino"
30
+ return None
@@ -0,0 +1,48 @@
1
+ """단계별 정규화. 각 단계는 독립 함수, normalize()가 순서대로 적용."""
2
+ import re
3
+ import unicodedata
4
+
5
+ # 숫자 사이의 . 과 , (3.5 / 1,000)는 남긴다
6
+ _PUNCT = re.compile(r"(?<!\d)[.,]|[.,](?!\d)|[?!~…·'\"‘’“”()\[\]{}<>:;/\\_\-—–、。,!?「」『』【】]")
7
+ _THOUSANDS = re.compile(r"(?<=\d),(?=\d)")
8
+ _FILLER = re.compile(r"(?<!\S)(?:음+|어+|아+|에+|그+)(?!\S)")
9
+ _LETTERS = dict(zip(
10
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
11
+ "에이 비 시 디 이 에프 지 에이치 아이 제이 케이 엘 엠 엔 오 피 큐 알 에스 티 유 브이 더블유 엑스 와이 지".split(),
12
+ ))
13
+ _UNITS = {"%": "퍼센트", "km": "킬로미터", "kg": "킬로그램", "cm": "센티미터", "mm": "밀리미터", "ml": "밀리리터"}
14
+ _UNIT_RE = re.compile(r"(?<=\d)\s*(" + "|".join(map(re.escape, sorted(_UNITS, key=len, reverse=True))) + r")(?![A-Za-z])")
15
+ _CAPS = re.compile(r"[A-Z]+")
16
+
17
+
18
+ def nfc(text: str) -> str:
19
+ return unicodedata.normalize("NFC", text)
20
+
21
+
22
+ def strip_punct(text: str) -> str:
23
+ return _PUNCT.sub(" ", _THOUSANDS.sub("", text))
24
+
25
+
26
+ def strip_fillers(text: str) -> str:
27
+ return _FILLER.sub(" ", text)
28
+
29
+
30
+ def spell_letters(text: str) -> str:
31
+ """AI → 에이아이, 50% → 50퍼센트. 대문자 연속과 단위 기호만 바꾼다."""
32
+ text = _UNIT_RE.sub(lambda m: _UNITS[m.group(1)], text)
33
+ return _CAPS.sub(lambda m: "".join(_LETTERS[c] for c in m.group()), text)
34
+
35
+
36
+ def squeeze(text: str) -> str:
37
+ return " ".join(text.split())
38
+
39
+
40
+ def normalize(text: str, punct: bool = True, fillers: bool = False, letters: bool = True) -> str:
41
+ text = nfc(text)
42
+ if punct:
43
+ text = strip_punct(text)
44
+ if fillers:
45
+ text = strip_fillers(text)
46
+ if letters:
47
+ text = spell_letters(text)
48
+ return squeeze(text)
@@ -0,0 +1,103 @@
1
+ """숫자 읽기와 후보 전개."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from itertools import product
6
+ from typing import Callable, List, Sequence, Tuple
7
+
8
+ from .counters import lookup
9
+
10
+ NUMBER = re.compile(r"\d+(?:\.\d+)?") # 천 단위 콤마는 normalize 단계에서 이미 제거됨
11
+
12
+ _DIGIT = "영일이삼사오육칠팔구"
13
+ _SMALL = ["", "십", "백", "천"]
14
+ _BIG = ["", "만", "억", "조", "경"]
15
+ _NATIVE_TENS = ["", "열", "스물", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"]
16
+ _NATIVE_ONES = ["", "한", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉"]
17
+
18
+
19
+ def digitwise(s: str) -> str:
20
+ """010 → 공일공, 1234 → 일이삼사 (전화번호·코드)."""
21
+ return "".join("공" if c == "0" else _DIGIT[int(c)] for c in s if c.isdigit())
22
+
23
+
24
+ def _sino_small(n: int) -> str:
25
+ out = []
26
+ for pos in (3, 2, 1, 0):
27
+ d = n // 10**pos % 10
28
+ if d == 0:
29
+ continue
30
+ out.append(("" if d == 1 and pos else _DIGIT[d]) + _SMALL[pos])
31
+ return "".join(out)
32
+
33
+
34
+ def sino(s: str) -> str:
35
+ """한자어 읽기. 2024 → 이천이십사, 15000 → 만오천, 3.5 → 삼점오."""
36
+ if "." in s:
37
+ whole, frac = s.split(".", 1)
38
+ return sino(whole) + "점" + digitwise(frac)
39
+ if s.startswith("0") and len(s) > 1:
40
+ return digitwise(s)
41
+ n = int(s)
42
+ if n == 0:
43
+ return "영"
44
+ parts = []
45
+ i = 0
46
+ while n:
47
+ g, n = n % 10000, n // 10000
48
+ if g:
49
+ parts.append(("" if g == 1 and i == 1 else _sino_small(g)) + _BIG[i])
50
+ i += 1
51
+ return "".join(reversed(parts))
52
+
53
+
54
+ def native(s: str):
55
+ """고유어 관형형 읽기 (3 → 세, 20 → 스무, 21 → 스물한). 1~99만 지원, 그 외 None."""
56
+ if not s.isdigit() or not 1 <= int(s) <= 99:
57
+ return None
58
+ n = int(s)
59
+ if n == 20:
60
+ return "스무"
61
+ return _NATIVE_TENS[n // 10] + _NATIVE_ONES[n % 10]
62
+
63
+
64
+ def options(num: str, following: str) -> List[str]:
65
+ """숫자 하나의 읽기 후보. 원문 숫자는 항상 포함."""
66
+ kind = lookup(following.lstrip())
67
+ opts = [num]
68
+ nat = native(num) if kind != "sino" else None
69
+ if nat:
70
+ opts.append(nat)
71
+ if kind != "native" or not nat: # 고유어 읽기가 없는 수(100개 → 백 개)는 한자어로
72
+ opts.append(sino(num))
73
+ if len(num) >= 2 and num.isdigit() and not num.startswith("0"):
74
+ opts.append(digitwise(num)) # 2024 → 이공이사, 전화번호 등
75
+ return list(dict.fromkeys(opts))
76
+
77
+
78
+ def _spans(text: str) -> List[Tuple[int, int, List[str]]]:
79
+ return [(m.start(), m.end(), options(m.group(), text[m.end():m.end() + 6])) for m in NUMBER.finditer(text)]
80
+
81
+
82
+ def _build(text: str, spans, choice: Sequence[str]) -> str:
83
+ for (start, end, _), rep in sorted(zip(spans, choice), reverse=True):
84
+ text = text[:start] + rep + text[end:]
85
+ return text
86
+
87
+
88
+ def best(text: str, score: Callable[[str], float], limit: int = 1024) -> str:
89
+ """text의 숫자들을 읽기 후보로 바꿔 본 문자열 중 score가 최소인 것."""
90
+ spans = _spans(text)
91
+ if not spans:
92
+ return text
93
+ total = 1
94
+ for _, _, o in spans:
95
+ total *= len(o)
96
+ if total <= limit:
97
+ return min((_build(text, spans, c) for c in product(*(o for _, _, o in spans))), key=score)
98
+ # shortcut: 후보 조합이 limit 초과면 구간별로 따로 고름(다른 구간은 원문 숫자 유지), 인접 구간 간섭으로 틀리면 묶음 전개로 올릴 것
99
+ base = [o[0] for _, _, o in spans]
100
+ choice = []
101
+ for i, (_, _, o) in enumerate(spans):
102
+ choice.append(min(o, key=lambda opt: score(_build(text, spans, base[:i] + [opt] + base[i + 1:]))))
103
+ return _build(text, spans, choice)
@@ -0,0 +1,103 @@
1
+ ref hyp cer
2
+ 네, 알겠습니다. 네 알겠습니다 0
3
+ 안녕하세요! 안녕하세요 0
4
+ 뭐라고요? 뭐라고요 0
5
+ 음... 그래요 음 그래요 0
6
+ "안녕"이라고 말했다 안녕이라고 말했다 0
7
+ (웃음) 좋네요 웃음 좋네요 0
8
+ 서울·부산 서울 부산 0
9
+ 그래~ 알았어 그래 알았어 0
10
+ 첫째, 둘째, 셋째 첫째 둘째 셋째 0
11
+ 안녕하세요. 반갑습니다. 안녕하세요 반갑습니다 0
12
+ 좋아요… 그럼요 좋아요 그럼요 0
13
+ ‘따옴표’ 테스트 따옴표 테스트 0
14
+ [자막] 시작합니다 자막 시작합니다 0
15
+ 가-나-다 가나다 0
16
+ 오늘날씨가좋다 오늘 날씨가 좋다 0
17
+ 아버지가 방에 들어가신다 아버지가방에들어가신다 0
18
+ 잘 지내세요 잘 지내세요 0
19
+ 한국어 음성 인식 한국어음성인식 0
20
+ 안녕 하세요 안녕하세요 0
21
+ 이것은 테스트 입니다 이것은 테스트입니다 0
22
+ 에이아이 모델 AI 모델 0
23
+ 케이티엑스 타고 갔어 KTX 타고 갔어 0
24
+ 유에스비 꽂아 USB 꽂아 0
25
+ 에스케이티 요금제 SKT 요금제 0
26
+ 티브이 켜줘 TV 켜줘 0
27
+ 지피에스 켜 GPS 켜 0
28
+ AI 모델 에이아이 모델 0
29
+ 사과 세 개 주세요 사과 3개 주세요 0
30
+ 사과 한 개 사과 1개 0
31
+ 두 명이요 2명이요 0
32
+ 네 마리 4마리 0
33
+ 다섯 번째 5번째 0
34
+ 열 개 10개 0
35
+ 열한 명 11명 0
36
+ 스무 살 20살 0
37
+ 스물한 살 21살 0
38
+ 서른 명 30명 0
39
+ 세 시간 걸려 3시간 걸려 0
40
+ 커피 두 잔 커피 2잔 0
41
+ 책 세 권 책 3권 0
42
+ 두 배 2배 0
43
+ 세 달 동안 3달 동안 0
44
+ 아흔아홉 개 99개 0
45
+ 삼 층으로 가세요 3층으로 가세요 0
46
+ 삼층으로 가세요 3층으로 가세요 0
47
+ 이천이십사 년 2024년 0
48
+ 십이 월 이십오 일 12월 25일 0
49
+ 오천 원 5000원 0
50
+ 오천 원 5,000원 0
51
+ 백만 원 1,000,000원 0
52
+ 만 원 10000원 0
53
+ 만 오천 원 15000원 0
54
+ 삼십 분 30분 0
55
+ 사십오 초 45초 0
56
+ 이십 세 20세 0
57
+ 삼 학년 3학년 0
58
+ 오십 퍼센트 50% 0
59
+ 삼 킬로미터 3km 0
60
+ 백 점 100점 0
61
+ 백 개 100개 0
62
+ 이백삼십 쪽 230쪽 0
63
+ 이 호선 2호선 0
64
+ 삼천 원 3천 원 0
65
+ 십억 10억 0
66
+ 세 시 삼십 분 3시 30분 0
67
+ 열두 시 오 분 12시 5분 0
68
+ 세 시 삼십 분 3시 삼십 분 0
69
+ 삼 월 삼 일 3월 3일 0
70
+ 이천이십오 년 삼 월 2025년 3월 0
71
+ 사과 두 개 천 원 사과 2개 1000원 0
72
+ 삼 층 세 개 3층 3개 0
73
+ 열 시 십 분 전 10시 10분 전 0
74
+ 두 명이 삼 층에 2명이 3층에 0
75
+ 오 분 뒤에 세 명 5분 뒤에 3명 0
76
+ 삼 점 오 3.5 0
77
+ 영 점 오 퍼센트 0.5% 0
78
+ 공일공 010 0
79
+ 공일공 일이삼사 오육칠팔 010-1234-5678 0
80
+ 이공이사 년 2024년 0
81
+ 영 도 0도 0
82
+ 천 원 1,000원 0
83
+ 3개 세 개 0
84
+ 3층 삼 층 0
85
+ 3시 30분 세 시 삼십 분 0
86
+ 2024년 이천이십사 년 0
87
+ 3개 3개 0
88
+ 사과 3개 사과 세개 0
89
+ 사과 사가 0.5
90
+ 삼 층으로 가세요 세 층으로 가세요 0.142857
91
+ 3층 세층 0.5
92
+ 3개 삼개 0.5
93
+ 안녕하세요 안녕하세요 반갑습니다 1.0
94
+ 안녕하세요 반갑습니다 안녕하세요 0.5
95
+ 세 개 네 개 0.5
96
+ 열 명 여덟 명 1.0
97
+ 30분 3분 0.333333
98
+ 네, 알겟습니다. 네 알겠습니다요 0.333333
99
+ AI 모델 에이 모델 0.333333
100
+ 세 시 삼십 분 3시 13분 0.4
101
+ 사과 세 개 주세요 사과 3개 주세 0.142857
102
+ 오늘 날씨가 좋다 오늘 날씨가 춥다 0.142857
103
+ 안녕 1.0
@@ -0,0 +1,58 @@
1
+ import csv
2
+ import pathlib
3
+ import unicodedata
4
+
5
+ import pytest
6
+
7
+ import kocer
8
+ from kocer import numbers
9
+
10
+ ROWS = list(csv.DictReader(open(pathlib.Path(__file__).with_name("cases.tsv"), encoding="utf-8"), delimiter="\t"))
11
+
12
+
13
+ @pytest.mark.parametrize("row", ROWS, ids=[f"{r['ref']}|{r['hyp']}" for r in ROWS])
14
+ def test_golden(row):
15
+ assert kocer.cer(row["ref"], row["hyp"]) == pytest.approx(float(row["cer"]), abs=1e-4)
16
+
17
+
18
+ def test_raw_cer_is_reported_alongside():
19
+ r = kocer.measure("네, 알겠습니다.", "네 알겠습니다")
20
+ assert r.cer == 0 and r.raw_cer > 0
21
+ assert kocer.cer("네, 알겠습니다.", "네 알겠습니다", normalize=False) == pytest.approx(r.raw_cer)
22
+
23
+
24
+ def test_numbers_can_be_turned_off():
25
+ assert kocer.cer("세 개", "3개", numbers=False) > 0
26
+
27
+
28
+ def test_nfd_input():
29
+ assert kocer.cer(unicodedata.normalize("NFD", "한글 테스트"), "한글 테스트") == 0
30
+
31
+
32
+ def test_list_input_matches_jiwer_style_corpus_rate():
33
+ refs, hyps = ["가나", "다라마"], ["가나", "다라바"]
34
+ assert kocer.cer(refs, hyps) == pytest.approx(1 / 5)
35
+
36
+
37
+ def test_wer_basic():
38
+ assert kocer.wer("오늘 날씨 좋다", "오늘 날씨 좋다") == 0
39
+ assert kocer.wer("오늘 날씨 좋다", "오늘 날씨 춥다") == pytest.approx(1 / 3)
40
+
41
+
42
+ @pytest.mark.parametrize("n,expect", [("0", "영"), ("7", "칠"), ("10", "십"), ("11", "십일"), ("100", "백"),
43
+ ("2024", "이천이십사"), ("10000", "만"), ("15000", "만오천"),
44
+ ("100000000", "일억"), ("1000000", "백만"), ("3.5", "삼점오"), ("010", "공일공")])
45
+ def test_sino(n, expect):
46
+ assert numbers.sino(n) == expect
47
+
48
+
49
+ @pytest.mark.parametrize("n,expect", [("1", "한"), ("3", "세"), ("10", "열"), ("11", "열한"), ("20", "스무"),
50
+ ("21", "스물한"), ("99", "아흔아홉"), ("100", None), ("0", None)])
51
+ def test_native(n, expect):
52
+ assert numbers.native(n) == expect
53
+
54
+
55
+ def test_many_numbers_fall_back_to_greedy_without_blowing_up():
56
+ ref = " ".join(f"{i}개" for i in range(1, 13))
57
+ hyp = " ".join(["한 개", "두 개", "세 개", "네 개", "다섯 개", "여섯 개", "일곱 개", "여덟 개", "아홉 개", "열 개", "열한 개", "열두 개"])
58
+ assert kocer.cer(ref, hyp) == 0