Spaces:
Runtime error
Runtime error
| 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 | |