File size: 7,443 Bytes
2322285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
---
language:
- ru
license: apache-2.0
base_model: Qwen/Qwen2.5-0.5B-Instruct
tags:
- end-of-utterance
- dialog
- call-center
- conversational-ai
- russian
- speech
- voice-activity
pipeline_tag: text-generation
---

# EOU Detector — Russian Call-Center Dialog

End-of-Utterance (EOU) detector for Russian conversational speech, fine-tuned from
[Qwen2.5-0.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct) on 200k real
call-center dialogs.

The model predicts **P(`<|im_end|>`)** at the last token position — the probability that
the current speaker has finished their utterance. No classification head; the LM vocabulary
does the detection.

Developed at [Simplexphone](https://simplexphone.com) — real-time voice AI for call centers.

## Performance

Evaluated on 200 stratified samples (100 positive EOU + 100 negative) from held-out call-center data:

| Metric | Value |
|---|---|
| F1 | **0.851** |
| False Alarm (1 − Precision) | 22.3% |
| False Rejection (1 − Recall) | 6.0% |
| Optimal threshold | 0.077 |
| GPU latency (H100, batch=1) | ~10 ms |
| CPU latency (Xeon 28-core, batch=1) | ~55 ms |

## Usage

```python
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

tok   = AutoTokenizer.from_pretrained("feanet/eou-detector-russian")
model = AutoModelForCausalLM.from_pretrained(
    "feanet/eou-detector-russian", torch_dtype=torch.float32
)
model.eval()

EOU_ID    = tok.convert_tokens_to_ids("<|im_end|>")
THRESHOLD = 0.077

def eou_probability(history: list[dict], current_text: str) -> float:
    """
    history: list of {"role": "user"|"assistant", "content": "..."}
    current_text: the utterance to score (last client turn)
    Returns P(end-of-utterance) in [0, 1].
    """
    msgs   = history + [{"role": "user", "content": current_text}]
    prompt = tok.apply_chat_template(msgs, add_generation_prompt=False, tokenize=False)
    prompt = prompt[: prompt.rfind("<|im_end|>")]          # strip trailing EOU token
    enc    = tok(prompt, return_tensors="pt", truncation=True, max_length=512)
    with torch.no_grad():
        logits = model(**enc).logits
    return torch.softmax(logits[0, -1, :], dim=-1)[EOU_ID].item()


# Example
history = [{"role": "assistant", "content": "добрый день чем могу помочь"}]

print(eou_probability(history, "спасибо до свидания"))   # → ~0.8  (farewell, EOU)
print(eou_probability(history, "хотел уточнить по"))     # → ~0.02 (incomplete, not EOU)
```

### ONNX / production deployment

For lower-latency production use, export to ONNX:

```python
import torch, torch.nn as nn
from transformers import AutoModelForCausalLM, AutoTokenizer

class EOUModel(nn.Module):
    def __init__(self, model, eou_id):
        super().__init__()
        self.lm     = model
        self.eou_id = eou_id

    def forward(self, input_ids):
        logits = self.lm(input_ids).logits
        return torch.softmax(logits[:, -1, :], dim=-1)[:, self.eou_id]

tok   = AutoTokenizer.from_pretrained("feanet/eou-detector-russian")
model = AutoModelForCausalLM.from_pretrained("feanet/eou-detector-russian",
                                              torch_dtype=torch.float32).eval()
eou_model = EOUModel(model, tok.convert_tokens_to_ids("<|im_end|>")).eval()

dummy = tok(["хорошо спасибо"], return_tensors="pt")["input_ids"]
torch.onnx.export(eou_model, (dummy,), "model.onnx",
                  input_names=["input_ids"], output_names=["eou_prob"],
                  dynamic_axes={"input_ids": {0: "batch", 1: "seq_len"},
                                "eou_prob":  {0: "batch"}},
                  opset_version=18)
```

ONNX batch=1 GPU latency: **~6 ms** (H100).

## Training

**Data:** 200,667 Russian call-center dialog files in `[HH:MM] A/B: text` format.
Speaker A = customer (`user`), Speaker B = operator (`assistant`).

**Method:** Causal language modelling on the full Qwen2.5 chat template.
The model learns to predict `<|im_end|>` at natural turn boundaries as part of
standard next-token prediction — no artificial labels.

Full loss (not masked to EOU positions only) is essential: masking causes catastrophic
overfitting where the model memorises positions rather than learning turn-end signals.

**Key training details:**
- Sequences slid into 512-token windows (stride 256) → 341k training chunks
- Optimizer: AdamW, lr=1e-5, cosine schedule, 5% warmup
- Precision: bf16 on 1× H100 80 GB
- Early stopping on eval loss, patience=3
- Best checkpoint: step 29,326 (~2 epochs)
- Weight untying applied before training (safetensors requirement for Qwen)

## C++ / ONNX Runtime

Dependencies: [onnxruntime](https://github.com/microsoft/onnxruntime),
[tokenizers-cpp](https://github.com/mlc-ai/tokenizers-cpp) (reads `tokenizer.json` directly),
[ICU](https://icu.unicode.org/) for NFKC normalisation.

```cpp
#include <onnxruntime_cxx_api.h>
#include <tokenizers_cpp.h>

// Build the Qwen chat-template prompt manually and strip the trailing <|im_end|>
// token — the model scores P(<|im_end|>) as the *next* token at that position.
//
// Template token IDs (Qwen2.5 vocab):
//   <|im_start|>=151644  <|im_end|>=151645  \n=198
//   system=8948  user=872  assistant=77091
//
// ONNX interface:
//   input  "input_ids"  INT64  [1, seq_len]
//   output "eou_prob"   FLOAT  [1]

static constexpr float THRESHOLD  = 0.0766f;
static constexpr int   MAX_TOKENS = 512;

Ort::Env     env(ORT_LOGGING_LEVEL_WARNING, "eou");
Ort::Session session(env, "model.onnx", Ort::SessionOptions{});

auto tokenizer = tokenizers::Tokenizer::FromBlobJSON(
    ReadFile("tokenizer.json"));   // your file-read helper

// Build input_ids: [system block] + turns + [user open, current text]
// then truncate to MAX_TOKENS from the right.
std::vector<int64_t> ids = BuildPromptIds(tokenizer, history, current_text);

Ort::MemoryInfo mem("Cpu", OrtDeviceAllocator, 0, OrtMemTypeDefault);
std::array<int64_t, 2> shape{1, (int64_t)ids.size()};
auto input_tensor = Ort::Value::CreateTensor<int64_t>(
    mem, ids.data(), ids.size(), shape.data(), shape.size());

const char* input_names[]  = {"input_ids"};
const char* output_names[] = {"eou_prob"};
auto output = session.Run(Ort::RunOptions{}, input_names, &input_tensor, 1,
                          output_names, 1);

float prob = output[0].GetTensorData<float>()[0];
bool  eou  = prob >= THRESHOLD;   // FA=22.3%  FR=6.0%  F1=0.851
```

Full header-only class with preprocessing, tokenisation, and GPU support:
[`eou_detector.h`](https://huggingface.co/feanet/eou-detector-russian/blob/main/eou_detector.h)

### Latency

| Runtime | Hardware | Batch | Latency |
|---|---|---|---|
| PyTorch FP32 | H100 80 GB | 1 | ~23 ms |
| ONNX Runtime FP32 | H100 80 GB | 1 | **6 ms** |
| ONNX Runtime FP32 | Xeon 28-core | 1 | ~55 ms |
| ONNX Runtime FP32 | H100 80 GB | 128 | 14 ms (9 k items/s) |

## Intended use

- Voice assistant / IVR systems: detect when the caller has finished speaking
  before routing to ASR or NLU
- Call-center analytics: segment transcripts by speaker turn
- Real-time dialog systems needing a language-aware alternative to silence-based VAD

## Limitations

- Trained on Russian call-center speech transcripts; performance on other domains is good
  on other languages is not good
- Scores ASR transcript text, not audio — a separate VAD/ASR stage is needed upstream
- Short utterances (< 3 tokens) may score unreliably