diff --git a/README.md b/README.md index ac65f1c24f..a37abc1cfe 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,11 @@ pip install fms-hf-tuning[fms-accel] `fms-acceleration` is a collection of plugins that packages that accelerate fine-tuning / training of large models, as part of the `fms-hf-tuning` suite. For more details on see [this section below](#fms-acceleration). ## Data format -We support two data formats: +We support the following data formats: -1. #### Pre-process the JSON/JSONL dataset +### 1. JSON formats with a single sequence and a specified response_template to use for masking on completion. + +#### 1.1 Pre-process the JSON/JSONL dataset Pre-process the JSON/JSONL dataset to contain a single sequence of each data instance containing input + Response. The trainer is configured to expect a response template as a string. For example, if one wants to prepare the `alpaca` format data to feed into this trainer, it is quite easy and can be done with the following code. ```python @@ -87,7 +89,7 @@ The same way can be applied to any dataset, with more info can be found [here](h Once the JSON is converted using the formatting function, pass the `dataset_text_field` containing the single sequence to the trainer. -2. #### Format JSON/JSONL on the fly +#### 1.2 Format JSON/JSONL on the fly Pass a JSON/JSONL and a `data_formatter_template` to use the formatting function on the fly while tuning. The template should specify fields of JSON with `{{field}}`. While tuning, the data will be converted to a single sequence using the template. JSON fields can contain alpha-numeric characters, spaces and the following special symbols - "." , "_", "-". @@ -101,8 +103,20 @@ data_formatter_template: `### Input: {{input}} \n\n##Label: {{output}}` Formatting will happen on the fly while tuning. The keys in template should match fields in JSON file. The `response template` corresponding to the above template will need to be supplied. in this case, `response template` = `\n## Label:`. +##### In conclusion, if using the reponse_template and single sequence, either the `data_formatter_template` argument or `dataset_text_field` needs to be supplied to the trainer. + +### 2. JSONL with input and output fields (no response template) + + Pass a JSONL containing fields "input" with source text and "output" with class labels. Pre-format the input as you see fit. The output field will simply be concatenated to the end of input to create single sequence, and input will be masked. + + The "input" and "output" field names are mandatory and cannot be changed. -##### In conclusion, either the `data_formatter_template` argument or `dataset_text_field` needs to be supplied to the trainer. +Example: Train.jsonl + +``` +{"input": "### Input: Colorado is a state in USA ### Output:", "output": "USA : Location"} +{"input": "### Input: Arizona is also a state in USA ### Output:", "output": "USA : Location"} +``` ## Supported Models diff --git a/tests/test_sft_trainer.py b/tests/test_sft_trainer.py index 7c96ccceb7..b01c216c4b 100644 --- a/tests/test_sft_trainer.py +++ b/tests/test_sft_trainer.py @@ -35,6 +35,7 @@ MALFORMATTED_DATA, MODEL_NAME, TWITTER_COMPLAINTS_DATA, + TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT, TWITTER_COMPLAINTS_JSON_FORMAT, ) @@ -724,3 +725,58 @@ def test_run_with_good_experimental_metadata(): additional_callbacks=[TrainerCallback()], exp_metadata=metadata, ) + + +### Tests for pretokenized data +def test_pretokenized_dataset(): + """Ensure that we can provide a pretokenized dataset with input/output format.""" + with tempfile.TemporaryDirectory() as tempdir: + train_args = copy.deepcopy(TRAIN_ARGS) + train_args.output_dir = tempdir + data_args = copy.deepcopy(DATA_ARGS) + data_args.dataset_text_field = None + data_args.response_template = None + data_args.training_data_path = TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT + sft_trainer.train(MODEL_ARGS, data_args, train_args, PEFT_PT_ARGS) + _validate_training(tempdir) + + +@pytest.mark.parametrize( + "dataset_text_field,response_template", + [ + ("foo", None), + (None, "bar"), + ], +) +def test_pretokenized_dataset_bad_args(dataset_text_field, response_template): + """Ensure that we can't provide only dataset text field / response template for pretok data.""" + with tempfile.TemporaryDirectory() as tempdir: + train_args = copy.deepcopy(TRAIN_ARGS) + train_args.output_dir = tempdir + + data_args = copy.deepcopy(DATA_ARGS) + data_args.dataset_text_field = dataset_text_field + data_args.response_template = response_template + data_args.training_data_path = TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT + # We should raise an error since we should not have a dataset text + # field or a response template if we have pretokenized data + with pytest.raises(ValueError): + sft_trainer.train(MODEL_ARGS, data_args, train_args, PEFT_PT_ARGS) + + +def test_pretokenized_dataset_wrong_format(): + """Ensure that we fail to generate data if the data is in the wrong format.""" + with tempfile.TemporaryDirectory() as tempdir: + train_args = copy.deepcopy(TRAIN_ARGS) + train_args.output_dir = tempdir + + data_args = copy.deepcopy(DATA_ARGS) + data_args.dataset_text_field = None + data_args.response_template = None + data_args.training_data_path = TWITTER_COMPLAINTS_DATA + + # It would be best to handle this in a way that is more understandable; we might + # need to directly add validation prior to the dataset generation since datasets + # is essentially swallowing a KeyError here. + with pytest.raises(ValueError): + sft_trainer.train(MODEL_ARGS, data_args, train_args, PEFT_PT_ARGS) diff --git a/tests/utils/test_preprocessing_utils.py b/tests/utils/test_preprocessing_utils.py index e24cf710a0..9d0e1519d5 100644 --- a/tests/utils/test_preprocessing_utils.py +++ b/tests/utils/test_preprocessing_utils.py @@ -18,7 +18,7 @@ from tuning.utils.preprocessing_utils import ( combine_sequence, format_dataset, - get_data_trainer_kwargs, + get_data_collator, get_formatted_dataset_with_single_sequence, get_preprocessed_dataset, load_hf_dataset_from_jsonl_file, @@ -42,6 +42,24 @@ def test_combine_sequence(input_element, output_element, expected_res): assert comb_seq == expected_res +@pytest.mark.parametrize( + "input_element,output_element,expected_res", + [ + ("foo ", "bar", "foo bar"), + ("foo\n", "bar", "foo\nbar"), + ("foo\t", "bar", "foo\tbar"), + ("foo", "bar", "foo bar"), + ], +) +def test_combine_sequence_adds_eos(input_element, output_element, expected_res): + """Ensure that input / output elements are combined with correct whitespace handling.""" + tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) + comb_seq = combine_sequence(input_element, output_element, tokenizer.eos_token) + expected_res += tokenizer.eos_token + assert isinstance(comb_seq, str) + assert comb_seq == expected_res + + # Tests for loading the dataset from disk def test_load_hf_dataset_from_jsonl_file(): input_field_name = "Tweet text" @@ -108,80 +126,53 @@ def test_get_preprocessed_dataset(max_sequence_length): assert key_lengths.pop() <= max_sequence_length -# Tests for fetching train args @pytest.mark.parametrize( - "use_validation_data, collator_type, packing", + "packing, response_template, formatted_train_dataset, max_seq_length, expected_collator", [ - (True, None, True), - (False, None, True), - (True, DataCollatorForCompletionOnlyLM, False), - (False, DataCollatorForCompletionOnlyLM, False), + ( + False, + "\n### Label:", + load_hf_dataset_from_jsonl_file( + TWITTER_COMPLAINTS_DATA, + input_field_name="Tweet text", + output_field_name="text_label", + ), + 1024, + DataCollatorForCompletionOnlyLM, + ), + ( + False, + None, + Dataset.from_list( + [ + { + "input_ids": [9437, 29, 210], + "attention_mask": [1, 1, 1], + "labels": [1, 20, 30], + } + ] + ), + 1024, + DataCollatorForSeq2Seq, + ), ], ) -def test_get_trainer_kwargs_with_response_template_and_text_field( - use_validation_data, collator_type, packing +def test_get_data_collator( + packing, + response_template, + formatted_train_dataset, + max_seq_length, + expected_collator, ): - training_data_path = TWITTER_COMPLAINTS_DATA - validation_data_path = training_data_path if use_validation_data else None - # Expected columns in the raw loaded dataset for the twitter data - column_names = set(["Tweet text", "ID", "Label", "text_label", "output"]) - trainer_kwargs = get_data_trainer_kwargs( - training_data_path=training_data_path, - validation_data_path=validation_data_path, - packing=packing, - response_template="\n### Label:", - max_sequence_length=100, - tokenizer=AutoTokenizer.from_pretrained(MODEL_NAME), - dataset_text_field="output", + """Ensure that the correct collator type is fetched based on the data args""" + collator = get_data_collator( + packing, + response_template, + AutoTokenizer.from_pretrained(MODEL_NAME), + formatted_train_dataset, + max_seq_length, ) - assert len(trainer_kwargs) == 3 - # If we are packing, we should not have a data collator - if collator_type is None: - assert trainer_kwargs["data_collator"] is None - else: - assert isinstance(trainer_kwargs["data_collator"], collator_type) - - # We should only have a validation dataset if one is present - if validation_data_path is None: - assert trainer_kwargs["eval_dataset"] is None - else: - assert isinstance(trainer_kwargs["eval_dataset"], Dataset) - assert set(trainer_kwargs["eval_dataset"].column_names) == column_names - - assert isinstance(trainer_kwargs["train_dataset"], Dataset) - assert set(trainer_kwargs["train_dataset"].column_names) == column_names - - -@pytest.mark.parametrize("use_validation_data", [True, False]) -def test_get_trainer_kwargs_with_custom_masking(use_validation_data): - training_data_path = TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT - validation_data_path = training_data_path if use_validation_data else None - # Expected columns in the raw loaded dataset for the twitter data - column_names = set(["input_ids", "attention_mask", "labels"]) - trainer_kwargs = get_data_trainer_kwargs( - training_data_path=training_data_path, - validation_data_path=validation_data_path, - packing=False, - response_template=None, - max_sequence_length=100, - tokenizer=AutoTokenizer.from_pretrained(MODEL_NAME), - dataset_text_field=None, - ) - assert len(trainer_kwargs) == 4 - # If we are packing, we should not have a data collator - assert isinstance(trainer_kwargs["data_collator"], DataCollatorForSeq2Seq) - - # We should only have a validation dataset if one is present - if validation_data_path is None: - assert trainer_kwargs["eval_dataset"] is None - else: - assert isinstance(trainer_kwargs["eval_dataset"], Dataset) - assert set(trainer_kwargs["eval_dataset"].column_names) == column_names - - assert isinstance(trainer_kwargs["train_dataset"], Dataset) - assert set(trainer_kwargs["train_dataset"].column_names) == column_names - # Needed to sidestep TRL validation - assert trainer_kwargs["formatting_func"] is not None + assert isinstance(collator, expected_collator) # Tests for validating data args @@ -197,6 +188,14 @@ def test_get_trainer_kwargs_with_custom_masking(use_validation_data): ), False, ), + # data formatter with no response template + ( + configs.DataArguments( + training_data_path=TWITTER_COMPLAINTS_DATA, + data_formatter_template="### Input: {{input}} \n\n### Response: {{output}}", + ), + False, + ), # response template with no dataset_text_field or formatter ( configs.DataArguments( @@ -205,9 +204,17 @@ def test_get_trainer_kwargs_with_custom_masking(use_validation_data): ), False, ), + # JSON without input / output for no single sequence arguments + ( + configs.DataArguments( + training_data_path=TWITTER_COMPLAINTS_DATA, + ), + False, + ), ], ) def test_validate_args(data_args, packing): + """Ensure that respective errors are thrown for incorrect data arguments""" with pytest.raises(ValueError): validate_data_args(data_args, packing) @@ -255,12 +262,27 @@ def test_get_formatted_dataset_with_single_sequence( data_formatter_template="### Text:{{input}} \n\n### Label: {{output}}", ) ), + # input/output JSON with masking on input + ( + configs.DataArguments( + training_data_path=TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT, + validation_data_path=TWITTER_COMPLAINTS_DATA_INPUT_OUTPUT, + ) + ), ], ) def test_format_dataset(data_args): + """Ensure that the train/eval data are properly formatted based on the data args / text field""" tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) - train_set, eval_set, dataset_text_field = format_dataset(data_args, tokenizer) + train_set, eval_set, dataset_text_field = format_dataset( + data_args, tokenizer, max_seq_length=1024 + ) assert isinstance(train_set, Dataset) assert isinstance(eval_set, Dataset) - assert dataset_text_field in train_set.column_names - assert dataset_text_field in eval_set.column_names + if dataset_text_field is None: + column_names = set(["input_ids", "attention_mask", "labels"]) + assert set(eval_set.column_names) == column_names + assert set(train_set.column_names) == column_names + else: + assert dataset_text_field in train_set.column_names + assert dataset_text_field in eval_set.column_names diff --git a/tuning/sft_trainer.py b/tuning/sft_trainer.py index 30095b986d..d889c67e7a 100644 --- a/tuning/sft_trainer.py +++ b/tuning/sft_trainer.py @@ -268,8 +268,14 @@ def train( formatted_train_dataset, formatted_validation_dataset, dataset_text_field, - ) = format_dataset(data_args, tokenizer) - data_collator = get_data_collator(packing, data_args.response_template, tokenizer) + ) = format_dataset(data_args, tokenizer, max_seq_length) + data_collator = get_data_collator( + packing, + data_args.response_template, + tokenizer, + formatted_train_dataset, + max_seq_length, + ) if framework is not None and framework.requires_agumentation: model, (peft_config,) = framework.augmentation( diff --git a/tuning/utils/preprocessing_utils.py b/tuning/utils/preprocessing_utils.py index 88db911a61..e3dbdb37d3 100644 --- a/tuning/utils/preprocessing_utils.py +++ b/tuning/utils/preprocessing_utils.py @@ -28,6 +28,10 @@ logger = logging.get_logger("sft_trainer_preprocessing") +# In future we may make the fields configurable +JSON_INPUT_KEY = "input" +JSON_OUTPUT_KEY = "output" + def validate_data_args(data_args: configs.DataArguments, packing: bool): @@ -36,20 +40,14 @@ def validate_data_args(data_args: configs.DataArguments, packing: bool): ), "Training data path has to be set and str" # Dataset containing single sequence needs a response template for masking - if data_args.response_template is None and data_args.dataset_text_field is not None: - if packing is False: - raise ValueError( - "Since dataset_text_field is provided and packing is disabled, \ - needs a corresponding response template for masking" - ) - - # Currently if packing is false, we require a response_template. This may change in future. - if packing is False: + if data_args.dataset_text_field or data_args.data_formatter_template: if data_args.response_template is None: - raise ValueError( - "Response template is None, needs to be set for training \ - with packing disabled." - ) + if packing is False: + raise ValueError( + "Since dataset_text_field or data_formatter_template \ + is provided and packing is disabled, \ + needs a corresponding response template for masking" + ) if data_args.response_template: # To use Response template, pass datasets with single sequence instances \ @@ -65,16 +63,32 @@ def validate_data_args(data_args: configs.DataArguments, packing: bool): "dataset_text_field and data_formatter_template are both set,\ but are mutually exclusive options" ) - # TODO(s) In future seupport two more formats: - # 1. Allow no response template, and JSON with input/output fields and mask input - # 2. Allow pretokenized Dataset besides JSON. + # If not single sequence, JSON should contain input/output fields + if not (data_args.dataset_text_field or data_args.data_formatter_template): + json_dataset = datasets.load_dataset( + "json", data_files=data_args.training_data_path + ) + if JSON_INPUT_KEY not in json_dataset["train"].column_names: + raise ValueError( + "JSON should contain input field if no dataset_text_field or \ + data_formatter_template specified" + ) + if JSON_OUTPUT_KEY not in json_dataset["train"].column_names: + raise ValueError( + "JSON should contain output field if no dataset_text_field or \ + data_formatter_template specified" + ) + # TODO(s) In future support + # Allow pretokenized Dataset besides JSON. def get_data_collator( packing: bool, response_template: Optional[str], tokenizer: AutoTokenizer, + formatted_train_dataset: Dataset, + max_seq_length: int, ) -> Callable: """Create and return the the appropriate collator type based on the configuration for packing, response_template, and dataset_text_field. @@ -86,6 +100,10 @@ def get_data_collator( Response template to be used for formatting by TRL. tokenizer: AutoTokenizer Loaded tokenizer object to be used by the collator. + formatted_train_dataset: Dataset + Train Dataset formatted for tuning + max_seq_length: int + Max sequence length expected Returns: Callable @@ -105,25 +123,29 @@ def get_data_collator( tokenizer=tokenizer, ignore_index=configs.IGNORE_INDEX, ) - # TO DO with future changes, - # 1. Support no packing and seq2seq colator without response template - # # if dataset_text_field is None and response_template is None: - # # Use the seq2seq data collator; - # # Note that this automatically pads labels with -100 - # return DataCollatorForSeq2Seq( - # tokenizer=tokenizer, padding=True, max_length=max_sequence_length - # ) - # 2. add anything needed for preprocessed input + # Note that this automatically pads labels with -100 + # TODO check if this is sufficient for preprocessed + if ( + "attention_mask" in formatted_train_dataset.column_names + and "labels" in formatted_train_dataset.column_names + ): + return DataCollatorForSeq2Seq( + tokenizer=tokenizer, padding=True, max_length=max_seq_length + ) raise ValueError( "Could not pick a data collator. Please refer to supported data formats" ) -def format_dataset(data_args: configs.DataArguments, tokenizer: AutoTokenizer): +def format_dataset( + data_args: configs.DataArguments, tokenizer: AutoTokenizer, max_seq_length: int +): """ Args: data_args: tuning.config.configs.DataArguments tokenizer: AutoTokenizer + max_seq_length: int + Max sequence length expected Returns: Tuple(Dataset, Dataset, str) tuple containing train_dataset, eval_dataset and dataset_text_field @@ -148,132 +170,25 @@ def format_dataset(data_args: configs.DataArguments, tokenizer: AutoTokenizer): data_args.data_formatter_template, ) logger.info("Validation dataset length is %s", len(eval_dataset)) - # TODO: add a else here for preprocessing - return train_dataset, eval_dataset, dataset_text_field - - -################################################################################### -### The functions below are not yet used. Iterative development towards new features - - -def get_data_collator_temp( - packing: bool, - dataset_text_field: Optional[str], - response_template: Optional[str], - max_sequence_length: int, - tokenizer: AutoTokenizer, -) -> Callable: - """Create and return the the appropriate collator type based on the configuration for packing, - response_template, and dataset_text_field. - - Args: - packing: bool - Whether or not we should apply packing or not. - dataset_text_field: Optional[str] - Dataset text field fto be used for formatting by TRL. - response_template: Optional[str] - Response template to be used for formatting by TRL. - max_sequence_length: int - Max sequence length to be used for sequence tokenization. - tokenizer: AutoTokenizer - Loaded tokenizer object to be used by the collator. - - Returns: - Callable - Callable collator to be leveraged by the trainer. - """ - if not packing: - if dataset_text_field is None and response_template is None: - # Use the seq2seq data collator; note that this automatically pads labels with -100 - return DataCollatorForSeq2Seq( - tokenizer=tokenizer, padding=True, max_length=max_sequence_length - ) - # TODO: near term - how response template ids are parsed out needs to be cleaned. - # The [2:] here applies if response template has \n prefix, it is needed to strip \n, - # otherwise template is not found. We will create issue to clean this out after we discuss - # data formats and collators we will support. - response_template_ids = tokenizer.encode( - response_template, add_special_tokens=False - )[2:] - return DataCollatorForCompletionOnlyLM( - response_template=response_template_ids, - tokenizer=tokenizer, - ignore_index=configs.IGNORE_INDEX, - ) - - -def get_data_trainer_kwargs( - training_data_path: str, - validation_data_path: str, - packing: bool, - response_template: Optional[str], - max_sequence_length: int, - tokenizer: AutoTokenizer, - dataset_text_field: Optional[str], -) -> Dict[str, Any]: - """Get trainer args related to data / processing. At the moment, this consists of: - - the training dataset - - the evaluation dataset - - the data collator - - Maybe a formatting a function [only for a special case for validation] - The result can be kwarg expanded into the trainer initialization. - - Args: - training_data_path: str - Path to the training data. - validation_data_path: str - Path to the validation data. - packing: bool - Whether or not we should apply packing or not. - response_template: Optional[str] - Response template to be used for formatting by TRL. - max_sequence_length: int - Max sequence length to be used for sequence tokenization. - tokenizer: AutoTokenizer - Loaded tokenizer object to be used by the collator. - dataset_text_field: Optional[str] - Dataset text field fto be used for formatting by TRL. - - Returns: - Dict[str, Any] - Data related kwargs to be used by the SFT Trainer. - """ - data_collator = get_data_collator_temp( - packing, dataset_text_field, response_template, max_sequence_length, tokenizer - ) - eval_dataset = None - data_kwargs = {} - if isinstance(data_collator, DataCollatorForSeq2Seq): - # HACK: This function is never called, but is needed to sidestep TRL's internal validation. - data_kwargs["formatting_func"] = lambda x: x + else: + # This is for JSON containing input/output fields train_dataset = get_preprocessed_dataset( - training_data_path, + data_args.training_data_path, tokenizer, - max_sequence_length, - input_field_name="input", - output_field_name="output", + max_seq_length, + input_field_name=JSON_INPUT_KEY, + output_field_name=JSON_OUTPUT_KEY, ) - if validation_data_path: + if data_args.validation_data_path: eval_dataset = get_preprocessed_dataset( - validation_data_path, + data_args.validation_data_path, tokenizer, - max_sequence_length, - input_field_name="input", - output_field_name="output", - ) - else: - train_dataset = get_formatted_dataset_with_single_sequence( - training_data_path, dataset_text_field, tokenizer - ) - if validation_data_path: - eval_dataset = get_formatted_dataset_with_single_sequence( - validation_data_path, dataset_text_field, tokenizer + max_seq_length, + input_field_name=JSON_INPUT_KEY, + output_field_name=JSON_OUTPUT_KEY, ) - data_kwargs["data_collator"] = data_collator - data_kwargs["train_dataset"] = train_dataset - data_kwargs["eval_dataset"] = eval_dataset - return data_kwargs + return train_dataset, eval_dataset, dataset_text_field def get_formatted_dataset_with_single_sequence( @@ -396,7 +311,7 @@ def get_jsonl_object(): ### Utils for custom masking / manipulating input / output strs, etc -def combine_sequence(input_element: str, output_element: str): +def combine_sequence(input_element: str, output_element: str, eos_token: str = ""): """Combines / concatenates input & output element. Args: @@ -404,6 +319,9 @@ def combine_sequence(input_element: str, output_element: str): Input component of the combined sequence. output_element: str Output component of the combined sequence. + eos_token: str + EOS token associated with the tokenizer. \ + If passed, it will be concatenated at end Returns: str @@ -412,8 +330,8 @@ def combine_sequence(input_element: str, output_element: str): if not input_element.endswith((" ", "\n", "\t")) and not output_element.startswith( (" ", "\n", "\t") ): - return input_element + " " + output_element - return input_element + output_element + return input_element + " " + output_element + eos_token + return input_element + output_element + eos_token def preprocess_and_tokenize( @@ -445,7 +363,7 @@ def preprocess_and_tokenize( Dictionary containing the input IDs/labels/attention mask for this record. """ combined_seq = combine_sequence( - element[input_field_name], element[output_field_name] + element[input_field_name], element[output_field_name], tokenizer.eos_token ) tokenized_comb_seqs = tokenizer(