0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

話者ラベル付きの文字起こしから「質問と回答」を組み直す

0
Posted at

インタビュー文字起こしツール

インタビューの音声を文字起こしすると、発話は時系列のまま平坦な一列で出てくる。このままだと「その発言は何に対する答えか」を機械的に取り出せないので、引用や集計の前に構造を復元する必要がある。

この記事はツール比較ではありません。ここで扱うのは、話者ラベル付きの文字起こし結果を前提に、そこから質問と回答の対応関係を復元する手順です。入力となる話者ラベル付きの原稿を用意する手段としては、インタビュー文字起こしツール(Video Transcriber AI)のようなサービスで TXT または CSV を書き出す方法もあれば、手元のモデルを使う方法もあります。どちらの場合でも、この後段の処理は必要になります。

インタビュー文字起こしツール

なぜ「平坦な一列」では不足なのか

発話の列だけでは、次の 3 つが判定できない。

  • その発言が自発的なものか、質問に引き出されたものか
  • 複数の発話が同じ質問に対する答えなのか、別の話題なのか
  • 引用するときに、どの質問を併記すれば文脈が保たれるか

とくに 3 つ目が実務に効く。同じ「高いと思います」という発言でも、質問が「競合と比べてどうですか」なのか「社内での評判はどうですか」なのかで、記事に載せたときの意味が変わる。

話者推定のヒューリズムと限界

今回の実装では、疑問符で終わる発話が最も多い話者を質問側とみなす。単純だが、一対一のインタビューではほぼ動く。

ただしこれは明確な仮定の上に成り立っている。両者が質問を投げ合う形式(パネルディスカッション、相互インタビュー)では破綻するし、疑問符を付けない話し方をする人もいる。そのため実装では --interviewer で明示的に指定できるようにしてある。推定が当たっているかは、後述のカウンタで確認できる。

インタビュー文字起こしツール

実装

入力は Speaker: text 形式のテキストを想定しています。全角コロンにも対応させています。

qiita.rb
"""Rebuild question and answer pairs from a speaker-labelled transcript.

Reads a TXT export written as "Speaker: text" lines, identifies which speaker
asks the questions, then pairs every question with the answers that follow it.

Usage:
    python qa_pairs.py transcript.txt --markdown out.md
"""

from __future__ import annotations

import argparse
import re
import sys

TURN_RE = re.compile(r"^\s*[\[\(]?(?P<sp>[^\s::]{1,24})[\]\)]?\s*[::]\s*(?P<tx>.+)$")
QUESTION_END = ("?", "?")


def load(path):
    turns, current = [], None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.strip()
            if not line:
                continue
            match = TURN_RE.match(line)
            if match:
                if current:
                    turns.append(current)
                current = {"speaker": match.group("sp"), "text": match.group("tx")}
            elif current:
                current["text"] += " " + line
    if current:
        turns.append(current)
    return turns


def guess_interviewer(turns):
    """The speaker whose turns most often end with a question mark asks the questions.

    This is a heuristic. It fails when both people ask questions, and it says
    nothing about turns where the same speaker never punctuates a question.
    """
    counts = {}
    for turn in turns:
        counts.setdefault(turn["speaker"], 0)
        if turn["text"].rstrip().endswith(QUESTION_END):
            counts[turn["speaker"]] += 1
    if not counts:
        return None
    best = max(counts.items(), key=lambda kv: (kv[1], -list(counts).index(kv[0])))
    return best[0] if best[1] else None


def build_pairs(turns, interviewer):
    pairs = []
    for turn in turns:
        if turn["speaker"] == interviewer:
            pairs.append({"question": turn, "answers": []})
        elif pairs:
            pairs[-1]["answers"].append(turn)
        else:
            pairs.append({"question": None, "answers": [turn]})
    return pairs


def stats(pairs):
    finished = [p for p in pairs if p["question"] and p["answers"]]
    lengths = [len(a["text"].split()) for p in finished for a in p["answers"]]
    return {
        "pairs": len(finished),
        "orphan_questions": len([p for p in pairs if p["question"] and not p["answers"]]),
        "orphan_answers": len([p for p in pairs if not p["question"]]),
        "avg_answer_words": round(sum(lengths) / len(lengths), 1) if lengths else 0.0,
        "longest_answer": max(lengths) if lengths else 0,
    }


def render(pairs):
    lines = []
    index = 0
    for pair in pairs:
        if not pair["question"]:
            lines.append("> %s\n" % " ".join(a["text"] for a in pair["answers"]))
            continue
        index += 1
        lines.append("**Q%d. %s** (%s)\n" % (index, pair["question"]["text"], pair["question"]["speaker"]))
        for answer in pair["answers"]:
            lines.append("> %s (%s)\n" % (answer["text"], answer["speaker"]))
    return "\n".join(lines)


def main(argv=None):
    parser = argparse.ArgumentParser()
    parser.add_argument("path")
    parser.add_argument("--interviewer", default=None)
    parser.add_argument("--markdown", default=None)
    args = parser.parse_args(argv)

    turns = load(args.path)
    if not turns:
        print("no turns parsed; expected lines shaped like 'Speaker: text'", file=sys.stderr)
        return 1

    interviewer = args.interviewer or guess_interviewer(turns)
    if interviewer is None:
        print("could not identify the interviewer; pass --interviewer", file=sys.stderr)
        return 1

    pairs = build_pairs(turns, interviewer)
    report = stats(pairs)
    print("speakers   : %s" % ", ".join(sorted({t['speaker'] for t in turns})))
    print("interviewer: %s (auto)" % interviewer if not args.interviewer else "interviewer: %s" % interviewer)
    print("qa pairs   : %d" % report["pairs"])
    print("questions without an answer : %d" % report["orphan_questions"])
    print("answers without a question  : %d" % report["orphan_answers"])
    print("avg answer length : %s words" % report["avg_answer_words"])
    print("longest answer    : %s words" % report["longest_answer"])

    if args.markdown:
        with open(args.markdown, "w", encoding="utf-8") as fh:
            fh.write(render(pairs))
        print("wrote %s" % args.markdown)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

interviewer の推定は endswith だけで判定しています。句点の有無や話し言葉特有の語尾には依存させない方針です。今回はこれで十分ですが、形式の揺れが大きい場合は --interviewer で固定してください。

実行結果

実際に 3 組のやり取りを含むファイルで実行すると次のようになります。

qiita.rb
speakers   : インタビュアー, 回答者
interviewer: インタビュアー (auto)
qa pairs   : 3
questions without an answer : 0
answers without a question  : 0
avg answer length : 3.2 words
longest answer    : 7 words

ここで見るべきなのは questions without an answer と answers without a question です。

前者が多いなら質問側の推定が間違っている可能性が高い。後者が多いなら、質問と答えが一対一で対応していない(答えが長く区切りがない、あるいは序盤に質問なしの発言が続いている)ことを示します。この 2 つの値が 0 に近ければ、推定と構造化はうまくいっていると判断してよい。

生成される Markdown は次の形になります。引用ブロックが回答なので、そのまま議事メモや記事の下書きに貼れます。

qiita.rb
**Q1. 今の料金体系について教えていただけますか?** (インタビュアー)

> 定価は 1 席あたり 15 ドルですが、実際にはほとんどのチームが 50 ドル払っています。 (回答者)

日本語の語数カウントでハマった点

avg answer length は len(text.split()) で数えている。英語の発話なら単語数として機能するが、日本語は空白で区切らないため、これは文の長さではなく文の数の代理にしかならない。実測でも 1 文が 3.2 words と出ており、文字数や文数で評価したいなら len(text) や句点の数え上げに変える必要がある。この値を絶対的な指標として使わないことが前提です。

できないこと

この手順は同時発話を扱わない。話者ラベルが「誰の発話か」を保証してくれるのは、話者が重なっていない区間だけです。重なりのある区間でラベル自体が怪しい場合は、この構造化を信じずに原音で確認する。

また、見出しは自分の手で付けていない。構造を得るところまでがこのコードの範囲で、トピックの抽出や要約は別の工程です。ここを一緒にやると、誤った話者推定がそのまま要約に混入して検証が難しくなる。

まとめ

文字起こしの価値は文章の量ではなく、質問と回答の対応関係が取り出せる状態になっているかで決まる。その前処理をスクリプトにしておくと、調査の記録を読む順番が変わる。

書き出し形式は TXT のほか CSV も選べます。相互インタビューのように話者が入れ替わる素材では推定が効かないので、手元の原稿がどういう形式かを確認してから インタビュー文字起こしツール の書き出し設定を選ぶのがよい。入力形式が違うだけで後段の手順は同じです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?