Source code for music21.features.outputFormats

from __future__ import annotations

import pathlib
import typing as t
import unittest

from music21 import environment
from music21 import exceptions21

if t.TYPE_CHECKING:
    from music21.features.base import DataSet

environLocal = environment.Environment('features.outputFormats')


class OutputFormatException(exceptions21.Music21Exception):
    pass


[docs] class OutputFormat: ''' Provide output for a DataSet, which is passed in as an initial argument. ''' def __init__(self, dataSet: DataSet|None = None) -> None: # assume a two-dimensional array self.ext: str = '' # store a file extension if necessary # pass a data set object self._dataSet = dataSet
[docs] def getHeaderLines(self) -> list: ''' Get the header as a list of lines. ''' return [] # define in subclass
[docs] def getString(self, includeClassLabel: bool = True, includeId: bool = True, lineBreak: str = '\n') -> str: return '' # define in subclass
[docs] def write(self, fp: str|pathlib.Path|None = None, includeClassLabel: bool = True, includeId: bool = True) -> str|pathlib.Path: ''' Write the file. If no file path is given, a temporary file will be written. ''' if fp is None: fp = environLocal.getTempFile(suffix=self.ext) if not str(fp).endswith(self.ext): raise OutputFormatException('Could not get a temp file with the right extension') with open(fp, 'w', encoding='utf-8') as f: f.write(self.getString(includeClassLabel=includeClassLabel, includeId=includeId)) return fp
[docs] class OutputTabOrange(OutputFormat): ''' Tab delimited file format used with Orange. For more information, see: https://orange3.readthedocs.io/projects/orange-data-mining-library/en/latest/tutorial/data.html#saving-the-data ''' def __init__(self, dataSet: DataSet|None = None) -> None: super().__init__(dataSet=dataSet) self.ext = '.tab'
[docs] def getHeaderLines(self, includeClassLabel: bool = True, includeId: bool = True) -> list[list[str]]: # noinspection PyShadowingNames ''' Get the header as a list of lines. >>> f = [features.jSymbolic.ChangesOfMeterFeature] >>> ds = features.DataSet() >>> ds.addFeatureExtractors(f) >>> of = features.outputFormats.OutputTabOrange(ds) >>> for x in of.getHeaderLines(): print(x) ['Identifier', 'Changes_of_Meter'] ['string', 'discrete'] ['meta', ''] >>> ds = features.DataSet(classLabel='Composer') >>> ds.addFeatureExtractors(f) >>> of = features.outputFormats.OutputTabOrange(ds) >>> for x in of.getHeaderLines(): print(x) ['Identifier', 'Changes_of_Meter', 'Composer'] ['string', 'discrete', 'discrete'] ['meta', '', 'class'] ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get header lines without a DataSet') post = [] post.append(self._dataSet.getAttributeLabels( includeClassLabel=includeClassLabel, includeId=includeId)) # second row metadata row = [] for x in self._dataSet.getDiscreteLabels( includeClassLabel=includeClassLabel, includeId=includeId): if x is None: # this is a string entry row.append('string') elif x is True: # if True, it is discrete row.append('discrete') else: row.append('continuous') post.append(row) # third row metadata row = [] for x in self._dataSet.getClassPositionLabels(includeId=includeId): if x is None: # the id value row.append('meta') elif x is True: # if True, it is the class column row.append('class') else: row.append('') post.append(row) return post
[docs] def getString(self, includeClassLabel: bool = True, includeId: bool = True, lineBreak: str = '\n') -> str: ''' Get the complete DataSet as a string with the appropriate headers. ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get a string without a DataSet') msg = [] header = self.getHeaderLines(includeClassLabel=includeClassLabel, includeId=includeId) data = header + self._dataSet.getFeaturesAsList( includeClassLabel=includeClassLabel) for row in data: sub = [] for e in row: sub.append(str(e)) msg.append('\t'.join(sub)) return lineBreak.join(msg)
[docs] class OutputCSV(OutputFormat): ''' Comma-separated value list. ''' def __init__(self, dataSet: DataSet|None = None) -> None: super().__init__(dataSet=dataSet) self.ext = '.csv'
[docs] def getHeaderLines(self, includeClassLabel: bool = True, includeId: bool = True) -> list[list[str]]: ''' Get the header as a list of lines. >>> f = [features.jSymbolic.ChangesOfMeterFeature] >>> ds = features.DataSet(classLabel='Composer') >>> ds.addFeatureExtractors(f) >>> of = features.outputFormats.OutputCSV(ds) >>> of.getHeaderLines()[0] ['Identifier', 'Changes_of_Meter', 'Composer'] ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get header lines without a DataSet') post = [] post.append(self._dataSet.getAttributeLabels( includeClassLabel=includeClassLabel, includeId=includeId)) return post
[docs] def getString(self, includeClassLabel: bool = True, includeId: bool = True, lineBreak: str = '\n') -> str: if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get a string without a DataSet') msg = [] header = self.getHeaderLines(includeClassLabel=includeClassLabel, includeId=includeId) data = header + self._dataSet.getFeaturesAsList( includeClassLabel=includeClassLabel, includeId=includeId) for row in data: sub = [] for e in row: sub.append(str(e)) msg.append(','.join(sub)) return lineBreak.join(msg)
[docs] class OutputARFF(OutputFormat): ''' An ARFF (Attribute-Relation File Format) file. See https://web.archive.org/web/20160212022757/http://weka.wikispaces.com/ARFF+%28stable+version%29 for more details >>> oa = features.outputFormats.OutputARFF() >>> oa.ext '.arff' ''' def __init__(self, dataSet: DataSet|None = None) -> None: super().__init__(dataSet=dataSet) self.ext = '.arff'
[docs] def getHeaderLines(self, includeClassLabel: bool = True, includeId: bool = True) -> list[str]: ''' Get the header as a list of lines. >>> f = [features.jSymbolic.ChangesOfMeterFeature] >>> ds = features.DataSet(classLabel='Composer') >>> ds.addFeatureExtractors(f) >>> of = features.outputFormats.OutputARFF(ds) >>> for x in of.getHeaderLines(): print(x) @RELATION Composer @ATTRIBUTE Identifier STRING @ATTRIBUTE Changes_of_Meter NUMERIC @ATTRIBUTE class {} @DATA ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get header lines without a DataSet') post = [] # get three parallel lists attrs = self._dataSet.getAttributeLabels( includeClassLabel=includeClassLabel, includeId=includeId) discreteLabels = self._dataSet.getDiscreteLabels( includeClassLabel=includeClassLabel, includeId=includeId) classLabels = self._dataSet.getClassPositionLabels(includeId=includeId) post.append(f'@RELATION {self._dataSet.getClassLabel()}') for i, attrLabel in enumerate(attrs): discrete = discreteLabels[i] classLabel = classLabels[i] if not classLabel: # a normal attribute if discrete is None: # this is an identifier post.append(f'@ATTRIBUTE {attrLabel} STRING') elif discrete is True: post.append(f'@ATTRIBUTE {attrLabel} NUMERIC') else: # this needs to be a NOMINAL type post.append(f'@ATTRIBUTE {attrLabel} NUMERIC') else: values = self._dataSet.getUniqueClassValues() joined = ','.join(str(v) for v in values) post.append('@ATTRIBUTE class {' + joined + '}') # include start of data declaration post.append('@DATA') return post
[docs] def getString(self, includeClassLabel: bool = True, includeId: bool = True, lineBreak: str = '\n') -> str: if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get a string without a DataSet') msg = [] header = self.getHeaderLines(includeClassLabel=includeClassLabel, includeId=includeId) for row in header: msg.append(row) data = self._dataSet.getFeaturesAsList( includeClassLabel=includeClassLabel) # data is separated by commas for row in data: sub = [] for e in row: sub.append(str(e)) msg.append(','.join(sub)) return lineBreak.join(msg)
class Test(unittest.TestCase): def testARFFNumericClassValues(self): ''' Regression test: numeric (non-string) class values must not crash ARFF header generation. Previously the class declaration was built with ','.join(values), which raised a TypeError when the class values were ints or floats rather than strings. AI-assisted (Claude). ''' from music21 import converter from music21 import features ds = features.DataSet(classLabel='Meter') ds.addFeatureExtractors(features.extractorsById(['r31'])) s1 = converter.parse('tinynotation: 4/4 c4 d e f') s2 = converter.parse('tinynotation: 3/4 c4 d e') # integer class values, not strings ds.addMultipleData([s1, s2], classValues=[3, 4]) ds.process() self.assertEqual(ds.getUniqueClassValues(), [3, 4]) of = features.outputFormats.OutputARFF(dataSet=ds) classLines = [line for line in of.getHeaderLines() if line.startswith('@ATTRIBUTE class')] self.assertEqual(classLines, ['@ATTRIBUTE class {3,4}']) # building the full string must not raise either self.assertIn('@ATTRIBUTE class {3,4}', of.getString()) if __name__ == '__main__': import music21 music21.mainTest()