Gürültülü OCR üzerinde konuşmacı atfı: akşam akşam tutulmuş bir defter
Bir regex'in Meclis Başkanı'nın sessizlik ricasını on iki kez yanlış kişiye nasıl yapıştırdığı, ve 850 oturumda doğruluk oranını 95.6%'dan 99.1%'e taşıyan beş akşamın hikâyesi.
Konuşmacı atfı modelini ilk kez 6 Şubat 2023 TBMM oturumunda çalıştırdım. Model, Meclis Başkanı'nın yaptığı on iki açıklamayı tam bir özgüvenle Kemal Kılıçdaroğlu'na yazdı. O oturum, depremden üç gün sonra yayınlanmıştı. Başkan on bir dakikasını, hayatını kaybedenlerin isimleri okunabilsin diye sessizlik istemekle geçirmişti. O on iki cümlenin hiçbiri Kılıçdaroğlu'na ait değildi. Bunu biliyordum çünkü oturumu o gece canlı izlemiştim. Model bilmiyordu.
Terminalin önünde oturdum ve çok belirgin bir küçüklük hissi yaşadım. Normal bir oturumu yüzde 95,6 doğru atfeden bir model, transkriptin en çok önem taşıdığı günde felaket boyutunda yanılabilen bir modeldir.
Bu yazı, o sayıyı 95,6'dan 99,1'e taşıyan ve akşam akşam tutulmuş bir defterdir. Zafer yazısı değil. Kötü bir regex'in düzelmesinin beş akşam aldığı, herkesin çözüm sandığı LLM'in aslında işin yalnızca yüzde dördünü yaptığı hikâyedir.
Külliyat aslında nasıl görünüyor
NLP Parliament projesi, 1995'ten 2024'e kadar TBMM oturum tutanaklarını yutar. PDF'leri metne düzleştirdiğinizde yaklaşık 6.000 sayfa eder. PDF'lerin kendisi taranmış, sonra OCR'lanmış. İmzalar kenara taşar. Sayfa sonları cümlenin ortasına denk düşer. Prosedürel blokları ayırmak için kullanılan belirli bir yatay çizgi vardır; OCR onu ısrarla bir sıra küçük L harfi olarak okur.
Konuşmacı etiketi, temiz halinde, şöyle görünür:
MUSTAFA KARA (X Partisi) – Sayın Başkan, değerli milletvekilleri...Tüm büyük harfle isim, parantez içinde parti, bir em-dash, ardından konuşma. Kolay. Bir regex bunu tek satırda halleder.
Gürültülü halde aynı satır şöyle OCR'lanır:
MUSTAFA KARA (X Parlisl) — Sayın Başkan, değerli...Üç şeye dikkat edin. Önce, "Partisi" kelimesi "Parlisl" oldu — soluk tarama "ti" dizisini "lI" olarak okumuş. Sonra em-dash genişledi. Ve sayfaların yüzde yirmisinde em-dash hiç yok; OCR onu düşürmüş.
Temiz halde yüzde yüz emin olan bir regex, gürültülü halde sessizce çöker. Sessiz çöküş en kötüsüdür. Konuşma bir önceki konuşmacıya atfedilir, çünkü regex'in hâlâ bellekte tuttuğu kişi odur.
1. Akşam: regex
Herkes nereden başlıyorsa oradan başladım.
SPEAKER_RE = re.compile(
r"^([A-ZÇĞİÖŞÜ ]{4,40})\s*\(([^)]+)\)\s*[–—-]\s*(.*)"
)
def attribute(lines):
current = None
for line in lines:
m = SPEAKER_RE.match(line)
if m:
# yeni konuşmacı bloğu başlıyor
current = (m.group(1).strip(), m.group(2).strip())
yield current, m.group(3)
elif current:
yield current, lineBu desen, temiz hali halleder. Aynı zamanda şunu da varsayar: yeni etiket bulunamazsa, mevcut konuşmacı devam ediyordur. İşte bu varsayım, bug'ın ta kendisi. Bozuk etikette regex eşleşmez, satır devam sayılır ve önceki konuşmacı bir sonrakinin cümlelerini yer.
İlk akşam dört saatimi varyasyonlara harcadım. Daha fazla tire karakteri. Daha gevşek boşluk. Daha izinli bir parti grubu. Yaptığım her düzeltme hatayı başka bir sayfaya taşıdı. Gece 1:30 civarında durdum, çünkü doğruluğu inatçılıkla karıştırmaya başladığımı sezdim.
2. Akşam: bulanık eşleştirme ve on iki birleşmiş konuşmacı
İkinci akşam tam eşleşmeden vazgeçtim. Konuşmacı bloğunu bilinen bir şekille bulanık eşleştir, iki düzenlemeye kadar izin ver.
from rapidfuzz import fuzz
def looks_like_speaker_tag(line: str) -> bool:
# Konuşmacı etiketleri kısa, çoğu büyük harf, parantez içerir.
if "(" not in line or ")" not in line:
return False
head = line.split("–")[0] if "–" in line else line.split("—")[0]
# İlk 30 karakterde büyük harf oranı; insanlar bu kadar bağırmaz.
upper = sum(c.isupper() for c in head[:30])
return upper >= 10 and fuzz.partial_ratio(head, head.upper()) > 85Bu kod bozuk etiketleri yakaladı. Etiket olmayan şeyleri de yakaladı. Büyük harfli bölüm başlıkları, sayfa altbilgileri, OTURUM AÇILIRKEN gibi prosedürel işaretler. Bir oturumda var olmayan on iki "yeni konuşmacı" tespit etti; o satırlar yeni blok sayıldı ve gerçek konuşmacıların metinleri konfetiye döndü.
Recall yukarı, precision aşağı. Model artık daha ilginç bir şekilde yanlıştı. Gece yarısı durdum, çünkü farklı bir şekilde yanlış olmak ilerleme sayılmaz.
3. Akşam: sözlük
Bu, akıllıca davranmaktan vazgeçtiğim akşamdı.
Bir sözlük inşa ettim. Her oturum için tarihi biliyordum. Tarihten yola çıkarak resmi milletvekili listesine bakabiliyordum: kim yemin etmiş, kim yok, kim son oturumdan beri parti değiştirmiş. 2017 oturumu için sözlükte yaklaşık 550 girdi, 2023 için yaklaşık 600 girdi var.
def load_speaker_dict(session_date):
# roster.json resmi TBMM kayıtlarından elle düzenlendi.
roster = json.load(open(f"rosters/{session_date.year}.json"))
# Verili bir günde sadece mevcut üyeler konuşabilir. Yoklamalar önemli.
present = roster["present_on"].get(session_date.isoformat(), roster["sworn_in"])
return {normalize(m["name"]): m for m in roster["members"] if m["id"] in present}Artık bulanık etiket eşleşmesinin bir çıpası vardı. Aday isim, tüm Türk isimleri evreniyle değil, o günün yoklamasıyla eşleşiyordu. False positive'ler keskin biçimde düştü.
Sonra öngöremediğim duruma çarptım: ismi değişen üyeler. Dönem ortasında evlenip eşinin soyadını alan üye. Partisinden ihraç edilip bağımsız olarak oturmaya devam eden üye. Listede bir şekilde, OCR'da başka şekilde; sözlük bağlantıyı kaçırıyor.
Bir takma ad tablosu ekledim. Her girdi, geçerli tarih aralığıyla birlikte eski isimler listesine sahip olabilir. Akşam bittiğinde sözlük, küçük bir biyografi gibi okunan bir dipnot kümesi kazanmıştı. Bazıları hâlâ kod tabanındaki en sevdiğim bölüm.
4. Akşam: konum ve şekil
Dördüncü akşam, işin çoğunu yapan sıkıcı akşamdı.
Bin doğru atfedilmiş satıra ve bin yanlış atfedilmiş satıra baktıktan sonra şunu fark ettim: gerçek bir konuşmacı etiketi neredeyse her zaman satırın sıfırıncı sütununda başlar, bir boş satırın ardından gelir ve sonrasında ya bir tire ya da bir hard newline vardır. BAŞKAN: gibi prosedürel çağrılar tire değil iki nokta üst üste kullanır; takip eden satır da kısa ve parantetiktir.
def is_substantive_tag(prev_blank, line, next_line):
# Esas konuşmacılarda tire vardır; prosedürel çağrılarda iki nokta.
has_dash = any(d in line for d in ("–", "—"))
has_colon = line.rstrip().endswith(":")
# Gerçek konuşmalar bir sonraki satırda sürer; prosedürel çağrılar kısadır.
next_is_substance = len(next_line.strip()) > 40
return prev_blank and has_dash and not has_colon and next_is_substanceSözlükle birleştirildiğinde, ayrı tutulmuş 50 oturumluk batch'te atıf doğruluğunu yaklaşık yüzde 96'ya çıkardı. Orijinal regex'in üstüne 0,4 puan eklemek için üç akşam dikkatli çalışma katmıştık. Üretim NLP'si işte böyle hissettirir.
5. Akşam: LLM, tutumlu kullanıldı
Son yüzde 4 uzun kuyruktu. OCR'ın o kadar bozuk olduğu durumlar ki sözlük aday çıpalayamıyordu. Aynı dakikada konuşan benzer isimli iki üye. Sayfa sonunun konuşmacı etiketi ile ilk kelime arasına tam isabet ettiği durumlar.
Sadece bunlar için bir LLM hakem ekledim.
def adjudicate(window, candidates, model):
# Sadece heuristik güveni 0.7'nin altındayken çağrılır.
# 6 satırlık bir pencere ve günün sözlük adaylarını veriyoruz.
prompt = (
"Aşağıdaki tutanak penceresi ve bu oturumda mevcut milletvekilleri "
"listesi verildiğinde, son satırın konuşmacısını tespit et. "
"Emin değilsen 'unknown' döndür.\n\n"
f"PENCERE:\n{window}\n\nADAYLAR:\n{candidates}\n"
)
out = model.generate(prompt, max_tokens=40, temperature=0)
return parse_candidate(out, candidates)Hakem, oturum başına ortalama 200 satırda çalışıyor. Güncel fiyatla oturum başına yaklaşık üç sent. 850 oturum boyunca LLM, doğruluğu yüzde 96'dan 99,1'e taşıyan durumlarla ilgilendi.
Sezgilere ters ders tam orada duruyor. LLM problemi çözmedi. Sıkıcı heuristik ve elle düzenlenmiş sözlük işin yüzde 95'ini yaptı. LLM yalnızca son yüzde 4'ü hallettiği için sihirli göründü. LLM'le başlasaydım servetimi harcardım ve hâlâ aynı uzun kuyruğu yüzde 96'da kovalıyor olurdum.
Küçük bir OCR hatası hayvanat bahçesi
ocr_zoo.md adlı dosyada tutulan, kapsamlı olmayan bir örnek:
Partisi → Parlisl(soluk tarama, t→l, i→I)İSMET → ISMET(eğri sayfada noktalı-İ kaybolur)BAŞKAN → BASKAN → BAŞKAH(sedilla göç eder; alt kenarda N→H)- Em-dash → hiçbir şey (5 sayfada 1)
- Orta kıvrım kıvrıldığında iki sütun birleşir
(X.Y.Z. Partisi)noktalama(X Y Z Partisi)'ye düşer
Her biri bir noktada kendine ait bir akşam talep etti.
Geçmişteki bana ne derdim
Birinci akşama dönebilseydim şunu söylerdim: regex'le başlama. Sözlükle başla. Sözlük, tahmin etmeyi aramaya çeviren şeydir. Diğer her şey süs.
Bir de paskalya yumurtası var, çünkü her defterin bir tane olmalı. Konuşmacı sözlüğünde şu an 1.247 girdi var. Üçünde ⚠️ benzer yazılışlı X-isimli kişiyle aynı kişi değildir notu duruyor. Hangi üç isim en çok karışıklığa sebep oluyor, onu sana bırakıyorum. Bariz olanları tahmin edersen, muhtemelen ikisini doğru, üçüncüsünü acı bir şekilde yanlış bilmiş olursun.
// madem buradasın
- 11 dk okuma
Bir LLM'in siyasi pozisyon halüsine ettiği hafta — ve bir gazetecinin az kalsın bunu alıntılayacağı
Otomatik üretilen bir TBMM özeti, oylanmamış bir yasada bir üyenin oy verdiğini söyledi. Gazeteci haberi 24 saat bekletti. Sonra 'hayır' diyebilen bir doğrulama katmanı inşa edildi: dört gün.
nlpllmcivic-techverification - 7 dk okuma
Agentic AI guardrail'leri: while(true) döngüsünü token bütçeni yakmasından durdurmak
Vibes-döngüsünü production'da gerçekten çalıştırabileceğin bir şeye çeviren dört guardrail: bütçe zarfı, retry eğrileri, break koşulları ve gerçek bir fallback zinciri.
agentic-aiproductionengineering-lessons