3v324v23 commited on
Commit
52bd073
·
1 Parent(s): 77ea3c2

feat: add uncertainty handling in scoring and trace schema for improved feedback

Browse files
app/scorer.py CHANGED
@@ -198,6 +198,7 @@ class Reference:
198
  class ScorerConfig:
199
  flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
200
  unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
 
201
  issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
202
  max_issues: int = 8
203
  max_tips: int | None = None # None: as many as the rules document says (scoring.max_tips)
@@ -581,6 +582,8 @@ class Scorer:
581
  ar, ipa, toks, al, scores, unrelated = fit.ar, fit.ipa, fit.toks, fit.al, fit.scores, fit.unrelated
582
  i2t = self.vocab.i2t
583
  heard = " ".join(i2t[i] for i in fit.heard_ids)
 
 
584
  # Measure against what the model gives a native on the same sound: a sound it misses in the
585
  # reference voice too (ʕ in على heard as dropped) is the model's weakness, not the learner's.
586
  heatmap = self._heatmap(fit.logp, toks, al, scores, duration_s) if diagnostics else None
@@ -617,7 +620,7 @@ class Scorer:
617
  heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
618
  check = self._check(tok, heard_ph)
619
  beyond.append(self._beyond_margin(tok, sc, heard_ph))
620
- flagged = (bool(sc.score < self.cfg.flag_below) and beyond[-1]
621
  and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
622
  if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
623
  spec = self.rules.insertion
@@ -644,7 +647,7 @@ class Scorer:
644
  tip["idx"] = idx
645
  tip["score"] = round(sc.score)
646
  tips.append(tip)
647
- if idx in fit.inserted and not unrelated and "cluster" not in seen:
648
  seen.add("cluster")
649
  spec = self.rules.insertion
650
  tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
@@ -665,7 +668,7 @@ class Scorer:
665
  got = [i for j in at for i in carriers[w][j] if 0 <= i < len(phonemes)]
666
  worst = min(got, key=lambda i: phonemes[i]["score"]) if got else None
667
  letter = {"t": text, "score": phonemes[worst]["score"] if got else None}
668
- if got and letter["score"] < LETTER_GOOD and not unrelated:
669
  letter.update(letter_reason(toks[worst].sound, heard_as[worst], checks[worst]))
670
  letters.append(letter)
671
  if w in forgiven and min(ws) >= self.cfg.issue_below:
@@ -689,7 +692,7 @@ class Scorer:
689
  # Anything short of 100 must say why: every imprecise sound that did not get a full tip,
690
  # weakest first. If nothing is clearly off, name the weakest few.
691
  issues = []
692
- if round(overall) < 100 and not unrelated:
693
  tipped = {t.pop("idx") for t in tips}
694
  rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
695
  key=lambda i: scores[i].score)
@@ -711,8 +714,11 @@ class Scorer:
711
  for t in tips:
712
  t.pop("idx", None)
713
  result = {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard, "heard_tr": " ".join(h for h in heard_words if h),
714
- "unrelated": unrelated, "words": words, "tips": tips, "issues": issues, "references": len(refs),
 
715
  **self._dialect(toks, scores)}
 
 
716
  if calibrated:
717
  result["calibrated"] = calibrated
718
  if heatmap is not None:
@@ -785,6 +791,7 @@ class Scorer:
785
  "deleted_comp": round(float(np.exp(g.deleted_comp)), 3),
786
  "flag_below": cfg.flag_below, "issue_below": cfg.issue_below, "near_vowel_floor": cfg.near_vowel_floor,
787
  "word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
 
788
  "unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
789
  "ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
790
  "ayin_deleted_floor": cfg.ayin_deleted_floor,
@@ -804,6 +811,9 @@ class Scorer:
804
  "aipa": fit.aipa,
805
  "unrelated": {"heard": heard, "distance": fit.distance, "tokens": n,
806
  "ratio": round(fit.distance / max(1, n), 3), "threshold": cfg.unrelated_ratio,
 
 
 
807
  "unrelated": fit.unrelated},
808
  "references": [{"mean": round(float(np.mean(r.scores)), 1), "sounds": len(r.sounds)} for r in refs],
809
  "tokens": tokens,
 
198
  class ScorerConfig:
199
  flag_below: float = 50.0 # phoneme score under which a consonant gets a tip
200
  unrelated_ratio: float = 0.65 # normalised edit distance above which we say "different sentence"
201
+ feedback_uncertain_margin: float = 0.15 # below unrelated_ratio: score, but make no named sound claims
202
  issue_below: float = 90.0 # phoneme score under which it is listed as imprecise
203
  max_issues: int = 8
204
  max_tips: int | None = None # None: as many as the rules document says (scoring.max_tips)
 
582
  ar, ipa, toks, al, scores, unrelated = fit.ar, fit.ipa, fit.toks, fit.al, fit.scores, fit.unrelated
583
  i2t = self.vocab.i2t
584
  heard = " ".join(i2t[i] for i in fit.heard_ids)
585
+ fit_ratio = fit.distance / max(1, len(toks))
586
+ uncertain = bool(not unrelated and fit_ratio >= self.cfg.unrelated_ratio - self.cfg.feedback_uncertain_margin)
587
  # Measure against what the model gives a native on the same sound: a sound it misses in the
588
  # reference voice too (ʕ in على heard as dropped) is the model's weakness, not the learner's.
589
  heatmap = self._heatmap(fit.logp, toks, al, scores, duration_s) if diagnostics else None
 
620
  heard_ph = "_" if sc.deleted else (i2t.get(sc.competitor) if sc.competitor is not None else None)
621
  check = self._check(tok, heard_ph)
622
  beyond.append(self._beyond_margin(tok, sc, heard_ph))
623
+ flagged = (not uncertain and bool(sc.score < self.cfg.flag_below) and beyond[-1]
624
  and (tok.phoneme not in VOWELS or self._flag_vowel(toks, idx)))
625
  if idx in fit.inserted: # a vowel before this consonant (3.10): the reason its letter gives
626
  spec = self.rules.insertion
 
647
  tip["idx"] = idx
648
  tip["score"] = round(sc.score)
649
  tips.append(tip)
650
+ if idx in fit.inserted and not unrelated and not uncertain and "cluster" not in seen:
651
  seen.add("cluster")
652
  spec = self.rules.insertion
653
  tips.append({"title": self.rules.titles.get(spec["rule"], spec["rule"]), "text": str(spec["feedback"]),
 
668
  got = [i for j in at for i in carriers[w][j] if 0 <= i < len(phonemes)]
669
  worst = min(got, key=lambda i: phonemes[i]["score"]) if got else None
670
  letter = {"t": text, "score": phonemes[worst]["score"] if got else None}
671
+ if got and letter["score"] < LETTER_GOOD and not unrelated and not uncertain:
672
  letter.update(letter_reason(toks[worst].sound, heard_as[worst], checks[worst]))
673
  letters.append(letter)
674
  if w in forgiven and min(ws) >= self.cfg.issue_below:
 
692
  # Anything short of 100 must say why: every imprecise sound that did not get a full tip,
693
  # weakest first. If nothing is clearly off, name the weakest few.
694
  issues = []
695
+ if round(overall) < 100 and not unrelated and not uncertain:
696
  tipped = {t.pop("idx") for t in tips}
697
  rest = sorted((i for i in range(len(toks)) if i not in tipped and scores[i].score < 100),
698
  key=lambda i: scores[i].score)
 
714
  for t in tips:
715
  t.pop("idx", None)
716
  result = {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard, "heard_tr": " ".join(h for h in heard_words if h),
717
+ "unrelated": unrelated, "uncertain": uncertain,
718
+ "words": words, "tips": tips, "issues": issues, "references": len(refs),
719
  **self._dialect(toks, scores)}
720
+ if uncertain:
721
+ result["uncertainty_message"] = "ההקלטה זוהתה רק באופן חלקי, ולכן אי אפשר לקבוע אילו הגאים נאמרו אחרת. נסו שוב."
722
  if calibrated:
723
  result["calibrated"] = calibrated
724
  if heatmap is not None:
 
791
  "deleted_comp": round(float(np.exp(g.deleted_comp)), 3),
792
  "flag_below": cfg.flag_below, "issue_below": cfg.issue_below, "near_vowel_floor": cfg.near_vowel_floor,
793
  "word_min_weight": cfg.word_min_weight, "vowel_weight": 0.5, "unrelated_ratio": cfg.unrelated_ratio,
794
+ "feedback_uncertain_ratio": round(cfg.unrelated_ratio - cfg.feedback_uncertain_margin, 3),
795
  "unrelated_cap": 30.0, "letter_good": LETTER_GOOD, "max_tips": max_tips, "max_issues": cfg.max_issues,
796
  "ref_min_mean": cfg.ref_min_mean, "margin_default": float(self.rules.scoring.get("margin_default", 0.0)),
797
  "ayin_deleted_floor": cfg.ayin_deleted_floor,
 
811
  "aipa": fit.aipa,
812
  "unrelated": {"heard": heard, "distance": fit.distance, "tokens": n,
813
  "ratio": round(fit.distance / max(1, n), 3), "threshold": cfg.unrelated_ratio,
814
+ "uncertain_threshold": round(cfg.unrelated_ratio - cfg.feedback_uncertain_margin, 3),
815
+ "uncertain": (not fit.unrelated and fit.distance / max(1, n) >=
816
+ cfg.unrelated_ratio - cfg.feedback_uncertain_margin),
817
  "unrelated": fit.unrelated},
818
  "references": [{"mean": round(float(np.mean(r.scores)), 1), "sounds": len(r.sounds)} for r in refs],
819
  "tokens": tokens,
docs/trace-schema.ts CHANGED
@@ -22,6 +22,8 @@ export interface ScoreResult {
22
  heard_ipa: string; // greedy decode: what the model heard, free of the expected text
23
  heard_tr: string; // the same in Hebrew letters, cut at the aligned word starts
24
  unrelated: boolean; // a different sentence (TraceUnrelated): score capped, no tips
 
 
25
  words: Word[];
26
  tips: Tip[]; // the most crucial mistakes, severity first (at most config.max_tips)
27
  issues: Issue[]; // the rest of the imprecise sounds
@@ -95,6 +97,7 @@ export interface TraceConfig {
95
  word_min_weight: number;
96
  vowel_weight: number; // a vowel's weight in the sentence's phone mean
97
  unrelated_ratio: number;
 
98
  unrelated_cap: number;
99
  letter_good: number;
100
  max_tips: number;
@@ -128,6 +131,8 @@ export interface TraceUnrelated {
128
  tokens: number;
129
  ratio: number; // distance / tokens
130
  threshold: number; // over this: a different sentence
 
 
131
  unrelated: boolean;
132
  }
133
 
 
22
  heard_ipa: string; // greedy decode: what the model heard, free of the expected text
23
  heard_tr: string; // the same in Hebrew letters, cut at the aligned word starts
24
  unrelated: boolean; // a different sentence (TraceUnrelated): score capped, no tips
25
+ uncertain: boolean; // marginal target fit: score retained, categorical sound feedback suppressed
26
+ uncertainty_message?: string;
27
  words: Word[];
28
  tips: Tip[]; // the most crucial mistakes, severity first (at most config.max_tips)
29
  issues: Issue[]; // the rest of the imprecise sounds
 
97
  word_min_weight: number;
98
  vowel_weight: number; // a vowel's weight in the sentence's phone mean
99
  unrelated_ratio: number;
100
+ feedback_uncertain_ratio: number;
101
  unrelated_cap: number;
102
  letter_good: number;
103
  max_tips: number;
 
131
  tokens: number;
132
  ratio: number; // distance / tokens
133
  threshold: number; // over this: a different sentence
134
+ uncertain_threshold: number; // at/over this: suppress categorical phoneme feedback
135
+ uncertain: boolean;
136
  unrelated: boolean;
137
  }
138
 
static/index.html CHANGED
@@ -3170,7 +3170,7 @@ function drawStory(){
3170
  }else if(b!=null) tools.append(pill(b,"הציון הטוב ביותר"));
3171
  if(b!=null||r){ // a line that was said shows its Arabic
3172
  const tr=document.createElement("span"); tr.className="tr"; row.classList.add("said");
3173
- if(r&&!(r.score>STORY.pass&&!r.unrelated)&&r.words.length&&r.words.every(w=>w.letters)){
3174
  tr.classList.add("lit");
3175
  r.words.forEach((w,k)=>{ if(k) tr.append(" "); litWord(tr,w); });
3176
  }else setTr(tr,p.tr);
@@ -3758,7 +3758,7 @@ function render(r){
3758
  $("score").textContent=r.score;
3759
  const pill=$("pill"); const s=r.score;
3760
  pill.className="pill "+(s>=85?"good":s>=60?"mid":"bad");
3761
- pill.textContent= r.unrelated ? "משפט אחר?" : s>=85?"מצוין": s>=60?"כמעט":"נסו שוב";
3762
 
3763
  $("words").hidden=true;
3764
  drawAnalyzedAnswer(r);
@@ -3769,6 +3769,10 @@ function render(r){
3769
  const li=document.createElement("li");
3770
  li.innerHTML='<span class="what">ההקלטה לא דומה למשפט הצפוי</span><span>ייתכן שבחרתם ניסוח אחר. גלו את מילות התשובה ונסו את הנוסח המוצע.</span>';
3771
  tips.appendChild(li);
 
 
 
 
3772
  }
3773
  for(const t of r.tips){
3774
  const li=document.createElement("li");
@@ -3785,7 +3789,7 @@ function render(r){
3785
  setTr(body,r.issues.map(i=>`${i.sound} ב״${i.word_tr}״ (${i.score})`+(i.missing?" – לא נשמעה":i.heard?` – נשמע כמו ${i.heard}`:"")).join(" · "));
3786
  li.append(what,body); tips.appendChild(li);
3787
  }
3788
- if(!r.unrelated && !r.tips.length && !r.issues.length){
3789
  const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
3790
  tips.appendChild(li);
3791
  }
@@ -3794,9 +3798,9 @@ function render(r){
3794
  keepScore(cur.id,s); if(daily) dailySaid.add(cur.id); // only an empty recording is not practice
3795
  const story=!driving&&!$("story").hidden;
3796
  if(story){ storyLast[cur.id]=r; drawStory(); } // the line was said: its grade and its Arabic appear on it
3797
- if(story&&!(!r.unrelated&&s>STORY.pass)) storyRetry();
3798
  if(bubbleCat()){ bubbleOutcome(r); return; }
3799
- if(!driving&&!r.unrelated&&s>=AUTO_NEXT.minimum){ // driving mode moves on by itself, in its own loop
3800
  $("status").textContent=`ציון ${s} ✓ עוברים למשפט הבא`;
3801
  startAutoNext();
3802
  }
@@ -3817,7 +3821,7 @@ function waText(r,passed){
3817
  waMsg=el; $("bubbleFoot").prepend(el);
3818
  }
3819
  async function bubbleOutcome(r,expired=false){
3820
- const id=cur.id, correct=!r.unrelated&&r.score>=BUBBLE_PASS;
3821
  if(bubbleResolving) return;
3822
  bubbleResolving=true;
3823
  if(!expired){
@@ -3959,7 +3963,8 @@ function drawAnalyzedAnswer(r){
3959
  const pair=mark("analysis-word","","span"), expected=mark("expected","","span");
3960
  if(word.letters&&word.letters.length) litWord(expected,word);
3961
  else{ expected.classList.add(band(word.score)); setTr(expected,word.tr); }
3962
- const heard=mark(`heard-word${word.heard_tr?"":" empty"}`,"","span"); setTr(heard,word.heard_tr||"לא זוהה");
 
3963
  pair.append(expected,heard); main.append(pair);
3964
  });
3965
  card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
 
3170
  }else if(b!=null) tools.append(pill(b,"הציון הטוב ביותר"));
3171
  if(b!=null||r){ // a line that was said shows its Arabic
3172
  const tr=document.createElement("span"); tr.className="tr"; row.classList.add("said");
3173
+ if(r&&!(r.score>STORY.pass&&!r.unrelated&&!r.uncertain)&&r.words.length&&r.words.every(w=>w.letters)){
3174
  tr.classList.add("lit");
3175
  r.words.forEach((w,k)=>{ if(k) tr.append(" "); litWord(tr,w); });
3176
  }else setTr(tr,p.tr);
 
3758
  $("score").textContent=r.score;
3759
  const pill=$("pill"); const s=r.score;
3760
  pill.className="pill "+(s>=85?"good":s>=60?"mid":"bad");
3761
+ pill.textContent= r.unrelated ? "משפט אחר?" : r.uncertain ? "לא ברור" : s>=85?"מצוין": s>=60?"כמעט":"נסו שוב";
3762
 
3763
  $("words").hidden=true;
3764
  drawAnalyzedAnswer(r);
 
3769
  const li=document.createElement("li");
3770
  li.innerHTML='<span class="what">ההקלטה לא דומה למשפט הצפוי</span><span>ייתכן שבחרתם ניסוח אחר. גלו את מילות התשובה ונסו את הנוסח המוצע.</span>';
3771
  tips.appendChild(li);
3772
+ }else if(r.uncertain){
3773
+ const li=document.createElement("li");
3774
+ li.innerHTML=`<span class="what">הזיהוי לא היה מספיק ברור</span><span>${r.uncertainty_message||"אי אפשר לקבוע אילו הגאים נאמרו אחרת. נסו שוב."}</span>`;
3775
+ tips.appendChild(li);
3776
  }
3777
  for(const t of r.tips){
3778
  const li=document.createElement("li");
 
3789
  setTr(body,r.issues.map(i=>`${i.sound} ב״${i.word_tr}״ (${i.score})`+(i.missing?" – לא נשמעה":i.heard?` – נשמע כמו ${i.heard}`:"")).join(" · "));
3790
  li.append(what,body); tips.appendChild(li);
3791
  }
3792
+ if(!r.unrelated && !r.uncertain && !r.tips.length && !r.issues.length){
3793
  const li=document.createElement("li"); li.innerHTML='<span class="what">כל העיצורים נשמעו נכון</span><span>נסו לומר את המשפט בקצב דיבור טבעי.</span>';
3794
  tips.appendChild(li);
3795
  }
 
3798
  keepScore(cur.id,s); if(daily) dailySaid.add(cur.id); // only an empty recording is not practice
3799
  const story=!driving&&!$("story").hidden;
3800
  if(story){ storyLast[cur.id]=r; drawStory(); } // the line was said: its grade and its Arabic appear on it
3801
+ if(story&&!(!r.unrelated&&!r.uncertain&&s>STORY.pass)) storyRetry();
3802
  if(bubbleCat()){ bubbleOutcome(r); return; }
3803
+ if(!driving&&!r.unrelated&&!r.uncertain&&s>=AUTO_NEXT.minimum){ // driving mode moves on by itself, in its own loop
3804
  $("status").textContent=`ציון ${s} ✓ עוברים למשפט הבא`;
3805
  startAutoNext();
3806
  }
 
3821
  waMsg=el; $("bubbleFoot").prepend(el);
3822
  }
3823
  async function bubbleOutcome(r,expired=false){
3824
+ const id=cur.id, correct=!r.unrelated&&!r.uncertain&&r.score>=BUBBLE_PASS;
3825
  if(bubbleResolving) return;
3826
  bubbleResolving=true;
3827
  if(!expired){
 
3963
  const pair=mark("analysis-word","","span"), expected=mark("expected","","span");
3964
  if(word.letters&&word.letters.length) litWord(expected,word);
3965
  else{ expected.classList.add(band(word.score)); setTr(expected,word.tr); }
3966
+ const heardText=r.uncertain?"לא זוהה בביטחון":(word.heard_tr||"לא זוהה");
3967
+ const heard=mark(`heard-word${word.heard_tr&&!r.uncertain?"":" empty"}`,"","span"); setTr(heard,heardText);
3968
  pair.append(expected,heard); main.append(pair);
3969
  });
3970
  card.replaceChildren(mark("analysis-key","למעלה: התשובה · למטה: המערכת שמעה","small"),main);
tests/test_scoring.py CHANGED
@@ -311,6 +311,27 @@ def test_unrelated_sentence():
311
  assert r["unrelated"] and r["score"] <= 30
312
 
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  @pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
315
  def test_phrase_bank_word_counts_match(p):
316
  for ar, ipa in p["variants"]:
 
311
  assert r["unrelated"] and r["score"] <= 30
312
 
313
 
314
+ def test_marginal_sentence_fit_suppresses_categorical_sound_feedback():
315
+ # Half of the target phones differ: still score the attempt, but do not tell the learner they said specific sounds.
316
+ phrase = {"variants": [("تتتتت", "t a t a t a t a t a")]}
317
+ phones = ["d", "a", "d", "a", "d", "a", "d", "a", "d", "a"]
318
+ r = custom_scorer(["t"], phones).score(None, phrase, diagnostics=True)
319
+ assert not r["unrelated"] and r["uncertain"]
320
+ assert r["tips"] == [] and r["issues"] == []
321
+ assert not any(p["flagged"] or p["heard"] for w in r["words"] for p in w["phonemes"])
322
+ assert r["uncertainty_message"]
323
+ assert r["trace"]["unrelated"]["uncertain"]
324
+ assert r["trace"]["unrelated"]["ratio"] == 0.5
325
+
326
+
327
+ def test_clear_sentence_fit_still_reports_a_real_substitution():
328
+ phrase = {"variants": [("تتتتت", "t a t a t a t a t a")]}
329
+ phones = ["d", "a", "t", "a", "t", "a", "t", "a", "t", "a"]
330
+ r = custom_scorer(["t"], phones).score(None, phrase)
331
+ assert not r["unrelated"] and not r["uncertain"]
332
+ assert r["tips"][0]["key"] == "t>d"
333
+
334
+
335
  @pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
336
  def test_phrase_bank_word_counts_match(p):
337
  for ar, ipa in p["variants"]: