Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for 'upload_to_hf_hub()' path mismatch with 'save()' #3977

Merged
merged 16 commits into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a4b9b3d
fix for 'upload_to_hf_hub()' path mismatch with 'save()'
sanjaydasgupta Mar 22, 2024
2f80119
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2024
e11a52c
added one unit test 'test_upload_to_hf_hub__validate_upload_parameters2'
sanjaydasgupta Mar 22, 2024
b9f3819
Merge remote-tracking branch 'origin/upload-hf-issue-3925' into uploa…
sanjaydasgupta Mar 22, 2024
7257808
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 22, 2024
f749c0c
passed additional argument 'model_path_is_experiment_path=False' to '…
sanjaydasgupta Mar 22, 2024
5c58b4c
always normalize any user-provided path to the experiment-path, modif…
sanjaydasgupta Mar 23, 2024
fa92d13
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 23, 2024
c56c42f
The unit test added earlier is no longer correct or relevant
sanjaydasgupta Mar 23, 2024
a62b1f4
Merge remote-tracking branch 'origin/upload-hf-issue-3925' into uploa…
sanjaydasgupta Mar 23, 2024
8cea3e2
minor docstring edit - just to kick off CI
sanjaydasgupta Mar 23, 2024
c68132e
updated the docstrings for 'model_weight' and other minor code changes
sanjaydasgupta Mar 23, 2024
8b072d2
used global literals 'MODEL_FILE_NAME' and 'MODEL_WEIGHTS_FILE_NAME' …
sanjaydasgupta Mar 23, 2024
c632b52
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 23, 2024
43656be
Merge branch 'ludwig-ai:master' into upload-hf-issue-3925
sanjaydasgupta Mar 24, 2024
aa1750e
fixed a few formatting errors within docstrings
sanjaydasgupta Mar 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions ludwig/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
from ludwig.utils.torch_utils import DEVICE
from ludwig.utils.trainer_utils import get_training_report
from ludwig.utils.types import DataFrame, TorchDevice
from ludwig.utils.upload_utils import HuggingFaceHub

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1938,39 +1939,53 @@ def upload_to_hf_hub(

# Inputs

:param repo_id (`str`):
:param repo_id: (`str`)
A namespace (user or an organization) and a repo name separated
by a `/`.
:param model_path (`str`):
The path of the saved model. This is the top level directory where
the models weights as well as other associated training artifacts
are saved.
:param private (`bool`, *optional*, defaults to `False`):
:param model_path: (`str`)
The path of the saved model. This is either (a) the directory where
the 'models_weights' folder and the 'model_hyperparameters.json' file
are stored, or (b) the parent of that folder.
:param private: (`bool`, *optional*, defaults to `False`)
Whether the model repo should be private.
:param repo_type (`str`, *optional*):
:param repo_type: (`str`, *optional*)
Set to `"dataset"` or `"space"` if uploading to a dataset or
space, `None` or `"model"` if uploading to a model. Default is
`None`.
:param commit_message (`str`, *optional*):
:param commit_message: (`str`, *optional*)
The summary / title / first line of the generated commit. Defaults to:
`f"Upload {path_in_repo} with huggingface_hub"`
:param commit_description (`str` *optional*):
:param commit_description: (`str` *optional*)
The description of the generated commit

# Returns

:return: (bool) True for success, False for failure.
"""
if os.path.exists(os.path.join(model_path, "model", "model_weights")) and os.path.exists(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sanjaydasgupta The idea/direction seems great! I just have a couple of minor suggestions for you to consider (because if they work, they might improve the code quality slightly):

  1. Can we create the constants for the two directory names, "model" and "model_weights" and replace codebase-wise?
  2. Is it possible instead of passing the boolean model_path_is_experiment_path, determine the "experiments" directory and standardize all methods on using it -- so the "experiment" directory would be passed to them. I think that even if a function is passed the "model" directory, and in those cases we have to make the assumption that "experiments" is "model/..", this would still be reasonable and acceptable. What do you think?

Thank you very much!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexsherstinsky, responding to your (2) above - sounds like a great idea! Let me take a closer look at the code.

Copy link
Contributor Author

@sanjaydasgupta sanjaydasgupta Mar 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @alexsherstinsky, both your suggestions have been implemented.

There was already a label MODEL_WEIGHTS_FILE_NAME defined (and used in some places) for the literal string "model_weights". So the choice of a label for "model" fell naturally on MODEL_FILE_NAME -- not the best, but there it is for the time being.

os.path.join(model_path, "model", MODEL_HYPERPARAMETERS_FILE_NAME)
):
model_path_is_experiment_path = True
sanjaydasgupta marked this conversation as resolved.
Show resolved Hide resolved
elif os.path.exists(os.path.join(model_path, "model_weights")) and os.path.exists(
os.path.join(model_path, MODEL_HYPERPARAMETERS_FILE_NAME)
):
model_path_is_experiment_path = False
else:
raise ValueError(
f"Can't find 'model_weights' and '{MODEL_HYPERPARAMETERS_FILE_NAME}' either at "
f"'{model_path}' or at '{model_path}/model'"
)
model_service = get_upload_registry()["hf_hub"]
hub = model_service()
hub: HuggingFaceHub = model_service()
hub.login()
upload_status = hub.upload(
upload_status: bool = hub.upload(
repo_id=repo_id,
model_path=model_path,
repo_type=repo_type,
private=private,
commit_message=commit_message,
commit_description=commit_description,
model_path_is_experiment_path=model_path_is_experiment_path,
)
return upload_status

Expand Down
43 changes: 38 additions & 5 deletions ludwig/utils/upload_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def upload(
commit_description: str | None = None,
dataset_file: str | None = None,
dataset_name: str | None = None,
model_path_is_experiment_path: bool = True,
) -> bool:
"""Abstract method to upload trained model artifacts to the target repository.

Expand All @@ -67,6 +68,7 @@ def _validate_upload_parameters(
private: bool | None = False,
commit_message: str | None = None,
commit_description: str | None = None,
model_path_is_experiment_path: bool = True,
):
"""Validate parameters before uploading trained model artifacts.

Expand All @@ -87,6 +89,8 @@ def _validate_upload_parameters(
commit_description (str, optional): A description of the commit when uploading to version control
systems. Not used in the base class, but subclasses may use it for specific repository
implementations. Defaults to None.
model_path_is_experiment_path (bool, optional): Whether the 'model_path' argument points to the
experiment folder

Raises:
FileNotFoundError: If the model_path does not exist.
Expand All @@ -99,7 +103,10 @@ def _validate_upload_parameters(
raise FileNotFoundError(f"The path '{model_path}' does not exist.")

# Make sure the model is actually trained
trained_model_artifacts_path = os.path.join(model_path, "model", "model_weights")
if model_path_is_experiment_path:
trained_model_artifacts_path = os.path.join(model_path, "model", "model_weights")
else:
trained_model_artifacts_path = os.path.join(model_path, "model_weights")
if not os.path.exists(trained_model_artifacts_path):
raise Exception(
f"Model artifacts not found at {trained_model_artifacts_path}. "
Expand Down Expand Up @@ -143,6 +150,7 @@ def _validate_upload_parameters(
private: bool | None = False,
commit_message: str | None = None,
commit_description: str | None = None,
model_path_is_experiment_path: bool = True,
):
"""Validate parameters before uploading trained model artifacts.

Expand All @@ -165,6 +173,8 @@ def _validate_upload_parameters(
commit_description (str, optional): A description of the commit when uploading to version control
systems. Not used in the base class, but subclasses may use it for specific repository
implementations. Defaults to None.
model_path_is_experiment_path (bool, optional): Whether the 'model_path' argument points to the
experiment folder

Raises:
ValueError: If the repo_id does not have both a namespace and a repo name separated by a '/'.
Expand All @@ -183,9 +193,13 @@ def _validate_upload_parameters(
private,
commit_message,
commit_description,
model_path_is_experiment_path,
)

trained_model_artifacts_path = os.path.join(model_path, "model", "model_weights")
if model_path_is_experiment_path:
trained_model_artifacts_path = os.path.join(model_path, "model", "model_weights")
else:
trained_model_artifacts_path = os.path.join(model_path, "model_weights")
"""
Make sure the model's saved artifacts either contain:
1. pytorch_model.bin -> regular model training, such as ECD or for LLMs
Expand All @@ -207,7 +221,10 @@ def _validate_upload_parameters(
"either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`"
"or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA."
)
model_hyperparameters_path: str = os.path.join(model_path, "model")
if model_path_is_experiment_path:
model_hyperparameters_path: str = os.path.join(model_path, "model")
else:
model_hyperparameters_path: str = model_path
if MODEL_HYPERPARAMETERS_FILE_NAME not in os.listdir(model_hyperparameters_path):
raise ValueError(f"Can't find '{MODEL_HYPERPARAMETERS_FILE_NAME}' at {model_hyperparameters_path}.")

Expand All @@ -219,6 +236,7 @@ def upload(
private: bool | None = False,
commit_message: str | None = None,
commit_description: str | None = None,
model_path_is_experiment_path: bool = True,
**kwargs,
) -> bool:
"""Create an empty repo on the HuggingFace Hub and upload trained model artifacts to that repo.
Expand All @@ -242,6 +260,8 @@ def upload(
`f"Upload {path_in_repo} with huggingface_hub"`
commit_description (`str` *optional*):
The description of the generated commit
model_path_is_experiment_path (`bool`, *optional*):
Whether the 'model_path' argument points to the experiment folder
"""
# Validate upload parameters are in the right format
HuggingFaceHub._validate_upload_parameters(
Expand All @@ -251,6 +271,7 @@ def upload(
private,
commit_message,
commit_description,
model_path_is_experiment_path,
)

# Create empty model repo using repo_id, but it is okay if it already exists.
Expand All @@ -266,8 +287,12 @@ def upload(
commit_description_weights: str | None = (
f"{commit_description} (weights)" if commit_description else commit_description
)
if model_path_is_experiment_path:
folder_path = os.path.join(model_path, "model", "model_weights")
else:
folder_path = os.path.join(model_path, "model_weights")
upload_path_weights: CommitInfo = self.api.upload_folder(
folder_path=os.path.join(model_path, "model", "model_weights"),
folder_path=folder_path,
repo_id=repo_id,
repo_type=repo_type,
commit_message=commit_message_weights,
Expand All @@ -281,8 +306,12 @@ def upload(
commit_description_config: str | None = (
f"{commit_description} (config)" if commit_description else commit_description
)
if model_path_is_experiment_path:
path_or_fileobj = os.path.join(model_path, "model", MODEL_HYPERPARAMETERS_FILE_NAME)
else:
path_or_fileobj = os.path.join(model_path, MODEL_HYPERPARAMETERS_FILE_NAME)
upload_path_config: CommitInfo = self.api.upload_file(
path_or_fileobj=os.path.join(model_path, "model", MODEL_HYPERPARAMETERS_FILE_NAME),
path_or_fileobj=path_or_fileobj,
path_in_repo="ludwig_config.json",
repo_id=repo_id,
repo_type=repo_type,
Expand Down Expand Up @@ -334,6 +363,7 @@ def _validate_upload_parameters(
private: bool | None = False,
commit_message: str | None = None,
commit_description: str | None = None,
model_path_is_experiment_path: bool = True,
):
"""Validate parameters before uploading trained model artifacts.

Expand All @@ -354,6 +384,8 @@ def _validate_upload_parameters(
commit_description (str, optional): A description of the commit when uploading to version control
systems. Not used in the base class, but subclasses may use it for specific repository
implementations. Defaults to None.
model_path_is_experiment_path (bool, optional): Whether the 'model_path' argument points to the
experiment folder.

Raises:
ValueError: If the repo_id is too long.
Expand All @@ -368,6 +400,7 @@ def _validate_upload_parameters(
private,
commit_message,
commit_description,
model_path_is_experiment_path,
)

def upload(
Expand Down
131 changes: 131 additions & 0 deletions tests/ludwig/utils/test_upload_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,134 @@ def test_upload_to_hf_hub__validate_upload_parameters(
)
except Exception as exc:
assert False, f'"HuggingFaceHub._validate_upload_parameters()" raised an exception: "{exc}".'


@pytest.mark.parametrize(
"file_names,error_raised",
[
pytest.param(
[
"pytorch_model.bin",
],
None,
id="pretrained_model_weights_bin",
),
pytest.param(
[
"adapter_model.bin",
],
None,
id="adapter_model_weights_bin_unmerged", # backward compatibility for peft versions < 0.7.0
),
pytest.param(
[
"adapter_model.safetensors",
],
None,
id="adapter_model_weights_safetensors_unmerged",
),
pytest.param(
[
"adapter_model.bin",
"adapter_model.safetensors",
],
None,
id="adapter_model_weights_bin_and_safetensors_unmerged", # backward compatibility for peft versions < 0.7.0
),
pytest.param(
[
"pytorch_model.bin",
"adapter_model.safetensors",
],
None,
id="pretrained_model_weights_bin_and_adapter_model_weights_safetensors_merged",
),
pytest.param(
[],
(
ValueError,
"Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501
),
id="model_weights_missing",
),
pytest.param(
[
"pytorch_model.safetensors",
],
(
ValueError,
"Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501
),
id="model_weights_unexpected_name_format_combination",
),
pytest.param(
[
"pytorch_model.unkn",
],
(
ValueError,
"Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501
),
id="model_weights_unrecognized_format",
),
pytest.param(
[
"unknown_model.safetensors",
],
(
ValueError,
"Can't find model weights at {model_weights_path}. Trained model weights should either be saved as `pytorch_model.bin` for regular model training, or have `adapter_model.bin`or `adapter_model.safetensors` if using parameter efficient fine-tuning methods like LoRA.", # noqa E501
),
id="model_weights_unrecognized_name",
),
],
)
@pytest.mark.unit
def test_upload_to_hf_hub__validate_upload_parameters2(
output_directory_manager, file_names: list[str], error_raised: tuple[type, str] | None
):
"""Test "HuggingFaceHub._validate_upload_parameters()", which is executed in the path of upload to HuggingFace
Hub; for example: `upload hf_hub -repo_id "hf-account/repo-name" --model_path.

/content/results/api_experiment_run`.

Each test case consists of: 1) Populating the temporary output directory ("training_results_output) with zero or
more test model weights file; 2) Executing "HuggingFaceHub._validate_upload_parameters()"; and 3) Asserting on
presence/absence of errors.

This test differs from the previous test (`test_upload_to_hf_hub__validate_upload_parameters`) by passing the
`model` folder to the `model_path` argument of `HuggingFaceHub._validate_upload_parameters()`. The previous test
passed the `experiment` folder to the `model_path` argument. Also note the additional argument
`model_path_is_experiment_path=False` passed to `HuggingFaceHub._validate_upload_parameters`.
"""
output_directory: str = output_directory_manager
_build_fake_model_repo(
destination_directory=output_directory, experiment_name="my_simple_experiment_run", file_names=file_names
)

model_path: pathlib.Path = pathlib.Path(output_directory) / "my_simple_experiment_run" / "model"
model_weights_path: pathlib.Path = pathlib.Path(model_path / "model_weights")

repo_id: str = "test_account/test_repo"
model_path: str = str(model_path)
if error_raised:
error_class: type # noqa [F842] # incorrect flagging of "local variable is annotated but never used
error_message: str # noqa [F842] # incorrect flagging of "local variable is annotated but never used
error_class, error_message = error_raised
with pytest.raises(error_class) as excinfo:
HuggingFaceHub._validate_upload_parameters(
repo_id=repo_id,
model_path=model_path,
model_path_is_experiment_path=False,
)

assert str(excinfo.value) == error_message.format(model_weights_path=model_weights_path)
else:
try:
HuggingFaceHub._validate_upload_parameters(
repo_id=repo_id,
model_path=model_path,
model_path_is_experiment_path=False,
)
except Exception as exc:
assert False, f'"HuggingFaceHub._validate_upload_parameters()" raised an exception: "{exc}".'
Loading