File size: 7,336 Bytes
15ff271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Data Splitting and Metadata Management with Data Leakage Protection.

Enforces:
1. Exact Duplicate Quarantine: Identical audio clips (all 22 duplicate clusters)
   are strictly confined to the TRAIN set to prevent any data leakage into Validation or Test.
2. Stratified 70/15/15 Splitting: Ensures balanced class distribution across Train, Validation, and Test.
3. Cryptographic Hash Lock: Generates splits_v1.json with a SHA-256 hash for strict reproducibility.
4. Generates dataset/metadata/metadata.csv with all established fields.
"""

import os
import json
import hashlib
from pathlib import Path
import pandas as pd
import numpy as np

from src.utils.config import (
    RAW_DATA_DIR,
    PROCESSED_DATA_DIR,
    METADATA_DIR,
    RANDOM_SEED,
    EMOTION_CLASSES,
    PROJECT_ROOT
)


def create_splits_and_metadata():
    print("=" * 70)
    print("STARTING DATA SPLITTING & LEAKAGE PROTECTION")
    print(f"Random Seed: {RANDOM_SEED}")
    print("=" * 70)

    # 1. Load the comprehensive dataset audit CSV
    audit_csv_path = METADATA_DIR / "dataset_audit.csv"
    if not audit_csv_path.exists():
        audit_csv_path = PROJECT_ROOT / "dataset_audit.csv"
    
    df = pd.read_csv(audit_csv_path)
    print(f"Loaded audit records for {len(df)} files.")

    # 2. Add processed file path and verify existence
    df["processed_file_path"] = df.apply(
        lambda row: str(PROCESSED_DATA_DIR / row["emotion"] / row["filename"]),
        axis=1
    )
    
    missing_processed = [p for p in df["processed_file_path"] if not os.path.exists(p)]
    if missing_processed:
        raise FileNotFoundError(f"Missing {len(missing_processed)} processed files! Run standardization first.")
    print("Verified all 5,000 processed files exist in dataset/processed/.")

    # 3. Identify duplicate audio files that MUST be quarantined to TRAIN
    # Any clip that belongs to a duplicate cluster goes to Train to prevent test leakage
    duplicate_mask = df["is_duplicate"] == True
    duplicate_df = df[duplicate_mask]
    non_duplicate_df = df[~duplicate_mask]
    
    print(f"Exact duplicate files to quarantine to TRAIN: {len(duplicate_df)} files ({df['duplicate_cluster_id'].nunique() - 1} clusters)")
    print(f"Unique files available for stratified partitioning: {len(non_duplicate_df)} files")

    # 4. Stratified Split on non-duplicate pool:
    # Target overall: Train ~70% (3500), Val ~15% (750), Test ~15% (750)
    # Since 44 duplicate files go into Train, remaining quota for Train is 3500 - 44 = 3456.
    # Total unique files = 4956.
    # Val = 750 (approx 150 per class)
    # Test = 750 (approx 150 per class)
    # Train unique = 4956 - 1500 = 3456
    
    np.random.seed(RANDOM_SEED)
    
    train_indices = list(duplicate_df.index)
    val_indices = []
    test_indices = []

    for emotion in EMOTION_CLASSES:
        emo_indices = non_duplicate_df[non_duplicate_df["emotion"] == emotion].index.tolist()
        np.random.shuffle(emo_indices)
        
        n_val = 150
        n_test = 150
        
        emo_val = emo_indices[:n_val]
        emo_test = emo_indices[n_val:n_val + n_test]
        emo_train = emo_indices[n_val + n_test:]
        
        val_indices.extend(emo_val)
        test_indices.extend(emo_test)
        train_indices.extend(emo_train)

    train_indices.sort()
    val_indices.sort()
    test_indices.sort()

    df.loc[train_indices, "split"] = "train"
    df.loc[val_indices, "split"] = "val"
    df.loc[test_indices, "split"] = "test"

    # Add source, group_id, speaker_id explicitly documenting limitation
    # As mandated by prompt: "Only include fields that can actually be established. Do not invent speaker IDs or source IDs."
    df["source"] = "BASE_multimedia_corpus"
    df["group_id"] = df["duplicate_cluster_id"].apply(lambda x: x if pd.notna(x) and x != "" else "independent")
    df["speaker_id"] = "not_annotated"

    # Verify duplicate leakage
    val_hashes = set(df.loc[val_indices, "md5_hash"])
    test_hashes = set(df.loc[test_indices, "md5_hash"])
    train_hashes = set(df.loc[train_indices, "md5_hash"])

    train_val_overlap = train_hashes.intersection(val_hashes)
    train_test_overlap = train_hashes.intersection(test_hashes)
    val_test_overlap = val_hashes.intersection(test_hashes)

    print("\n--- DATA LEAKAGE VERIFICATION ---")
    print(f"Identical Hash Overlap Train-Test: {len(train_test_overlap)} (Must be 0)")
    print(f"Identical Hash Overlap Train-Val:  {len(train_val_overlap)} (Must be 0)")
    print(f"Identical Hash Overlap Val-Test:    {len(val_test_overlap)} (Must be 0)")
    assert len(train_test_overlap) == 0, "CRITICAL ERROR: Data leakage detected between Train and Test!"
    assert len(train_val_overlap) == 0, "CRITICAL ERROR: Data leakage detected between Train and Val!"
    assert len(val_test_overlap) == 0, "CRITICAL ERROR: Data leakage detected between Val and Test!"
    print("PASSED: Zero audio duplicate leakage across splits!")

    # Print split summary
    print("\n--- SPLIT DISTRIBUTION ---")
    split_summary = df.groupby(["split", "emotion"]).size().unstack(fill_value=0)
    split_summary["Total"] = split_summary.sum(axis=1)
    print(split_summary)

    # 5. Save metadata.csv
    out_cols = [
        "audio_id",
        "filename",
        "file_path",
        "processed_file_path",
        "emotion",
        "duration_sec",
        "sample_rate",
        "channels",
        "bit_depth",
        "silence_percentage",
        "source",
        "group_id",
        "speaker_id",
        "quality_status",
        "is_duplicate",
        "split"
    ]
    metadata_csv_path = METADATA_DIR / "metadata.csv"
    metadata_csv_root = PROJECT_ROOT / "metadata.csv"
    df[out_cols].to_csv(metadata_csv_path, index=False)
    df[out_cols].to_csv(metadata_csv_root, index=False)
    print(f"\nSaved metadata.csv to {metadata_csv_path}")

    # 6. Save splits_v1.json
    splits_dict = {
        "split_version": "v1.0",
        "random_seed": RANDOM_SEED,
        "strategy": "Stratified 70/15/15 with Exact Duplicate Quarantine to Train",
        "split_counts": {
            "train": len(train_indices),
            "val": len(val_indices),
            "test": len(test_indices),
            "total": len(df)
        },
        "class_breakdown": {
            split: df[df["split"] == split]["emotion"].value_counts().to_dict()
            for split in ["train", "val", "test"]
        },
        "train_audio_ids": df.loc[train_indices, "audio_id"].tolist(),
        "val_audio_ids": df.loc[val_indices, "audio_id"].tolist(),
        "test_audio_ids": df.loc[test_indices, "audio_id"].tolist()
    }

    splits_json_str = json.dumps(splits_dict, indent=2)
    splits_sha256 = hashlib.sha256(splits_json_str.encode("utf-8")).hexdigest()
    splits_dict["sha256_checksum"] = splits_sha256

    splits_json_path = METADATA_DIR / "splits_v1.json"
    splits_json_root = PROJECT_ROOT / "splits_v1.json"
    with open(splits_json_path, "w", encoding="utf-8") as f:
        json.dump(splits_dict, f, indent=2)
    with open(splits_json_root, "w", encoding="utf-8") as f:
        json.dump(splits_dict, f, indent=2)

    print(f"Saved splits_v1.json to {splits_json_path} (SHA-256: {splits_sha256[:16]}...)")
    print("=" * 70)


if __name__ == "__main__":
    create_splits_and_metadata()