add test module
This commit is contained in:
241
sentence_api/scoring.py
Normal file
241
sentence_api/scoring.py
Normal file
@@ -0,0 +1,241 @@
|
||||
import math
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
from .audio_metrics import AudioMetrics
|
||||
|
||||
|
||||
SCORING_VERSION = "asr-fluency-v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextAlignment:
|
||||
reference_tokens: List[str]
|
||||
recognized_tokens: List[str]
|
||||
matches: int
|
||||
missing_tokens: List[str]
|
||||
extra_tokens: List[str]
|
||||
substitutions: List[Tuple[str, str]]
|
||||
content_score: float
|
||||
completeness_score: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreBreakdown:
|
||||
overall_score: float
|
||||
content_score: float
|
||||
completeness_score: float
|
||||
fluency_score: float
|
||||
duration_score: float
|
||||
pause_score: float
|
||||
speech_rate_score: float
|
||||
duration_ratio: float
|
||||
missing_tokens: List[str]
|
||||
extra_tokens: List[str]
|
||||
substitutions: List[Tuple[str, str]]
|
||||
feedback: str
|
||||
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
normalized = unicodedata.normalize("NFKC", text).lower().strip()
|
||||
tokens: List[str] = []
|
||||
word: List[str] = []
|
||||
|
||||
def flush_word() -> None:
|
||||
if word:
|
||||
token = "".join(word).strip("'")
|
||||
if token:
|
||||
tokens.append(token)
|
||||
word.clear()
|
||||
|
||||
for char in normalized:
|
||||
if _is_cjk(char):
|
||||
flush_word()
|
||||
tokens.append(char)
|
||||
elif char.isalnum() or (char == "'" and word):
|
||||
word.append(char)
|
||||
else:
|
||||
flush_word()
|
||||
flush_word()
|
||||
return tokens
|
||||
|
||||
|
||||
def align_text(reference_text: str, recognized_text: str) -> TextAlignment:
|
||||
reference = tokenize(reference_text)
|
||||
recognized = tokenize(recognized_text)
|
||||
if not reference:
|
||||
raise ValueError("Reference text does not contain any scoreable tokens.")
|
||||
|
||||
costs = [[0] * (len(recognized) + 1) for _ in range(len(reference) + 1)]
|
||||
for row in range(1, len(reference) + 1):
|
||||
costs[row][0] = row
|
||||
for column in range(1, len(recognized) + 1):
|
||||
costs[0][column] = column
|
||||
for row in range(1, len(reference) + 1):
|
||||
for column in range(1, len(recognized) + 1):
|
||||
substitution_cost = 0 if reference[row - 1] == recognized[column - 1] else 1
|
||||
costs[row][column] = min(
|
||||
costs[row - 1][column] + 1,
|
||||
costs[row][column - 1] + 1,
|
||||
costs[row - 1][column - 1] + substitution_cost,
|
||||
)
|
||||
|
||||
row = len(reference)
|
||||
column = len(recognized)
|
||||
matches = 0
|
||||
missing: List[str] = []
|
||||
extra: List[str] = []
|
||||
substitutions: List[Tuple[str, str]] = []
|
||||
while row > 0 or column > 0:
|
||||
if row > 0 and column > 0:
|
||||
same = reference[row - 1] == recognized[column - 1]
|
||||
diagonal_cost = costs[row - 1][column - 1] + (0 if same else 1)
|
||||
if costs[row][column] == diagonal_cost:
|
||||
if same:
|
||||
matches += 1
|
||||
else:
|
||||
substitutions.append((reference[row - 1], recognized[column - 1]))
|
||||
row -= 1
|
||||
column -= 1
|
||||
continue
|
||||
if row > 0 and costs[row][column] == costs[row - 1][column] + 1:
|
||||
missing.append(reference[row - 1])
|
||||
row -= 1
|
||||
else:
|
||||
extra.append(recognized[column - 1])
|
||||
column -= 1
|
||||
|
||||
missing.reverse()
|
||||
extra.reverse()
|
||||
substitutions.reverse()
|
||||
distance = len(missing) + len(extra) + len(substitutions)
|
||||
accuracy = max(0.0, 1.0 - distance / len(reference)) * 100
|
||||
attempted_reference_tokens = matches + len(substitutions)
|
||||
completeness = attempted_reference_tokens / len(reference) * 100
|
||||
content = accuracy * 0.7 + completeness * 0.3
|
||||
return TextAlignment(
|
||||
reference_tokens=reference,
|
||||
recognized_tokens=recognized,
|
||||
matches=matches,
|
||||
missing_tokens=missing,
|
||||
extra_tokens=extra,
|
||||
substitutions=substitutions,
|
||||
content_score=_round_score(content),
|
||||
completeness_score=_round_score(completeness),
|
||||
)
|
||||
|
||||
|
||||
def score_attempt(
|
||||
*,
|
||||
reference_text: str,
|
||||
recognized_text: str,
|
||||
reference_speech_duration_ms: int,
|
||||
student_metrics: AudioMetrics,
|
||||
) -> ScoreBreakdown:
|
||||
if reference_speech_duration_ms <= 0:
|
||||
raise ValueError("reference_speech_duration_ms must be positive")
|
||||
alignment = align_text(reference_text, recognized_text)
|
||||
ratio = student_metrics.speech_duration_ms / reference_speech_duration_ms
|
||||
duration = duration_similarity_score(ratio)
|
||||
pause = pause_score(student_metrics.internal_pause_ratio)
|
||||
|
||||
reference_rate = len(alignment.reference_tokens) / (reference_speech_duration_ms / 1000)
|
||||
recognized_units = max(1, len(alignment.recognized_tokens))
|
||||
student_rate = recognized_units / (student_metrics.speech_duration_ms / 1000)
|
||||
rate_ratio = student_rate / reference_rate if reference_rate > 0 else 1.0
|
||||
rate = symmetric_rate_score(rate_ratio)
|
||||
|
||||
fluency = duration * 0.35 + pause * 0.40 + rate * 0.25
|
||||
overall = alignment.content_score * 0.80 + fluency * 0.20
|
||||
feedback = build_feedback(alignment, ratio, student_metrics.internal_pause_ratio)
|
||||
return ScoreBreakdown(
|
||||
overall_score=_round_score(overall),
|
||||
content_score=alignment.content_score,
|
||||
completeness_score=alignment.completeness_score,
|
||||
fluency_score=_round_score(fluency),
|
||||
duration_score=_round_score(duration),
|
||||
pause_score=_round_score(pause),
|
||||
speech_rate_score=_round_score(rate),
|
||||
duration_ratio=round(ratio, 4),
|
||||
missing_tokens=alignment.missing_tokens,
|
||||
extra_tokens=alignment.extra_tokens,
|
||||
substitutions=alignment.substitutions,
|
||||
feedback=feedback,
|
||||
)
|
||||
|
||||
|
||||
def duration_similarity_score(ratio: float) -> float:
|
||||
if ratio <= 0:
|
||||
return 0.0
|
||||
if 0.80 <= ratio <= 1.30:
|
||||
return 100.0
|
||||
if 0.65 <= ratio < 0.80:
|
||||
return _interpolate(ratio, 0.65, 0.80, 60, 100)
|
||||
if 1.30 < ratio <= 1.50:
|
||||
return _interpolate(ratio, 1.30, 1.50, 100, 60)
|
||||
if 0.50 <= ratio < 0.65:
|
||||
return _interpolate(ratio, 0.50, 0.65, 20, 60)
|
||||
if 1.50 < ratio <= 1.80:
|
||||
return _interpolate(ratio, 1.50, 1.80, 60, 20)
|
||||
if ratio < 0.50:
|
||||
return max(0.0, ratio / 0.50 * 20)
|
||||
return max(0.0, 20 - (ratio - 1.80) / 0.40 * 20)
|
||||
|
||||
|
||||
def pause_score(internal_pause_ratio: float) -> float:
|
||||
if internal_pause_ratio <= 0.15:
|
||||
return 100.0
|
||||
if internal_pause_ratio <= 0.30:
|
||||
return _interpolate(internal_pause_ratio, 0.15, 0.30, 100, 70)
|
||||
if internal_pause_ratio <= 0.50:
|
||||
return _interpolate(internal_pause_ratio, 0.30, 0.50, 70, 20)
|
||||
return max(0.0, 20 - (internal_pause_ratio - 0.50) / 0.30 * 20)
|
||||
|
||||
|
||||
def symmetric_rate_score(ratio: float) -> float:
|
||||
if ratio <= 0:
|
||||
return 0.0
|
||||
deviation = abs(math.log(ratio))
|
||||
free_tolerance = math.log(1.20)
|
||||
if deviation <= free_tolerance:
|
||||
return 100.0
|
||||
return max(0.0, 100 - (deviation - free_tolerance) / math.log(2.5) * 100)
|
||||
|
||||
|
||||
def build_feedback(
|
||||
alignment: TextAlignment,
|
||||
duration_ratio: float,
|
||||
internal_pause_ratio: float,
|
||||
) -> str:
|
||||
messages = []
|
||||
if alignment.missing_tokens:
|
||||
messages.append("存在漏读")
|
||||
if alignment.substitutions:
|
||||
messages.append("存在错读")
|
||||
if alignment.extra_tokens:
|
||||
messages.append("存在多读")
|
||||
if duration_ratio < 0.65:
|
||||
messages.append("朗读明显偏快或内容不完整")
|
||||
elif duration_ratio > 1.50:
|
||||
messages.append("朗读速度偏慢")
|
||||
if internal_pause_ratio > 0.30:
|
||||
messages.append("句内停顿偏多")
|
||||
return ";".join(messages) if messages else "内容和朗读节奏匹配良好"
|
||||
|
||||
|
||||
def _is_cjk(char: str) -> bool:
|
||||
codepoint = ord(char)
|
||||
return (
|
||||
0x3400 <= codepoint <= 0x4DBF
|
||||
or 0x4E00 <= codepoint <= 0x9FFF
|
||||
or 0xF900 <= codepoint <= 0xFAFF
|
||||
)
|
||||
|
||||
|
||||
def _interpolate(value: float, start: float, end: float, start_score: float, end_score: float) -> float:
|
||||
return start_score + (value - start) / (end - start) * (end_score - start_score)
|
||||
|
||||
|
||||
def _round_score(value: float) -> float:
|
||||
return round(min(100.0, max(0.0, value)), 1)
|
||||
Reference in New Issue
Block a user