kocer 0.1.0__py3-none-any.whl

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.
kocer/__init__.py ADDED
@@ -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)
kocer/align.py ADDED
@@ -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))
kocer/cli.py ADDED
@@ -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())
kocer/counters.py ADDED
@@ -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
kocer/normalize.py ADDED
@@ -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)
kocer/numbers.py ADDED
@@ -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,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
@@ -0,0 +1,11 @@
1
+ kocer/__init__.py,sha256=V8wQcUkAcSsqpo0Ga201nfFgvUIyXAYsDxVvRk03WYc,3471
2
+ kocer/align.py,sha256=7tifOcYm3FguCxlIwmzQJv0O3peD_9DoruJlT9cEns0,1197
3
+ kocer/cli.py,sha256=ZHDFpjDEYj9MfPBQRN-KzUhCEAAvYDPqXrBrA0tS0U4,1822
4
+ kocer/counters.py,sha256=rXlL65KG1GRE2t37MI1ABwDuLLw_WGX_0HZFLBF1q9Q,1656
5
+ kocer/normalize.py,sha256=TIgdFwg49TAm1C_vubaEZkERMZSc8pp0w_YPiyjMcUc,1800
6
+ kocer/numbers.py,sha256=uBifqSMI8hey3jBXWgZN4En_dB0ti_FmBHx74vPuUNE,3751
7
+ kocer-0.1.0.dist-info/METADATA,sha256=GSdJZCTgDChm1V-05dxWjQ_NQfI288NJA6Do7NA6D38,5162
8
+ kocer-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ kocer-0.1.0.dist-info/entry_points.txt,sha256=YakXBTnGpatVwAOLySzNWTGd2nQJ58qu6oszrVJm1u4,41
10
+ kocer-0.1.0.dist-info/licenses/LICENSE,sha256=GTvv-ikQ5g2GPIGqfUKAoX4CZBcJ-YDYrxrQz8M5Jr8,1068
11
+ kocer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kocer = kocer.cli:main
@@ -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.