Spaces:
Runtime error
Runtime error
File size: 15,263 Bytes
bb92a2a | 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 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | import os
import json
import torch
from torch.utils.data import Dataset, random_split
import numpy as np
import pickle
from typing import List, Dict, Tuple, Optional, Any
import math
import torch.nn.functional as F
import random
from collections import defaultdict
def load_vocabs(vocab_path: str) -> Dict[str, Any]:
"""Loads vocabularies and augments with per-component PAD/NONE indices.
For 3-part prediction, only `root`, `quality`, and `bass` are loaded.
"""
with open(vocab_path, 'rb') as f:
data = pickle.load(f)
root_map = data['root_to_idx']
pad_token = 'PAD'
none_tokens = ['N', 'None'] # allow either spelling in source vocabs
bass_map = root_map
# Only keep the three chord parts for prediction
vocabs = {
'root': root_map,
'quality': data['quality_to_idx'],
'bass': bass_map,
'key': data['key_to_idx'],
}
# Global root PAD index (back-compat)
vocabs['pad_idx'] = root_map[pad_token]
# Add per-component PAD and NONE indices
for comp, comp_map in list(vocabs.items()):
if comp == 'pad_idx':
continue
# per-component PAD index (must exist)
comp_pad_idx = comp_map.get(pad_token)
if comp_pad_idx is None:
raise ValueError(f"Component '{comp}' vocab lacks PAD token")
vocabs[f'{comp}_pad_idx'] = comp_pad_idx
# NONE index preference: N > None > PAD
none_idx = None
for tok in none_tokens:
if tok in comp_map:
none_idx = comp_map[tok]
break
if none_idx is None:
none_idx = comp_pad_idx
vocabs[f'{comp}_none_idx'] = none_idx
return vocabs
class PianoRollDataset(Dataset):
"""Dataset for piano roll representation."""
pad_idx = -1 # Will be updated in __init__
def __init__(
self,
data_root: str,
config: dict,
vocabs: Dict[str, Any],
split: str = 'train',
use_augmentation: bool = False,
use_key: bool = False,
):
self.data_root = data_root
self.config = config
self.n_beats = self.config['n_beats']
self.split = split
self.use_augmentation = use_augmentation
self.use_key = use_key
self.beat_resolution = self.config['beat_resolution']
self.label_resolution = self.config['label_resolution']
self.pr_to_label_ratio = self.beat_resolution // self.label_resolution
self.vocabs = vocabs
self.pad_idx = self.vocabs['pad_idx']
self.chord_components = ['root', 'quality', 'bass']
self.label_indices_map = {'root': 0, 'quality': 1, 'bass': 2}
if self.use_key:
self.chord_components.append('key')
self.label_indices_map['key'] = 3
# --- Lengths in pianoroll-frame resolution ---
self.max_len = self.n_beats * self.beat_resolution
for comp in self.chord_components:
setattr(self, f'{comp}_vocab', self.vocabs[comp])
setattr(self, f'{comp}_none_idx', self.vocabs[f'{comp}_none_idx'])
suffix = 'shift0.npz' if not self.use_augmentation else '.npz'
# print(f"Loading {suffix} files from {data_root}")
self.file_list = sorted([
os.path.join(data_root, f)
for f in os.listdir(data_root) if f.endswith(suffix)
])
def __len__(self) -> int:
return len(self.file_list)
def __getitem__(self, idx: int) -> Optional[Dict[str, torch.Tensor]]:
filepath = self.file_list[idx]
with np.load(filepath, allow_pickle=True) as data:
pianoroll_full = torch.from_numpy(data['pianoroll'].T).float()
labels_full = data['labels']
boundaries_full = data['boundaries']
pianoroll = pianoroll_full
labels = labels_full
# --- Create ground truth chord tensor from labels (map to per-component vocab indices) ---
target_indices = {}
for comp in self.chord_components:
vocab = getattr(self, f'{comp}_vocab')
none_idx = getattr(self, f'{comp}_none_idx')
label_col_idx = self.label_indices_map[comp]
col = labels[:, label_col_idx]
mapped_tensor = None
# If labels are already integer indices within range, accept directly
try:
if np.issubdtype(col.dtype, np.integer):
col_int = col.astype(np.int64)
if col_int.min(initial=0) >= 0 and col_int.max(initial=0) < len(vocab):
mapped_tensor = torch.from_numpy(col_int)
except Exception:
mapped_tensor = None
# Otherwise map string/mixed labels through vocab with fallback to none_idx
if mapped_tensor is None:
try:
col_list = col.astype(str).tolist()
except Exception:
col_list = [str(x) for x in col.tolist()]
mapped = [vocab.get(lbl, none_idx) for lbl in col_list]
mapped_tensor = torch.tensor(mapped, dtype=torch.long)
target_indices[comp] = mapped_tensor.long()
# --- Load pre-computed boundary flag ---
boundary_flag = torch.from_numpy(boundaries_full.astype(np.float32))
if self.split == 'train':
return self._get_train_item(pianoroll, target_indices, boundary_flag)
else: # 'val' or 'test'
piece_name = _get_piece_name(filepath)
# Build accurate targets from labels for evaluation
return self._get_eval_item(pianoroll, labels, boundary_flag, piece_name)
def _sample_stratified_start(self, X: int) -> int:
"""
Sample s ∈ {0..X} with P(s) ∝ 1 + beta * (s/X).
Implemented as a mixture of Uniform and 'linear-in-s' discrete law.
Exact, O(1), numerically stable.
beta ∈ [0,2]. beta=0 -> uniform; beta=1 -> mild late tilt (good default).
"""
if X <= 0:
return 0
beta = float(getattr(self, 'stratify_beta', 1.0))
# Mixture weights: P = a * Uniform + (1-a) * Linear(s)
a = 1.0 - beta / 2.0 # ∈ [0,1]
if np.random.rand() < a:
# Uniform over 0..X
return int(np.random.randint(0, X + 1))
else:
# Sample from Q(s) ∝ s over {0..X} (i.e., s=0 has weight 0).
# Do it by inverting triangular numbers over 1..X.
M = X * (X + 1) // 2 # sum_{s=1}^X s
r = np.random.randint(1, M + 1) # 1..M inclusive
s = int((math.isqrt(1 + 8 * r) - 1) // 2) # floor((sqrt(1+8r)-1)/2)
# Numerical guard (rare when r hits exact triangle): clamp
if s > X:
s = X
return s
def _get_train_item(self, pianoroll, target_indices, boundary_flag):
n_pr_frames = pianoroll.shape[0]
# start with at least half of window size and convert to label frames
max_start_label_frame = (n_pr_frames - self.max_len // 2) // self.pr_to_label_ratio
target_max_len = self.max_len // self.pr_to_label_ratio
# Stratified start over 0..max_start_label_frame (tilt to late positions)
start_label_frame = self._sample_stratified_start(max_start_label_frame)
start_pr_frame = start_label_frame * self.pr_to_label_ratio
# --- slice & pad encoder input ---
pr_segment = pianoroll[start_pr_frame : start_pr_frame + self.max_len]
pr_pad_amount = self.max_len - pr_segment.shape[0]
if pr_pad_amount > 0:
# keep dtype/device consistent with pr_segment
pr_pad = pr_segment.new_zeros((pr_pad_amount, pr_segment.shape[1]))
pr_segment = torch.cat([pr_segment, pr_pad], dim=0)
# --- slice targets at label resolution ---
target_start = start_label_frame
target_segs = {}
for comp in self.chord_components:
target_segs[comp] = target_indices[comp][target_start : target_start + target_max_len]
boundary_seg = boundary_flag[target_start : target_start + target_max_len]
# --- masks & padding for targets ---
current_target_len = target_segs[self.chord_components[0]].shape[0]
target_mask = torch.zeros(target_max_len, dtype=torch.bool)
target_mask[:current_target_len] = True
# expand target mask to encoder (frame) mask
encoder_mask = target_mask.repeat_interleave(self.pr_to_label_ratio)
if pr_pad_amount > 0:
encoder_mask[-pr_pad_amount:] = False
target_pad_amount = target_max_len - current_target_len
if target_pad_amount > 0:
for comp in self.chord_components:
comp_none_idx = getattr(self, f'{comp}_none_idx')
pad_tensor = torch.full((target_pad_amount,), comp_none_idx, dtype=torch.long)
target_segs[comp] = torch.cat([target_segs[comp], pad_tensor])
boundary_pad = torch.zeros(target_pad_amount, dtype=boundary_seg.dtype)
boundary_seg = torch.cat([boundary_seg, boundary_pad])
item = {
'encoder_input': pr_segment,
'target_boundary': boundary_seg,
'mask': target_mask,
'encoder_mask': encoder_mask,
}
for comp in self.chord_components:
item[f'target_{comp}'] = target_segs[comp]
return item
def _get_eval_item(self, pianoroll, labels, boundary_flag, piece_name):
# Reconstruct per-component target indices directly from the label matrix
n_label_frames = labels.shape[0]
target_indices = {}
for comp in self.chord_components:
vocab = getattr(self, f'{comp}_vocab')
none_idx = getattr(self, f'{comp}_none_idx')
label_col_idx = self.label_indices_map[comp]
# Extract the column for this component; handle types robustly
col = labels[:, label_col_idx]
mapped_tensor = None
# Case 1: already integer indices
try:
if np.issubdtype(col.dtype, np.integer):
col_int = col.astype(np.int64)
# If values look like valid indices, accept directly; otherwise fallback to mapping
if col_int.min(initial=0) >= 0 and col_int.max(initial=0) < len(vocab):
mapped_tensor = torch.from_numpy(col_int)
except Exception:
mapped_tensor = None
# Case 2: map from labels (strings or mixed types) to indices
if mapped_tensor is None:
try:
col_list = col.astype(str).tolist()
except Exception:
col_list = [str(x) for x in col.tolist()]
mapped = [vocab.get(lbl, none_idx) for lbl in col_list]
mapped_tensor = torch.tensor(mapped, dtype=torch.long)
target_indices[comp] = mapped_tensor.long()
mask = torch.ones(n_label_frames, dtype=torch.bool)
encoder_mask = torch.ones(pianoroll.shape[0], dtype=torch.bool)
item = {
'piece_name': piece_name,
'encoder_input': pianoroll,
'target_boundary': boundary_flag,
'mask': mask,
'encoder_mask': encoder_mask,
}
for comp in self.chord_components:
item[f'target_{comp}'] = target_indices[comp]
return item
def get_vocab_sizes(self) -> Dict[str, int]:
sizes = {comp: len(self.vocabs[comp]) for comp in self.chord_components}
return sizes
def get_pad_idx(self) -> int:
return self.pad_idx
def _get_piece_name(filename: str) -> str:
"""Extracts the base piece name from a filename by splitting on '_shift'."""
base_filename = os.path.basename(filename)
if '_shift' in base_filename:
piece_name = base_filename.split('_shift')[0]
else:
piece_name = base_filename
return piece_name
def create_datasets(
data_root: str,
config: dict,
vocabs: Dict[str, Any],
seed: int = 42,
) -> Tuple[Dataset, Dataset]:
"""
Create train and validation datasets with group-based splitting.
This ensures that all augmentations of a piece belong to the same split.
"""
full_dataset = PianoRollDataset(
data_root=data_root,
config=config,
vocabs=vocabs,
split='train', # split does not matter here
use_augmentation=config['use_augmentation'],
use_key=config['use_key'],
)
# Group files by piece name
piece_files = defaultdict(list)
for f in full_dataset.file_list:
piece_name = _get_piece_name(f)
piece_files[piece_name].append(f)
unique_pieces = sorted(list(piece_files.keys()))
# Shuffle for random split
random.seed(seed)
random.shuffle(unique_pieces)
# Split unique pieces (90% train, 10% validation)
train_size = int(0.9 * len(unique_pieces))
train_pieces = unique_pieces[:train_size]
val_pieces = unique_pieces[train_size:]
# Get file lists for each split, only use shift0.npz for validation
train_files = [file for piece in train_pieces for file in piece_files[piece]]
if config['use_augmentation']:
val_files = [file for piece in val_pieces for file in piece_files[piece] if file.endswith('shift0.npz')]
else:
val_files = [file for piece in val_pieces for file in piece_files[piece]]
print(f"Train files: {len(train_files)}, Val files: {len(val_files)}")
# Create datasets for each split with the correct file list
train_dataset = PianoRollDataset(data_root, config, vocabs, 'train', use_key=config['use_key'])
train_dataset.file_list = train_files
val_dataset = PianoRollDataset(data_root, config, vocabs, 'val', use_key=config['use_key'])
val_dataset.file_list = val_files
json.dump(sorted([_get_piece_name(file) for file in val_files]),
open('val_files_unique.json', 'w'), indent=2)
return train_dataset, val_dataset
def collate_fn(batch):
"""
Collate function that filters out empty or invalid samples.
For training, it uses default collate.
For evaluation (variable length), it handles padding if needed, but typically used with batch_size=1.
"""
batch = [item for item in batch if item is not None]
if not batch:
return {}
# If batch contains only a single sample, simply return that sample's dict.
# This is handy for evaluation where we usually set batch_size = 1 and do
# not need the extra list wrapper.
if len(batch) == 1 and 'piece_name' in batch[0]:
return batch[0]
# For training batches (fixed-length segments) every sample has the same
# sequence length, so the default PyTorch collate works fine.
if 'encoder_input' in batch[0] and batch[0]['encoder_input'].shape[0] == batch[-1]['encoder_input'].shape[0]:
return torch.utils.data.dataloader.default_collate(batch)
# Otherwise we have variable-length sequences – fall back to returning the
# list so the caller can deal with padding/iteration manually.
return batch
|