본문 바로가기
TIL _Today I Learned/2024.11

[DAY 86] 중간 프로젝트_ 채점 기준 선정

by gamdong2 2024. 11. 20.
[천재교육] 프로젝트 기반 빅데이터 서비스 개발자 양성 과정 9기
학습일 : 2024.11.20

📕 학습 목록

  • 영어 발음 채점 항목 선정
  • 채점 기준 선정
  • 테스트 코드 작성

 

📗 활동 내용

1. 영어 발음 채점 항목 선정

1) 채점 항목 

① 피치 패턴

  • 목적: 억양의 자연스러움을 평가
    • 초등학생의 경우 음조 변화가 부자연스럽거나 단조로울 수 있음
  • 데이터 형태: 벡터값 (주파수 변화 패턴)
  • 측정 단위: 문장
  • 사용 통계: 코사인 유사도
  • 로직
    1. 문장 전체에서 모든 음절의 피치 패턴을 벡터로 생성 (공백 제외)
    2. 표준 음성과 코사인 유사도를 계산하여 패턴 유사성을 평가
  • 평가 기준: 유사도가 1에 가까울수록 Good
  • 특이 사항: 잘못 인식된 단어는 제외하고 평가

② 리듬 패턴 *단어 내부의 모든 음절의 길이 패턴  

  • 목적: 발화 흐름(리듬)의 일정성을 평가
    • 초등학생은 리듬이 일정하지 않은 경우가 많음
  • 데이터 형태: 벡터값 (음절의 길이 패턴)
  • 측정 단위: 문장  *문장 전체의 강세와 속도 흐름을 평가하므로, 음절 패턴을 문장 단위로 분석
  • 사용 통계: 코사인 유사도
  • 로직
    1. 문장 전체에서 모든 음절의 길이 패턴을 벡터로 생성 (공백 제외)
    2. 표준 음성과 코사인 유사도를 계산하여 패턴 유사성을 평가
  • 평가 기준: 유사도가 1에 가까울수록 Good
  • 특이 사항: 잘못 인식된 단어는 제외하고 평가

③ 발화 속도

  • 목적: 유창성(초당 발화 단어 수)을 평가.
    • 초등학생은 지나치게 느리거나 빠르게 발화하는 경우가 많음
  • 데이터 형태: 스칼라값 (초당 발화 단어 수)
  • 측정 단위: 문장
  • 사용 통계: 상대적 비율: abs(사용자 음성 발화 속도 / 표준 음성 발화 속도)
  • 로직
    1. 문장별 발화 시작 시간과 끝 시간을 이용해 발화 시간 계산
    2. 초당 발화 단어 수를 측정
    3. 표준 음성과 사용자의 발화 속도를 비교하여 차이 측정
  • 평가 기준: 비율이 1에 가까울수록 Good
  • 특이 사항: 잘못 인식된 단어는 제외하고 평가

④ 발화 중단 패턴  *단어와 단어 사이의 침묵 구간

  • 목적: 발화 중단(침묵 구간)의 패턴을 비교하여 발화 연속성을 평가
    • 초등학생이 발화 중간에 멈추는 경우가 많으므로, 침묵 길이의 분포가 유사한지 평가
  • 데이터 형태: 벡터값 (각 침묵 구간의 길이 리스트)
  • 측정 단위: 문장
  • 사용 통계: 코사인 유사도
  • 로직
    1. 음성 데이터를 분석하여 침묵 구간의 시작 시간과 끝 시간을 탐지
    2. 각 침묵 구간의 길이 리스트를 생성
    3. 사용자와 기준 음성의 침묵 구간 길이 리스트를 비교하여 코사인 유사도를 계산
  • 평가 기준: 유사도가 1에 가까울수록 Good
  • 특이 사항: 잘못 인식된 단어는 제외하고 평가

⑤ 잘못 인식된 단어

  • 목적: 발음 정확도를 평가 (단어 누락 포함)
  • 데이터 형태: 스칼라값 (잘못 인식된 단어 개수)
  • 측정 단위: 문장
  • 사용 통계: 잘못 인식된 단어 비율: (잘못 인식된 단어 수 / 표준 음성 단어 수)
  • 로직
    1. 사용자 음성 단어 리스트에서 표준 음성 단어 리스트에 없는 단어를 "잘못 인식된 단어"로 분류
    2. 표준 음성 단어 리스트에서 사용자 음성 단어 리스트를 제외하여 누락된 단어를 추가
    3. 잘못 인식된 단어 개수 및 비율 계산
  • 평가 기준: 잘못 인식된 단어 비율이 0에 가까울수록 Good
  • 피드백: 잘못 인식된 단어 리스트를 학습자에게 제공
[tip] 항목별 평가 방식 정리
평가 항목 단위 분석 대상 데이터
형태
평가 방법
피치 패턴 문장 음절의 억양(피치 주파수) 변화 패턴 벡터 잘못 인식된 단어 제외   문장 내 모든 음절의 피치 변화 패턴(벡터) 생성  두 음성의 코사인 유사도 계산 
리듬 패턴 문장 음절의 길이 패턴 벡터 잘못 인식된 단어 제외  문장 내 모든 음절의 길이 패턴(벡터) 생성 → 두 음성의 코사인 유사도 계산
발화 속도 문장 초당 발화 단어 수 스칼라 잘못 인식된 단어 제외 → 문장 전체의 발화 시작/끝 시간을 사용하여 초당 발화 단어 수 계산 → 두 음성의 상대적 비율 계산
발화 중단 패턴 문장 단어와 단어 사이의 침묵 구간 길이 패턴 벡터 잘못 인식된 단어 제외 →  침묵 구간(단어 사이의 정지 시간) 벡터 생성 → 두 음성의 코사인 유사도 계산
잘못 인식된 단어 문장 단어 리스트 스칼라 표준 음성의 단어 리스트에 없거나 일치하지 않는 단어를 잘못 인식된 단어로 분류 전체 단어 수 대비 잘못 인식된 단어 비율 계산 & 리스트 출력
[tip] Pitch Pattern: 단어별 평가 vs 문장 전체 평가
  단어별 평가 문장 전체 평가
장점 - 단어별 억양 변화(피치 패턴)를 정밀하게 분석 가능
- 특정 단어에서의 이상치(outlier) 감지 가
- 문장 전체에서 피치 패턴의 흐름을 평가하므로, 유효한 피치 데이터 부족 문제를 완화
- 단어별 데이터를 분리하지 않고 하나의 벡터로 연결해 안정적인 유사도 계산 가능
단점 - 각 단어의 피치 구간이 짧아 유효한 피치 데이터를 얻기 어려움
- 단어별 피치 데이터가 적으면 코사인 유사도를 계산하기에 벡터가 불충분할 수 있음
- nan 발생 가능성 또는 단어별 차이가 제대로 반영되지 않을 가능성
- 단어별 이상치 감지에는 부적합
- 문장의 길이가 길거나 불규칙할 경우 피치 변화 패턴이 덜 구체적일 수 있음

 

2. 채점 기준 선정

1) 데이터 준비

  • 오픈소스(AI Hub) 학습용 아동 영어 음성 데이터
    • 데이터 수: 100개
    • 데이터 특징: 실제 아동의 영어 음성 발화
    • 용도: 아동 음성의 채점 결과 분포 확인
  • TTS 표준 음성 데이터
    • TTS 모델: Tortoise TTS (High Quality)
    • 학습 데이터: 실제 미국 남성 영어 음원
    • 데이터 수: 100개
    • 데이터 특징: 고품질의 표준 발화 음성
    • 용도: 아동 음성의 채점 기준
  • 스크립트
    • 오픈소스(AI Hub) 학습용 아동 영어 음성 라벨링 데이터
    • 두 음성 데이터셋은 동일한 스크립트를 사용하여 채점됨
    • 데이터 수: 100개

2) 채점 기준 및 로직

  • TTS 표준 음성을 기준으로, 오픈 소스 아동 음성의 채점 결과 분포를 확인
  • 각 채점 항목별로 최소값(min), 최대값(max), Q1 - Q3 범위를 확인하고 이를 채점 기준으로 사용

3) 채점 결과 분석

① 평가 결과 범위 확인

  • 표준 발화와 아동 발화 간의 차이 분포를 확인
    • 5가지 채점 항목의 min, max, median, Q1, Q3, (mean) 값을 계산

② 서비스 구현

  • 계산된 범위를 기준으로 피드백 화면을 구현
  • 두 사용자 그룹을 명확히 분리
    • Sentence 단위 피드백 (아동 대상): 아동은 즉각적이고 간단한 문구로 동기를 얻음
    • Title 단위 종합 피드백 (학부모 대상): 학부모는 종합적이고 분석적인 정보를 통해 아동의 학습 진도를 파악할 수 있음

[Sentence 단위 피드백 (아동 대상)]

① 피드백 시점

  • 아동이 한 문장을 녹음한 직후

② 피드백 목적

  • 즉각적인 학습 동기 유발 및 개선 유도
  • 각 문장에서 잘못된 점을 빠르게 피드백하여 아동이 다음 발화에서 수정하도록 도움

③ 피드백 방식

  • 채점 항목: 잘못 인식된 단어 비율
  • 출력 메시지
    • "Excellent! 최고에요!"
    • "Good! 정말 멋져요!"
    • "Try Again! 다시 한 번 시도해볼까요?"
  • 기준
    • 아동 데이터의 Q1 - Q3 범위 안에 포함되면 "Excellent"
    • min-max 범위 내에 포함되면 "Good"
    • 범위를 벗어나면 "Try Again"

[Title 단위 피드백 (학부모 대상)]

① 피드백 시점

  • 아동이 한 Title(책)을 완전히 학습한 후

② 피드백 목적

  • 아동의 전체 학습 상태를 요약하여 학부모가 이해할 수 있도록 전달
  • 아동의 발음, 억양, 리듬 등 전반적인 평가 제공

③ 피드백 방식

  • 채점 항목
    • 피치 패턴
    • 리듬 패턴
    • 발화 속도
    • 발화 중단 패턴
    • 잘못 인식된 단어
  • 점수화 및 그래프
    • 각 항목에 대해 100점 만점 기준으로 점수 부여
    • 점수 기준
      • Q1 - Q3 범위 안에 포함되면 100점
      • min-max 범위 내에 포함되면 80점
      • 범위를 벗어나면 70점
    • 5각형 그래프 출력
      • 모든 Sentence의 점수 평균으로 Title의 각 항목의 점수를 요약하여 시각화

 

3. 코드 작성

1) 표준 발화와 아동 발화 간의 차이 분포 확인

import os
import re
import json
import numpy as np
import librosa
import parselmouth
from scipy.spatial.distance import cosine
from google.cloud import speech
from dotenv import load_dotenv


# Google Cloud Speech-to-Text API 설정
load_dotenv()
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.getenv("KEY_PATH")

speech_client = speech.SpeechClient()

def preprocess_audio(input_path, target_sr=16000):
    """오디오를 읽고 샘플링 속도를 변환"""
    y, sr = librosa.load(input_path, sr=None)
    if sr != target_sr:
        y = librosa.resample(y, orig_sr=sr, target_sr=target_sr)
    return y, target_sr


def recognize_speech(y, sr, language_code="en-US"):
    """Google Speech-to-Text API를 사용해 단어별 타임스탬프 추출"""
    import soundfile as sf
    from io import BytesIO

    buffer = BytesIO()
    sf.write(buffer, y, sr, format="WAV")
    buffer.seek(0)
    audio = speech.RecognitionAudio(content=buffer.read())
    config = speech.RecognitionConfig(
        encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
        sample_rate_hertz=sr,
        language_code=language_code,
        enable_word_time_offsets=True,
    )
    response = speech_client.recognize(config=config, audio=audio)

    word_timestamps = []
    for result in response.results:
        for word_info in result.alternatives[0].words:
            word_timestamps.append(
                {
                    "word": word_info.word,
                    "start_time": word_info.start_time.total_seconds(),
                    "end_time": word_info.end_time.total_seconds(),
                }
            )
    return word_timestamps


def extract_sentence_pitch(y, sr):
    """문장 전체의 피치 데이터를 추출"""
    snd = parselmouth.Sound(y, sr)
    pitch = snd.to_pitch()
    frame_frequencies = pitch.selected_array["frequency"]
    valid_frequencies = frame_frequencies[
        frame_frequencies > 0
    ]  # 유효한 피치 데이터만 추출
    return (
        valid_frequencies if len(valid_frequencies) > 0 else np.array([0])
    )  # 빈 경우 0으로 처리


def extract_sentence_syllable_durations(y, sr):
    """문장 전체에서 음절 길이를 계산"""
    snd = parselmouth.Sound(y, sr)
    pitch = snd.to_pitch()
    frame_frequencies = pitch.selected_array["frequency"]
    valid_frames = frame_frequencies[frame_frequencies > 0]
    syllable_duration = (
        len(valid_frames) / pitch.ceiling if len(valid_frames) > 0 else 0
    )
    return syllable_duration


def calculate_cosine_similarity(vec1, vec2):
    """벡터 간 코사인 유사도 계산 (길이 패딩 포함)"""
    if len(vec1) == 0 or len(vec2) == 0:
        return 0.0
    max_len = max(len(vec1), len(vec2))
    vec1 = np.pad(vec1, (0, max_len - len(vec1)), mode="constant")
    vec2 = np.pad(vec2, (0, max_len - len(vec2)), mode="constant")
    return 1 - cosine(vec1, vec2)


def calculate_pitch_similarity(user_pitch, ref_pitch):
    """문장 전체의 피치 패턴 유사도 계산"""
    return calculate_cosine_similarity(user_pitch, ref_pitch)


def calculate_rhythm_similarity(user_timestamps, ref_timestamps):
    """문장 전체의 리듬 패턴 유사도 계산"""
    user_rhythm = [
        duration for word in user_timestamps for duration in word["syllable_durations"]
    ]
    ref_rhythm = [
        duration for word in ref_timestamps for duration in word["syllable_durations"]
    ]
    return calculate_cosine_similarity(user_rhythm, ref_rhythm)


def calculate_speed_ratio(user_timestamps, ref_timestamps):
    """발화 속도 차이를 계산 (초당 발화 단어 수)"""
    if len(user_timestamps) == 0 or len(ref_timestamps) == 0:
        return 0.0

    user_duration = user_timestamps[-1]["end_time"] - user_timestamps[0]["start_time"]
    ref_duration = ref_timestamps[-1]["end_time"] - ref_timestamps[0]["start_time"]

    user_speed = len(user_timestamps) / user_duration if user_duration > 0 else 0
    ref_speed = len(ref_timestamps) / ref_duration if ref_duration > 0 else 0

    return user_speed / ref_speed if ref_speed > 0 else 0


def analyze_audio(user_audio_path, ref_audio_path):
    """사용자와 기준 음성을 분석하여 결과 반환"""
    y_user, sr_user = preprocess_audio(user_audio_path)
    y_ref, sr_ref = preprocess_audio(ref_audio_path)

    user_timestamps = recognize_speech(y_user, sr_user)
    ref_timestamps = recognize_speech(y_ref, sr_ref)

    # 잘못 인식된 단어 추출
    user_words = [word["word"] for word in user_timestamps]
    ref_words = [word["word"] for word in ref_timestamps]
    mispronounced_words = list(set(user_words) - set(ref_words))
    excluded_words = mispronounced_words

    total_words = len(ref_words)
    mispronounced_ratio = (
        len(mispronounced_words) / total_words if total_words > 0 else 0
    )

    valid_user_timestamps = [
        word for word in user_timestamps if word["word"] not in excluded_words
    ]
    valid_ref_timestamps = [
        word for word in ref_timestamps if word["word"] not in excluded_words
    ]

    # 문장 전체 음절 길이 계산
    user_syllable_duration = extract_sentence_syllable_durations(y_user, sr_user)
    ref_syllable_duration = extract_sentence_syllable_durations(y_ref, sr_ref)
    rhythm_similarity = calculate_cosine_similarity(
        [user_syllable_duration], [ref_syllable_duration]
    )

    # 문장 전체 피치 패턴 계산
    user_pitch = extract_sentence_pitch(y_user, sr_user)
    ref_pitch = extract_sentence_pitch(y_ref, sr_ref)
    pitch_similarity = calculate_pitch_similarity(user_pitch, ref_pitch)

    # 발화 속도 및 기타 계산
    speed_ratio = calculate_speed_ratio(valid_user_timestamps, valid_ref_timestamps)
    pause_similarity = calculate_cosine_similarity(
        [word["end_time"] - word["start_time"] for word in valid_user_timestamps],
        [word["end_time"] - word["start_time"] for word in valid_ref_timestamps],
    )

    return {
        "Pitch Pattern": pitch_similarity,
        "Rhythm Pattern": rhythm_similarity,
        "Speed": speed_ratio,
        "Pause Pattern": pause_similarity,
        "Mispronounced Words": {
            "ratio": mispronounced_ratio,
            "list": mispronounced_words,
        },
    }


def compare_audio_folders(user_folder, ref_folder):
    user_files = sorted(os.listdir(user_folder), key=extract_number_from_filename)
    ref_files = sorted(os.listdir(ref_folder), key=extract_number_from_filename)

    results = {}
    for idx, (user_file, ref_file) in enumerate(zip(user_files, ref_files), start=1):
        user_audio_path = os.path.join(user_folder, user_file)
        ref_audio_path = os.path.join(ref_folder, ref_file)

        print(f"Comparing: {user_file} vs {ref_file}")
        result = analyze_audio(user_audio_path, ref_audio_path)
        results[f"comparison_{idx}"] = result

    return results


def main():
    user_folder = "./test_data/child_audio_100"
    ref_folder = "./test_data/tts_tortoise_audio_100"
    results = compare_audio_folders(user_folder, ref_folder)

    with open("results5.json", "w") as f:
        json.dump(results, f, indent=4)

    print(json.dumps(results, indent=4))


def extract_number_from_filename(filename):
    """파일 이름에서 숫자를 추출"""
    match = re.search(r"(\d+)", filename)
    return int(match.group(1)) if match else float("inf")


# 실행
if __name__ == "__main__":
    main()

 

2) 채점 기준 출력

import json
import numpy as np
import pandas as pd


def calculate_statistics(json_file_path):
    # JSON 파일 불러오기
    with open(json_file_path, "r") as file:
        data = json.load(file)

    # 비교 항목 데이터를 추출하여 DataFrame 생성
    comparisons = []
    for key, values in data.items():
        comparisons.append(
            {
                "Pitch Pattern": values["Pitch Pattern"],
                "Rhythm Pattern": values["Rhythm Pattern"],
                "Speed": values["Speed"],
                "Pause Pattern": values["Pause Pattern"],
                "Mispronounced Words Ratio": values["Mispronounced Words"]["ratio"],
            }
        )

    df = pd.DataFrame(comparisons)

    # 통계량 계산
    stats = (
        df.describe(percentiles=[0.25, 0.5, 0.75])
        .loc[["min", "25%", "50%", "75%", "max", "mean"]]
        .T
    )
    stats.columns = ["Min", "Q1", "Median", "Q3", "Max", "Mean"]

    # 결과 출력
    print(stats)


# 실행
json_file_path = "pronunciation_evaluation_result.json"
calculate_statistics(json_file_path)
                                Min        Q1    Median        Q3       Max  \
Pitch Pattern              0.575492  0.808059  0.869744  0.920034  0.992826   
Rhythm Pattern             1.000000  1.000000  1.000000  1.000000  1.000000   
Speed                      0.378378  0.694153  0.875484  1.056140  5.333333   
Pause Pattern              0.293117  0.687836  0.799097  0.903662  0.997037   
Mispronounced Words Ratio  0.000000  0.000000  0.090909  0.250000  1.500000   

                               Mean  
Pitch Pattern              0.857399  
Rhythm Pattern             1.000000  
Speed                      0.957689  
Pause Pattern              0.766998  
Mispronounced Words Ratio  0.157405

 

 

 

📙 내일 일정

  • 중간 프로젝트