Repository: iterative/dvclive Branch: main Commit: 5ced79f3938f Files: 80 Total size: 602.4 KB Directory structure: gitextract_j98cv22l/ ├── .cruft.json ├── .gitattributes ├── .github/ │ ├── PULL_REQUEST_TEMPLATE.md │ ├── codecov.yml │ ├── dependabot.yml │ └── workflows/ │ ├── release.yml │ ├── tests.yml │ └── update-template.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.md ├── examples/ │ ├── DVCLive-Evidently.ipynb │ ├── DVCLive-Fabric.ipynb │ ├── DVCLive-HuggingFace.ipynb │ ├── DVCLive-PyTorch-Lightning.ipynb │ ├── DVCLive-Quickstart.ipynb │ ├── DVCLive-YOLO.ipynb │ └── DVCLive-scikit-learn.ipynb ├── noxfile.py ├── pyproject.toml ├── src/ │ └── dvclive/ │ ├── __init__.py │ ├── dvc.py │ ├── env.py │ ├── error.py │ ├── fabric.py │ ├── fastai.py │ ├── huggingface.py │ ├── keras.py │ ├── lgbm.py │ ├── lightning.py │ ├── live.py │ ├── monitor_system.py │ ├── optuna.py │ ├── plots/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── custom.py │ │ ├── image.py │ │ ├── metric.py │ │ ├── sklearn.py │ │ └── utils.py │ ├── py.typed │ ├── report.py │ ├── serialize.py │ ├── studio.py │ ├── utils.py │ ├── vscode.py │ └── xgb.py └── tests/ ├── __init__.py ├── conftest.py ├── frameworks/ │ ├── test_fabric.py │ ├── test_fastai.py │ ├── test_huggingface.py │ ├── test_keras.py │ ├── test_lgbm.py │ ├── test_lightning.py │ ├── test_optuna.py │ └── test_xgboost.py ├── plots/ │ ├── test_custom.py │ ├── test_image.py │ ├── test_metric.py │ └── test_sklearn.py ├── test_cleanup.py ├── test_context_manager.py ├── test_dvc.py ├── test_log_artifact.py ├── test_log_metric.py ├── test_log_param.py ├── test_logging.py ├── test_make_dvcyaml.py ├── test_make_report.py ├── test_make_summary.py ├── test_monitor_system.py ├── test_post_to_studio.py ├── test_resume.py ├── test_step.py ├── test_utils.py └── test_vscode.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cruft.json ================================================ { "template": "https://github.com/iterative/py-template", "commit": "e4ec95f4cfd03d4af0a8604d462ee11d07d63b42", "checkout": null, "context": { "cookiecutter": { "project_name": "dvclive", "package_name": "dvclive", "friendly_name": "dvclive", "author": "Iterative", "email": "support@dvc.org", "github_user": "iterative", "version": "0.0.0", "copyright_year": "2022", "license": "Apache-2.0", "docs": "False", "short_description": "Metric logger for ML projects.", "development_status": "Development Status :: 4 - Beta", "_template": "https://github.com/iterative/py-template" } }, "directory": null } ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ - [ ] ❗ I have followed the [Contributing to DVCLive](https://github.com/iterative/dvclive/blob/main/CONTRIBUTING.rst) guide. - [ ] 📖 If this PR requires [documentation](https://dvc.org/doc) updates, I have created a separate PR (or issue, at least) in [dvc.org](https://github.com/iterative/dvc.org) and linked it here. Thank you for the contribution - we'll try to review it as soon as possible. 🙏 ================================================ FILE: .github/codecov.yml ================================================ coverage: status: project: default: # auto compares coverage to the previous base commit target: auto # adjust accordingly based on how flaky your tests are # this allows a 10% drop from the previous base commit coverage threshold: 10% # non-blocking status checks informational: false ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - directory: "/" package-ecosystem: "pip" schedule: interval: "weekly" labels: - "maintenance" - directory: "/" package-ecosystem: "github-actions" schedule: interval: "weekly" labels: - "maintenance" ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [published] workflow_dispatch: env: FORCE_COLOR: "1" jobs: release: environment: pypi permissions: contents: read id-token: write runs-on: ubuntu-latest steps: - name: Check out the repository uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python 3.14 uses: actions/setup-python@v6 with: python-version: '3.14' - uses: astral-sh/setup-uv@v7 - name: Install nox run: uv pip install --system nox --upgrade - name: Build package run: nox -s build - name: Upload package if: github.event_name == 'release' uses: pypa/gh-action-pypi-publish@release/v1 ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: [main] pull_request: workflow_dispatch: env: FORCE_COLOR: "1" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - name: Check out the repository uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - uses: astral-sh/setup-uv@v7 with: enable-cache: false - name: Install nox run: uv pip install --system nox --upgrade - uses: actions/cache@v5 with: path: ~/.cache/pre-commit/ key: pre-commit-4|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Lint code run: nox -s lint tests: timeout-minutes: 30 strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] pyv: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] type: [core_tests] include: - os: ubuntu-latest pyv: "3.13" type: tests exclude: - os: ubuntu-latest pyv: "3.13" type: core_tests runs-on: ${{ matrix.os }} steps: - name: Check out the repository uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python ${{ matrix.pyv }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.pyv }} - uses: astral-sh/setup-uv@v7 with: enable-cache: false - name: Install nox run: uv pip install --system nox --upgrade - name: Run tests run: nox -s ${{ matrix.type }}-${{ matrix.pyv }} -- --cov-report=xml - name: Build package run: nox -s build - name: Upload coverage report uses: codecov/codecov-action@v5 check: if: always() needs: [lint, tests] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} ================================================ FILE: .github/workflows/update-template.yaml ================================================ name: Update template on: schedule: - cron: '5 1 * * *' # every day at 01:05 workflow_dispatch: jobs: update: runs-on: ubuntu-latest steps: - name: Check out the repository uses: actions/checkout@v5 - name: Update template uses: iterative/py-template@main ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Editors .idea .vscode .dvc/ .dvcignore src/dvclive/_dvclive_version.py ================================================ FILE: .pre-commit-config.yaml ================================================ default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-docstring-first - id: check-executables-have-shebangs - id: check-json - id: check-merge-conflict args: ["--assume-in-merge"] - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending args: ["--fix=lf"] - id: sort-simple-yaml - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell additional_dependencies: ["tomli"] exclude: > (?x)^( .*\.ipynb )$ - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.14.3" hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.15.0 hooks: - id: pretty-format-toml args: [--autofix, --no-sort] - id: pretty-format-yaml args: [--autofix, --indent, '2', '--offset', '2', --preserve-quotes] ================================================ FILE: CODE_OF_CONDUCT.rst ================================================ Contributor Covenant Code of Conduct ==================================== Our Pledge ---------- We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. Our Standards ------------- Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting Enforcement Responsibilities ---------------------------- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. Scope ----- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Enforcement ----------- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@dvc.org. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. Enforcement Guidelines ---------------------- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 1. Correction ~~~~~~~~~~~~~ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 2. Warning ~~~~~~~~~~ **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 3. Temporary Ban ~~~~~~~~~~~~~~~~ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 4. Permanent Ban ~~~~~~~~~~~~~~~~ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. Attribution ----------- This Code of Conduct is adapted from the `Contributor Covenant `__, version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct/. Community Impact Guidelines were inspired by `Mozilla’s code of conduct enforcement ladder `__. .. _homepage: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.rst ================================================ Contributor Guide ================= Thank you for your interest in improving this project. This project is open-source under the `Apache 2.0 license`_ and welcomes contributions in the form of bug reports, feature requests, and pull requests. Here is a list of important resources for contributors: - `Source Code`_ - `Issue Tracker`_ - `Code of Conduct`_ .. _Apache 2.0 license: https://opensource.org/licenses/Apache-2.0 .. _Source Code: https://github.com/iterative/dvclive .. _Issue Tracker: https://github.com/iterative/dvclive/issues How to report a bug ------------------- Report bugs on the `Issue Tracker`_. When filing an issue, make sure to answer these questions: - Which operating system and Python version are you using? - Which version of this project are you using? - What did you do? - What did you expect to see? - What did you see instead? The best way to get your bug fixed is to provide a test case, and/or steps to reproduce the issue. How to request a feature ------------------------ Request features on the `Issue Tracker`_. How to set up your development environment ------------------------------------------ You need Python 3.9+. - Clone the repository: .. code:: console $ git clone https://github.com/iterative/dvclive $ cd dvclive - Set up a virtual environment: .. code:: console $ python -m venv .venv $ source .venv/bin/activate Install in editable mode including development dependencies: .. code:: console $ pip install -e .[tests] If you need to test against a specific framework, you can install it separately: .. code:: console $ pip install -e .[tests,tf] $ pip install -e .[tests,optuna] How to test the project ----------------------- Run the full test suite: .. code:: console $ pytest -v tests Tests are located in the ``tests`` directory, and are written using the pytest_ testing framework. .. _pytest: https://pytest.readthedocs.io/ How to submit changes --------------------- Open a `pull request`_ to submit changes to this project. Your pull request needs to meet the following guidelines for acceptance: - The test suite must pass without errors and warnings. - Include unit tests. - If your changes add functionality, update the documentation accordingly. Feel free to submit early, though—we can always iterate on this. To run linting and code formatting checks, you can use `pre-commit`: .. code:: console $ pre-commit run --all-files It is recommended to open an issue before starting work on anything. This will allow a chance to talk it over with the owners and validate your approach. .. _pull request: https://github.com/iterative/dvclive/pulls .. github-only .. _Code of Conduct: CODE_OF_CONDUCT.rst ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022 Iterative. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # DVCLive [![PyPI](https://img.shields.io/pypi/v/dvclive.svg)](https://pypi.org/project/dvclive/) [![Status](https://img.shields.io/pypi/status/dvclive.svg)](https://pypi.org/project/dvclive/) [![Python Version](https://img.shields.io/pypi/pyversions/dvclive)](https://pypi.org/project/dvclive) [![License](https://img.shields.io/pypi/l/dvclive)](https://opensource.org/licenses/Apache-2.0) [![Tests](https://github.com/iterative/dvclive/workflows/Tests/badge.svg?branch=main)](https://github.com/iterative/dvclive/actions?workflow=Tests) [![Codecov](https://codecov.io/gh/iterative/dvclive/branch/main/graph/badge.svg)](https://app.codecov.io/gh/iterative/dvclive) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) DVCLive is a Python library for logging machine learning metrics and other metadata in simple file formats, which is fully compatible with DVC. # [Documentation](https://dvc.org/doc/dvclive) - [Get Started](https://dvc.org/doc/start/experiments) - [How it Works](https://dvc.org/doc/dvclive/how-it-works) - [API Reference](https://dvc.org/doc/dvclive/live) - [Integrations](https://dvc.org/doc/dvclive/ml-frameworks) ______________________________________________________________________ # Quickstart | Python API Overview | PyTorch Lightning | Scikit-learn | Ultralytics YOLO v8 | |--------|--------|--------|--------| | | | | | ## Install *dvclive* ```console $ pip install dvclive ``` ## Initialize DVC Repository ```console $ git init $ dvc init $ git commit -m "DVC init" ``` ## Example code Copy the snippet below into `train.py` for a basic API usage example: ```python import time import random from dvclive import Live params = {"learning_rate": 0.002, "optimizer": "Adam", "epochs": 20} with Live() as live: # log a parameters for param in params: live.log_param(param, params[param]) # simulate training offset = random.uniform(0.2, 0.1) for epoch in range(1, params["epochs"]): fuzz = random.uniform(0.01, 0.1) accuracy = 1 - (2 ** - epoch) - fuzz - offset loss = (2 ** - epoch) + fuzz + offset # log metrics to studio live.log_metric("accuracy", accuracy) live.log_metric("loss", loss) live.next_step() time.sleep(0.2) ``` See [Integrations](https://dvc.org/doc/dvclive/ml-frameworks) for examples using DVCLive alongside different ML Frameworks. ## Running Run this a couple of times to simulate multiple experiments: ```console $ python train.py $ python train.py $ python train.py ... ``` ## Comparing DVCLive outputs can be rendered in different ways: ### DVC CLI You can use [dvc exp show](https://dvc.org/doc/command-reference/exp/show) and [dvc plots](https://dvc.org/doc/command-reference/plots) to compare and visualize metrics, parameters and plots across experiments: ```console $ dvc exp show ``` ``` ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Experiment Created train.accuracy train.loss val.accuracy val.loss step epochs ───────────────────────────────────────────────────────────────────────────────────────────────────────────── workspace - 6.0109 0.23311 6.062 0.24321 6 7 master 08:50 PM - - - - - - ├── 4475845 [aulic-chiv] 08:56 PM 6.0109 0.23311 6.062 0.24321 6 7 ├── 7d4cef7 [yarer-tods] 08:56 PM 4.8551 0.82012 4.5555 0.033533 4 5 └── d503f8e [curst-chad] 08:56 PM 4.9768 0.070585 4.0773 0.46639 4 5 ───────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` ```console $ dvc plots diff $(dvc exp list --names-only) --open ``` ![dvc plots diff](./docs/dvc_plots_diff.png) ### DVC Extension for VS Code Inside the [DVC Extension for VS Code](https://marketplace.visualstudio.com/items?itemName=Iterative.dvc), you can compare and visualize results using the [Experiments](https://github.com/iterative/vscode-dvc/blob/main/extension/resources/walkthrough/experiments-table.md) and [Plots](https://github.com/iterative/vscode-dvc/blob/main/extension/resources/walkthrough/plots.md) views: ![VSCode Experiments](./docs/vscode_experiments.png) ![VSCode Plots](./docs/vscode_plots.png) While experiments are running, live updates will be displayed in both views. ### DVC Studio If you push the results to [DVC Studio](https://dvc.org/doc/studio), you can compare experiments against the entire repo history: ![Studio Compare](./docs/studio_compare.png) You can enable [Studio Live Experiments](https://dvc.org/doc/studio/user-guide/projects-and-experiments/live-metrics-and-plots) to see live updates while experiments are running. ______________________________________________________________________ # Comparison to related technologies **DVCLive** is an *ML Logger*, similar to: - [MLFlow](https://mlflow.org/) - [Weights & Biases](https://wandb.ai/site) - [Neptune](https://neptune.ai/) The main differences with those *ML Loggers* are: - **DVCLive** does not **require** any additional services or servers to run. - **DVCLive** metrics, parameters, and plots are [stored as plain text files](https://dvc.org/doc/dvclive/how-it-works#directory-structure) that can be versioned by tools like Git or tracked as pointers to files in DVC storage. - **DVCLive** can save experiments or runs as [hidden Git commits](https://dvc.org/doc/dvclive/how-it-works#track-the-results). You can then use different [options](#comparing) to visualize the metrics, parameters, and plots across experiments. ______________________________________________________________________ # Contributing Contributions are very welcome. To learn more, see the [Contributor Guide](CONTRIBUTING.rst). # License Distributed under the terms of the [Apache 2.0 license](https://opensource.org/licenses/Apache-2.0), *dvclive* is free and open source software. ================================================ FILE: examples/DVCLive-Evidently.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": { "id": "WpfOFaqHcnAt" }, "source": [ "# Install Evidently and DVC with DVCLive" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "executionInfo": { "elapsed": 2337, "status": "ok", "timestamp": 1697468096427, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "BqWpagFPZ45W" }, "outputs": [], "source": [ "!pip uninstall -q -y sqlalchemy pyarrow ipython-sql pandas-gbq" ] }, { "cell_type": "code", "execution_count": 2, "metadata": { "executionInfo": { "elapsed": 33615, "status": "ok", "timestamp": 1697468130037, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "DijzqeokW595" }, "outputs": [], "source": [ "%%capture\n", "!pip install -q dvc==3.25.0 dvclive==3.0.1 evidently==0.4.5 pandas==1.5.3" ] }, { "cell_type": "markdown", "metadata": { "id": "ZyZ2sX8GcvMU" }, "source": [ "# Load the data" ] }, { "cell_type": "code", "execution_count": 3, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "executionInfo": { "elapsed": 1772, "status": "ok", "timestamp": 1697468131788, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "ZUrB0D59XMDD", "outputId": "9f6f5a3c-f856-4d56-a8fb-ec4483ec6127" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "--2023-10-16 14:55:29-- https://archive.ics.uci.edu/static/public/275/bike+sharing+dataset.zip\n", "Resolving archive.ics.uci.edu (archive.ics.uci.edu)... 128.195.10.252\n", "Connecting to archive.ics.uci.edu (archive.ics.uci.edu)|128.195.10.252|:443... connected.\n", "HTTP request sent, awaiting response... 200 OK\n", "Length: unspecified\n", "Saving to: ‘bike+sharing+dataset.zip’\n", "\n", "bike+sharing+datase [ <=> ] 273.43K 443KB/s in 0.6s \n", "\n", "2023-10-16 14:55:30 (443 KB/s) - ‘bike+sharing+dataset.zip’ saved [279992]\n", "\n", "Archive: bike+sharing+dataset.zip\n", " inflating: Readme.txt \n", " inflating: day.csv \n", " inflating: hour.csv \n" ] } ], "source": [ "!mkdir raw_data && \\\n", " cd raw_data && \\\n", " wget https://archive.ics.uci.edu/static/public/275/bike+sharing+dataset.zip && \\\n", " unzip bike+sharing+dataset.zip" ] }, { "cell_type": "code", "execution_count": 4, "metadata": { "executionInfo": { "elapsed": 357, "status": "ok", "timestamp": 1697468132141, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "P3XXcUrQY1EQ" }, "outputs": [], "source": [ "import pandas as pd" ] }, { "cell_type": "code", "execution_count": 5, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 206 }, "executionInfo": { "elapsed": 9, "status": "ok", "timestamp": 1697468132141, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "MDK0xkdbYCWg", "outputId": "ec8d2605-144d-45ff-b442-70ba858a44a3" }, "outputs": [ { "data": { "text/html": [ "\n", "
\n", "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
instantdtedayseasonyrmnthholidayweekdayworkingdayweathersittempatemphumwindspeedcasualregisteredcnt
012011-01-0110106020.3441670.3636250.8058330.160446331654985
122011-01-0210100020.3634780.3537390.6960870.248539131670801
232011-01-0310101110.1963640.1894050.4372730.24830912012291349
342011-01-0410102110.2000000.2121220.5904350.16029610814541562
452011-01-0510103110.2269570.2292700.4369570.1869008215181600
\n", "
\n", "
\n", "\n", "
\n", " \n", "\n", " \n", "\n", " \n", "
\n", "\n", "\n", "
\n", " \n", "\n", "\n", "\n", " \n", "
\n", "
\n", "
\n" ], "text/plain": [ " instant dteday season yr mnth holiday weekday workingday \\\n", "0 1 2011-01-01 1 0 1 0 6 0 \n", "1 2 2011-01-02 1 0 1 0 0 0 \n", "2 3 2011-01-03 1 0 1 0 1 1 \n", "3 4 2011-01-04 1 0 1 0 2 1 \n", "4 5 2011-01-05 1 0 1 0 3 1 \n", "\n", " weathersit temp atemp hum windspeed casual registered \\\n", "0 2 0.344167 0.363625 0.805833 0.160446 331 654 \n", "1 2 0.363478 0.353739 0.696087 0.248539 131 670 \n", "2 1 0.196364 0.189405 0.437273 0.248309 120 1229 \n", "3 1 0.200000 0.212122 0.590435 0.160296 108 1454 \n", "4 1 0.226957 0.229270 0.436957 0.186900 82 1518 \n", "\n", " cnt \n", "0 985 \n", "1 801 \n", "2 1349 \n", "3 1562 \n", "4 1600 " ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df = pd.read_csv(\"raw_data/day.csv\", header=0, sep=\",\", parse_dates=[\"dteday\"])\n", "df.head()" ] }, { "cell_type": "markdown", "metadata": { "id": "4a9DrmjyhhEP" }, "source": [ "# Define column mapping" ] }, { "cell_type": "code", "execution_count": 6, "metadata": { "executionInfo": { "elapsed": 5, "status": "ok", "timestamp": 1697468132141, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "_bkEZuM8gELe" }, "outputs": [], "source": [ "from evidently.pipeline.column_mapping import ColumnMapping" ] }, { "cell_type": "code", "execution_count": 7, "metadata": { "executionInfo": { "elapsed": 5, "status": "ok", "timestamp": 1697468132141, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "dLIZqkHAgEuo" }, "outputs": [], "source": [ "data_columns = ColumnMapping()\n", "data_columns.numerical_features = [\"weathersit\", \"temp\", \"atemp\", \"hum\", \"windspeed\"]\n", "data_columns.categorical_features = [\"holiday\", \"workingday\"]" ] }, { "cell_type": "markdown", "metadata": { "id": "yNBKbk51hpyz" }, "source": [ "# Define what to log" ] }, { "cell_type": "code", "execution_count": 8, "metadata": { "executionInfo": { "elapsed": 4428, "status": "ok", "timestamp": 1697468136565, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "owblpS3Ahw0o" }, "outputs": [], "source": [ "from evidently.metric_preset import DataDriftPreset\n", "from evidently.report import Report" ] }, { "cell_type": "code", "execution_count": 9, "metadata": { "executionInfo": { "elapsed": 3, "status": "ok", "timestamp": 1697468136565, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "vRF8PjiYho6z" }, "outputs": [], "source": [ "def eval_drift(reference, production, column_mapping):\n", " data_drift_report = Report(metrics=[DataDriftPreset()])\n", " data_drift_report.run(\n", " reference_data=reference, current_data=production, column_mapping=column_mapping\n", " )\n", " report = data_drift_report.as_dict()\n", "\n", " drifts = []\n", "\n", " for feature in (\n", " column_mapping.numerical_features + column_mapping.categorical_features\n", " ):\n", " drifts.append(\n", " (\n", " feature,\n", " report[\"metrics\"][1][\"result\"][\"drift_by_columns\"][feature][\n", " \"drift_score\"\n", " ],\n", " )\n", " )\n", "\n", " return drifts" ] }, { "cell_type": "markdown", "metadata": { "id": "4Yhet51mh6Xz" }, "source": [ "# Define the comparison windows" ] }, { "cell_type": "code", "execution_count": 10, "metadata": { "executionInfo": { "elapsed": 3, "status": "ok", "timestamp": 1697468136565, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "nTq8xUbGh3Ux" }, "outputs": [], "source": [ "# set reference dates\n", "reference_dates = (\"2011-01-01 00:00:00\", \"2011-01-28 23:00:00\")\n", "\n", "# set experiment batches dates\n", "experiment_batches = [\n", " (\"2011-01-01 00:00:00\", \"2011-01-29 23:00:00\"),\n", " (\"2011-01-29 00:00:00\", \"2011-02-07 23:00:00\"),\n", " (\"2011-02-07 00:00:00\", \"2011-02-14 23:00:00\"),\n", " (\"2011-02-15 00:00:00\", \"2011-02-21 23:00:00\"),\n", "]" ] }, { "cell_type": "markdown", "metadata": { "id": "8lNq9OdniDss" }, "source": [ "# Run and log experiments with DVCLive" ] }, { "cell_type": "code", "execution_count": 11, "metadata": { "executionInfo": { "elapsed": 3, "status": "ok", "timestamp": 1697468136565, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "zUt5jrVSRIqD" }, "outputs": [], "source": [ "!git config --global user.email \"you@example.com\"\n", "!git config --global user.name \"Your Name\"" ] }, { "cell_type": "code", "execution_count": 12, "metadata": { "executionInfo": { "elapsed": 1231, "status": "ok", "timestamp": 1697468137794, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "5Hx1jI9PnT3C" }, "outputs": [], "source": [ "from dvclive import Live" ] }, { "cell_type": "markdown", "metadata": { "id": "jTsrtISaSF7D" }, "source": [ "There are two ways to use DVC, put all the drift evaluation steps in one single experiment (corresponding to a git commit), or to save each step as a separate experiment (git commit)" ] }, { "cell_type": "markdown", "metadata": { "id": "RGrEbbla30jr" }, "source": [ "## In one experiment" ] }, { "cell_type": "code", "execution_count": 13, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "executionInfo": { "elapsed": 2844, "status": "ok", "timestamp": 1697468140631, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "ijUf_HhRobl0", "outputId": "796d7eec-17dc-40b2-a4c9-5bdcf9184c58" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "/content\n", "/content/experiments\n", "hint: Using 'master' as the name for the initial branch. This default branch name\n", "hint: is subject to change. To configure the initial branch name to use in all\n", "hint: of your new repositories, which will suppress this warning, call:\n", "hint: \n", "hint: \tgit config --global init.defaultBranch \n", "hint: \n", "hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and\n", "hint: 'development'. The just-created branch can be renamed via this command:\n", "hint: \n", "hint: \tgit branch -m \n", "Initialized empty Git repository in /content/experiments/.git/\n", "fatal: pathspec '.gitignore' did not match any files\n", "On branch master\n", "\n", "Initial commit\n", "\n", "nothing to commit (create/copy files and use \"git add\" to track)\n", "Initialized DVC repository.\n", "\n", "You can now commit the changes to git.\n", "\n", "+---------------------------------------------------------------------+\n", "| |\n", "| DVC has enabled anonymous aggregate usage analytics. |\n", "| Read the analytics documentation (and how to opt-out) here: |\n", "| |\n", "| |\n", "+---------------------------------------------------------------------+\n", "\n", "What's next?\n", "------------\n", "- Check out the documentation: \n", "- Get help and share ideas: \n", "- Star us on GitHub: \n", "[master (root-commit) 9220260] Init DVC\n", " 3 files changed, 6 insertions(+)\n", " create mode 100644 .dvc/.gitignore\n", " create mode 100644 .dvc/config\n", " create mode 100644 .dvcignore\n" ] } ], "source": [ "# Setup a git repo with dvc\n", "\n", "%cd /content\n", "!rm -rf experiments && mkdir experiments\n", "%cd experiments\n", "\n", "!git init\n", "!git add .gitignore\n", "!git commit -m \"Init repo\"\n", "!dvc init\n", "!git commit -m \"Init DVC\"" ] }, { "cell_type": "code", "execution_count": 14, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 1000 }, "executionInfo": { "elapsed": 16055, "status": "ok", "timestamp": 1697468156663, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "_h-jGJqPiA30", "outputId": "0b949e24-8c53-4765-a8ee-64d002b3801e" }, "outputs": [ { "data": { "text/markdown": "# DVC Report\n\nparams.yaml\n\n| begin | end |\n|---------------------|---------------------|\n| 2011-02-15 00:00:00 | 2011-02-21 23:00:00 |\n\nmetrics.json\n\n| weathersit | temp | atemp | hum | windspeed | holiday | workingday | step |\n|--------------|--------|---------|-------|-------------|-----------|--------------|--------|\n| 0.231 | 0 | 0 | 0.062 | 0.012 | 0.275 | 0.593 | 3 |\n\n![static/holiday](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABTZ0lEQVR4nO3deVxUhf7G8Wdm2GVzY1FRU8tdUEzDFlsoSyttNc0lylLbo7rprbTlFrfNLDMx0/SnleaSdcsso6xM0wRxS80dXABNBQRlmTm/PyySREUdODPM5/16zUs5nDPzzLlzp8czc87XYhiGIQAAAHgMq9kBAAAAUL0ogAAAAB6GAggAAOBhKIAAAAAehgIIAADgYSiAAAAAHoYCCAAA4GEogAAAAB6GAggAAOBhKIAAAAAehgIIAADgYSiAAAAAHoYCCAAA4GEogAAAAB6GAgjAozz33HOyWCzav3+/U+7v8ssv1+WXX172844dO2SxWDR16tTTbnvXXXepadOmTskBAGeCAggAAOBhvMwOAAA1SZMmTXTkyBF5e3ubHQUATooCCABOZLFY5OfnZ3YMADglPgIG4JEOHTqku+66S6GhoQoJCVFCQoIKCwvLfl9aWqoXX3xRzZs3l6+vr5o2bap///vfKioqOuX9nuw7gPPnz1e7du3k5+endu3a6dNPP61w+9dff13dunVT3bp15e/vr9jYWM2ZM6fcOt27d1d0dHSF27ds2VI9evSoxB4A4MkogAA80u233678/HwlJSXp9ttv19SpU/X888+X/X7IkCEaNWqUOnXqpDfffFPdu3dXUlKS7rjjjjN+rG+++Ua33HKLLBaLkpKS1KdPHyUkJGjlypUnrPvWW2+pY8eOeuGFF/Tyyy/Ly8tLt912m7788suydQYOHKg1a9Zo3bp15bb99ddf9fvvv2vAgAFnnBGAhzEAwIOMHj3akGTcfffd5ZbfdNNNRt26dQ3DMIz09HRDkjFkyJBy6zzxxBOGJOO7774rW9a9e3eje/fuZT9v377dkGR88MEHZctiYmKMyMhI49ChQ2XLvvnmG0OS0aRJk3KPUVhYWO7n4uJio127dsaVV15ZtuzQoUOGn5+f8dRTT5Vb9+GHHzZq1aplHD58+PQ7AoBH4wggAI80bNiwcj9feuml+uOPP5SXl6cFCxZIkhITE8ut8/jjj0tSuaNxp7N3716lp6dr8ODBCgkJKVt+9dVXq02bNies7+/vX/b3gwcPKjc3V5deeqnS0tLKloeEhKh37976+OOPZRiGJMlut2vWrFnq06ePatWqVel8ADwTBRCAR2rcuHG5n2vXri3pWOnauXOnrFarWrRoUW6diIgIhYaGaufOnZV+nL/WPf/880/4XcuWLU9Y9sUXX+iiiy6Sn5+f6tSpo/r162vChAnKzc0tt96gQYOUkZGhn376SZL07bffKjs7WwMHDqx0NgCeiwIIwCPZbLYKl/91RE06dkZvdfrpp5904403ys/PT++++64WLFigRYsWqX///uVySVKPHj0UHh6uGTNmSJJmzJihiIgIxcfHV2tmAO6JAggA/9CkSRM5HA5t3ry53PLs7GwdOnRITZo0OaP7knTCfUnSpk2byv08d+5c+fn56euvv9bdd9+t66677qSFzmazqX///pozZ44OHjyo+fPnq1+/ficttgBwPAogAPxDz549JUljx44tt3zMmDGSpF69elX6viIjIxUTE6Np06aV+xh30aJF+u2338qta7PZZLFYZLfby5bt2LFD8+fPr/C+Bw4cqIMHD2ro0KE6fPgwZ/8CqDQuBA0A/xAdHa3Bgwfrvffe06FDh9S9e3etWLFC06ZNU58+fXTFFVec0f0lJSWpV69euuSSS3T33XfrwIEDGjdunNq2bavDhw+XrderVy+NGTNG1157rfr376+cnByNHz9eLVq00Jo1a064344dO6pdu3aaPXu2WrdurU6dOp3zcwfgGTgCCAAVeP/99/X888/r119/1aOPPqrvvvtOI0eO1MyZM8/4vq699lrNnj1bdrtdI0eO1Lx58/TBBx+oc+fO5da78sorNXnyZGVlZenRRx/Vxx9/rFdeeUU33XTTSe970KBBksTJHwDOiMX45zeLAQBu46233tJjjz2mHTt2nHBmMwCcDAUQANyUYRiKjo5W3bp19f3335sdB4Ab4TuAAOBmCgoK9Pnnn+v777/X2rVr9dlnn5kdCYCb4QggALiZHTt26LzzzlNoaKjuv/9+vfTSS2ZHAuBmKIAAAAAehrOAAQAAPAwFEAAAwMNwEogkh8OhPXv2KCgoqNpnfwIAAJwpwzCUn5+vBg0ayGo98+N5FEBJe/bsUVRUlNkxAAAAzkhmZqYaNWp0xttRACUFBQVJOrYTg4OD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qxYtWmjq1KlVntNdnOn+XLx48QmvUYvFoqysrOoJ7OKSkpJ04YUXKigoSGFhYerTp482bdp02u14Hz25s9mnvJee2oQJE9ShQ4eya/zFxcXpq6++OuU2rvgapQA60axZs5SYmKjRo0crLS1N0dHR6tGjh3Jycipcf+nSperXr5/uuecerVq1Sn369FGfPn20bt26ak7ums50f0rHLrq5d+/estvOnTurMbHrKygoUHR0tMaPH1+p9bdv365evXrpiiuuUHp6uh599FENGTJEX3/9dRUndQ9nuj//smnTpnKv07CwsCpK6F5++OEHPfDAA/rll1+0aNEilZSU6JprrlFBQcFJt+F99NTOZp9KvJeeSqNGjfTf//5XqampWrlypa688kr17t1b69evr3B9l32NGnCaLl26GA888EDZz3a73WjQoIGRlJRU4fq333670atXr3LLunbtagwdOrRKc7qLM92fH3zwgRESElJN6dyfJOPTTz895Tr/+te/jLZt25Zb1rdvX6NHjx5VmMw9VWZ/fv/994Yk4+DBg9WSyd3l5OQYkowffvjhpOvwPnpmKrNPeS89c7Vr1zbef//9Cn/nqq9RjgA6SXFxsVJTUxUfH1+2zGq1Kj4+XsuWLatwm2XLlpVbX5J69Ohx0vU9ydnsT0k6fPiwmjRpoqioqFP+iwyVw2u0asTExCgyMlJXX321fv75Z7PjuKzc3FxJUp06dU66Dq/RM1OZfSrxXlpZdrtdM2fOVEFBgeLi4ipcx1VfoxRAJ9m/f7/sdrvCw8PLLQ8PDz/p93uysrLOaH1Pcjb7s2XLlpoyZYo+++wzzZgxQw6HQ926ddOuXbuqI3KNdLLXaF5eno4cOWJSKvcVGRmp5ORkzZ07V3PnzlVUVJQuv/xypaWlmR3N5TgcDj366KO6+OKL1a5du5Oux/to5VV2n/Jeenpr165VYGCgfH19NWzYMH366adq06ZNheu66mvUy9RHB5woLi6u3L/AunXrptatW2vixIl68cUXTUwGHNOyZUu1bNmy7Odu3bpp69atevPNNzV9+nQTk7meBx54QOvWrdOSJUvMjlJjVHaf8l56ei1btlR6erpyc3M1Z84cDR48WD/88MNJS6Ar4gigk9SrV082m03Z2dnllmdnZysiIqLCbSIiIs5ofU9yNvvzn7y9vdWxY0dt2bKlKiJ6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)\n\n![static/windspeed](data:image/png;base64,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KukIip5ZtWqVaGJiIi5dulSMj48Xhw4dKtrY2IipqamiKIrioEGDxHHjxpXtf+jQIdHIyEicOXOmmJCQIEZERIjGxsbimTNnpHoKWqWyr+ekSZPEHTt2iJcvXxZjYmLEfv36iUqlUjx37pxUT0Hr5OTkiCdPnhRPnjwpAhBnzZolnjx5Urx27ZooiqI4btw4cdCgQWX7X7lyRTQzMxM//fRTMSEhQZw3b54ol8vF7du3S/UUtEplX8/Zs2eLGzduFC9duiSeOXNGHDNmjCiTycR//vlHqqegVUaMGCFaW1uLe/fuFW/dulV2y8/PL9uH76OV8zyvKd9Ln27cuHHivn37xKSkJPH06dPiuHHjREEQxJ07d4qiqDu/oyyAGvbTTz+JdevWFRUKhdiiRQvxyJEjZf/Wvn178a233iq3/59//il6eHiICoVC9PX1Fbds2VLNibVbZV7PDz/8sGxfR0dHsVu3bmJsbKwEqbXXw2VI/nt7+Dq+9dZbYvv27R+5j7+/v6hQKMT69euLv/76a7Xn1laVfT2//fZb0d3dXVQqlaKtra3YoUMHcffu3dKE10KPey0BlPud4/to5TzPa8r30qd75513RFdXV1GhUIj29vZi586dy8qfKOrO76ggiqJYfZ83EhEREZHUeA4gERERkYFhASQiIiIyMCyARERERAaGBZCIiIjIwLAAEhERERkYFkAiIiIiA8MCSERERGRgWACJiIiIDAwLIBEREZGBYQEkInpBb7/9Nnr27Cl1DCKiCmMBJCIiIjIwLIBERBW0du1aNG7cGKampqhZsyZCQkLw6aefYtmyZfjrr78gCAIEQcDevXsBACkpKejTpw9sbGxga2uLV199FVevXi17vIefHE6aNAn29vawsrLC8OHDUVRUJM0TJCKDYSR1ACIiXXDr1i30798f3333HV577TXk5OTgwIEDCA8PR3JyMrKzs/Hrr78CAGxtbVFcXIzQ0FAEBwfjwIEDMDIywjfffIOXX34Zp0+fhkKhAABERUVBqVRi7969uHr1KgYPHoyaNWti6tSpUj5dItJzLIBERBVw69YtlJSUoFevXnB1dQUANG7cGABgamqKwsJCODk5le2/YsUKqNVqLF68GIIgAAB+/fVX2NjYYO/evejSpQsAQKFQYMmSJTAzM4Ovry8mT56MTz/9FFOmTIFMxi9piKhq8N2FiKgC/Pz80LlzZzRu3Bi9e/fGokWLcPfu3Sfuf+rUKSQmJsLS0hIWFhawsLCAra0tCgoKcPny5XKPa2ZmVvZzcHAwcnNzkZKSUqXPh4gMGz8BJCKqALlcjl27duHw4cPYuXMnfvrpJ3z55Zc4evToY/fPzc1FQEAAfv/990f+zd7evqrjEhE9FQsgEVEFCYKA1q1bo3Xr1pg4cSJcXV2xYcMGKBQKqFSqcvs2a9YMq1evhoODA6ysrJ74mKdOncL9+/dhamoKADhy5AgsLCzg4uJSpc+FiAwbvwImIqqAo0ePYtq0aThx4gSSk5Oxfv16ZGRkwNvbG25ubjh9+jQuXLiAzMxMFBcXY+DAgbCzs8Orr76KAwcOICkpCXv37sXo0aNx/fr1ssctKirCkCFDEB8fj61btyIiIgIffPABz/8joirFTwCJiCrAysoK+/fvx5w5c5CdnQ1XV1d8//336Nq1KwIDA7F3714EBgYiNzcXe/bsQYcOHbB//358/vnn6NWrF3JyclC7dm107ty53CeCnTt3RsOGDdGuXTsUFhaif//++Prrr6V7okRkEARRFEWpQxARGaK3334b9+7dw8aNG6WOQkQGht8xEBERERkYFkAiIiIiA8OvgImIiIgMDD8BJCIiIjIwLIBEREREBoYFkIiIiMjAsAASERERGRgWQCIiIiIDwwJIREREZGBYAImIiIgMDAsgERERkYFhASQiIiIyMCyARERERAaGBZCIiIjIwLAAEhERERkYFkAiIiIiA8MCSERERGRg/h9LylRJ0HLjNwAAAABJRU5ErkJggg==)\n\n![static/temp](data:image/png;base64,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)\n\n![static/workingday](data:image/png;base64,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)\n\n![static/weathersit](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABVHklEQVR4nO3dd3RUdcLG8e9Meg8hPYROqKEIgiAgSgDRRRF3LesqYl+xor6AqICFIKuCLqysunZXXaVZEKmhCMpC6C300BISSiqpc98/kLiRAIHc5E4yz+ecOcfc3PLMdRgebvldm2EYBiIiIiLiMuxWBxARERGRmqUCKCIiIuJiVABFREREXIwKoIiIiIiLUQEUERERcTEqgCIiIiIuRgVQRERExMWoAIqIiIi4GBVAERERERejAigiIiLiYlQARURERFyMCqCIiIiIi1EBFBEREXExKoAiIiIiLkYFUESkGthsNh555BGrY5QZN24cNpvN6hgi4iRUAEVELtHKlSsZN24cJ0+etDrKJZkwYQKzZ8+2OoaIWEAFUETkEq1cuZLx48fXigL43HPPcerUqXLTVABFXJcKoIhILVVQUIDD4ajUvO7u7nh7e1dzIhGpLVQARaTW2bhxIzabjW+++aZs2tq1a7HZbFx22WXl5h04cCDdunUr+/mHH36gV69e+Pn5ERAQwPXXX8+WLVvOWv/dd99N06ZN8fb2JjIyknvuuYdjx46VzTNu3DieeeYZAJo0aYLNZsNms7Fv375y65o9ezbt2rXDy8uLtm3bMm/evLPez6FDh7jnnnuIiIgom+/9998vN09SUhI2m40vvviC5557jpiYGHx9fcnOzqa4uJjx48fTokULvL29qV+/Pj179mTBggXl8v7vNYA2m428vDw++uijsux33333Bfa8iNQV7lYHEBG5WO3atSM4OJhly5Zxww03ALB8+XLsdjsbNmwgOzubwMBAHA4HK1eu5IEHHgDgk08+YejQoQwYMIBXX32V/Px83n77bXr27Mm6deto3LgxAAsWLGDPnj0MGzaMyMhItmzZwjvvvMOWLVv4+eefsdlsDBkyhJSUFD7//HMmT55MaGgoAGFhYWU5V6xYwcyZM3n44YcJCAjgrbfe4uabbyY1NZX69esDkJ6ezhVXXFF200hYWBg//PAD9957L9nZ2TzxxBPl3vtLL72Ep6cnTz/9NIWFhXh6ejJu3DgSExO577776Nq1K9nZ2axZs4bk5GT69etX4T785JNPyuY/s3+aNWtm2v8jEXFyhohILXT99dcbXbt2Lft5yJAhxpAhQww3Nzfjhx9+MAzDMJKTkw3AmDNnjpGTk2MEBwcb999/f7n1pKWlGUFBQeWm5+fnn7W9zz//3ACMZcuWlU3729/+ZgDG3r17z5ofMDw9PY1du3aVTduwYYMBGH//+9/Lpt17771GVFSUkZmZWW752267zQgKCirLsmTJEgMwmjZtela+Dh06GNdff/0595VhGMbYsWON33/l+/n5GUOHDj3vciJSN+kUsIjUSr169SI5OZm8vDzg9NG26667jo4dO7J8+XLg9FFBm81Wdjr05MmT3H777WRmZpa93Nzc6NatG0uWLClbt4+PT9l/FxQUkJmZyRVXXAFAcnJypTMmJCSUO6rWvn17AgMD2bNnDwCGYTBjxgwGDRqEYRjlcg0YMICsrKyztjd06NBy+QCCg4PZsmULO3furHQ2EXFtOgUsIrVSr169KCkpYdWqVcTGxnL06FF69erFli1byhXANm3aEBISUlaOrrnmmgrXFxgYWPbfx48fZ/z48XzxxRccPXq03HxZWVmVztiwYcOzptWrV48TJ04AkJGRwcmTJ3nnnXd45513KlzH77ffpEmTs+Z58cUXufHGG4mLi6Ndu3Zce+213HnnnbRv377SWUXEtagAikit1KVLF7y9vVm2bBkNGzYkPDycuLg4evXqxT/+8Q8KCwtZvnw5N910E0DZ3bKffPIJkZGRZ63P3f23r8NbbrmFlStX8swzz9CxY0f8/f1xOBxce+21lb7rFsDNza3C6YZhlMv0l7/8haFDh1Y47+9L3O+P/gH07t2b3bt3M2fOHObPn897773H5MmTmT59Ovfdd1+l84qI61ABFJFaydPTk65du7J8+XIaNmxIr169gNNHBgsLC/nss89IT0+nd+/ewG83OISHh5OQkHDO9Z44cYJFixYxfvx4XnjhhbLpFZ1ereqTNcLCwggICKC0tPS8mSojJCSEYcOGMWzYMHJzc+nduzfjxo07bwHUk0FEXJeuARSRWqtXr1788ssvLFmypKwAhoaG0rp1a1599dWyeQAGDBhAYGAgEyZMoLi4+Kx1ZWRkAL8dtTtzlO6MKVOmnLWMn58fwCUPBO3m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mzF//nzDMGrPZ1QF0GR///vfjYYNGxqenp5G165djZ9//rnsd1dddZUxdOjQcvP/5z//MeLi4gxPT0+jbdu2xvfff1/DiZ3bxezPJ554omzeiIgI47rrrjOSk5MtSO28zgxD8vvXmf04dOhQ46qrrjprmY4dOxqenp5G06ZNjQ8++KDGczuri92fr776qtGsWTPD29vbCAkJMfr06WMsXrzYmvBOqKJ9CZT7zOl79OJcyj7Vd+n53XPPPUajRo0MT09PIywszOjbt29Z+TOM2vMZtRmGYdTc8UYRERERsZquARQRERFxMSqAIiIiIi5GBVBERETExagAioiIiLgYFUARERERF6MCKCIiIuJiVABFREREXIwKoIiIiIiLUQEUERERcTEqgCIiVXT33XczePBgq2OIiFSaCqCIiIiIi1EBFBGppK+//pr4+Hh8fHyoX78+CQkJPPPMM3z00UfMmTMHm82GzWYjKSkJgAMHDnDLLbcQHBxMSEgIN954I/v27Stb35kjh+PHjycsLIzAwEAeeughioqKrHmDIuIy3K0OICJSGxw5coTbb7+dSZMmcdNNN5GTk8Py5cu56667SE1NJTs7mw8++ACAkJAQiouLGTBgAN27d2f58uW4u7vz8ssvc+2117Jx40Y8PT0BWLRoEd7e3iQlJbFv3z6GDRtG/fr1eeWVV6x8uyJSx6kAiohUwpEjRygpKWHIkCE0atQIgPj4eAB8fHwoLCwkMjKybP5PP/0Uh8PBe++9h81mA+CDDz4gODiYpKQk+vfvD4Cnpyfvv/8+vr6+tG3blhdffJFnnnmGl156CbtdJ2lEpHro20VEpBI6dOhA3759iY+P509/+hPvvvsuJ06cOOf8GzZsYNeuXQQEBODv74+/vz8hISEUFBSwe/fucuv19fUt+7l79+7k5uZy4MCBan0/IuLadARQRKQS3NzcWLBgAStXrmT+/Pn8/e9/Z8yYMfzyyy8Vzp+bm0vnzp357LPPzvpdWFhYdccVETkvFUARkUqy2WxceeWVXHnllbzwwgs0atSIWbNm4enpSWlpabl5L7vsMr788kvCw8MJDAw85zo3bNjAqVOn8PHxAeDnn3/G39+f2NjYan0vIuLadApYRKQSfvnlFyZMmMCaNWtITU1l5syZZGRk0Lp1axo3bszGjRvZsWMHmZmZFBcXc8cddxAaGsqNN97I8uXL2bt3L0lJSTz22GMcPHiwbL1FRUXce++9bN26lblz5zJ27FgeeeQRXf8nItVKRwBFRCohMDCQZcuWMWXKFLKzs2nUqBGvv/46AwcOpEuXLiQlJdGlSxdyc3NZsmQJffr0YdmyZYwcOZIhQ4aQk5NDTEwMffv2LXdEsG/fvrRo0YLevXtTWFjI7bffzrhx46x7oyLiEmyGYRhWhxARcUV33303J0+eZPbs2VZHEREXo3MMIiIiIi5GBVBERETExegUsIiIiIiL0RFAERERERejAigiIiLiYlQARURERFyMCqCIiIiIi1EBFBEREXExKoAiIiIiLkYFUERERMTFqACKiIiIuBgVQBEREREXowIoIiIi4mJUAEVERERcjAqgiIiIiItRARQRERFxMSqAIiIiIi7m/wG0zqNzX7fG2AAAAABJRU5ErkJggg==)\n\n![static/hum](data:image/png;base64,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Fh999BEeeughFBUVYd++fXjyySeRlpaGwsJCfP311wAADw8PVFZWYujQoYiIiMC+ffugUqnw3nvv4d5778XJkyehVqsBAFFRUdBqtYiOjsbFixcxYcIENG3aFO+//77Il0tENo4FkIioDjIzM1FVVYWHH34YQUFBAIDOnTsDABwdHVFRUQFfX9/q7b/77jsYjUZ8+eWXkCQJAPD111/D3d0d0dHRuOeeewAAarUaa9asgZOTEzp27Ih33nkHr776Kt59910oFPyShogaBs8uRER1EBoaiiFDhqBz58547LHHsHr1aly7du2G2584cQKJiYlwdXWFi4sLXFxc4OHhgfLyciQlJdV4XCcnp+o/R0REoLi4GOnp6Q36eojIvnEEkIioDpRKJXbu3ImDBw/ijz/+wLJly/Dvf/8bR44cqXX74uJidOvWDWvXrv3H33l5eTV0XCKim2IBJCKqI0mS0KdPH/Tp0wdz585FUFAQtm7dCrVaDYPBUGPbrl27YuPGjfD29oabm9sNH/PEiRMoKyuDo6MjAODw4cNwcXFBYGBgg74WIrJv/AqYiKgOjhw5gg8++ADHjh1DWloatmzZgitXrqB9+/YIDg7GyZMnce7cOeTm5qKyshLjx4+Hp6cnRowYgX379iElJQXR0dGYPn06Ll26VP24er0ezzzzDM6cOYPt27dj3rx5mDp1Kuf/EVGD4gggEVEduLm5Ye/evViyZAkKCwsRFBSERYsWYdiwYejevTuio6PRvXt3FBcXY/fu3Rg4cCD27t2L119/HQ8//DCKiooQEBCAIUOG1BgRHDJkCFq3bo3+/fujoqICY8eOxVtvvSXuhRKRXZBkWZZFhyAiskdPP/008vPzsW3bNtFRiMjO8DsGIiIiIjvDAkhERERkZ/gVMBEREZGd4QggERERkZ1hASQiIiKyMyyARERERHaGBZCIiIjIzrAAEhEREdkZFkAiIiIiO8MCSERERGRnWACJiIiI7AwLIBEREZGdYQEkIiIisjMsgERERER2hgWQiIiIyM6wABIRERHZGRZAIiIiIjvz/wCUWP+3zJ+aTQAAAABJRU5ErkJggg==)\n\n![static/atemp](data:image/png;base64,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GZ07dzbWrFlT8XfXXHONMXDgwDPW//LLL42mTZsafn5+RsuWLY0ffvihihO7tos5nk888UTFujExMcYNN9xgbNiwwYTUrus/w5D87+s/x3HgwIHGNddc84dt2rVrZ/j5+RkNGzY0pk+fXuW5XdXFHs/XX3/daNSokREQEGBERkYa3bt3N3766Sdzwrugsx1L4IzPnM6jF+dSjqnOpec3ZMgQo379+oafn59Ro0YN49prr60of4bhPp9Ri2EYRtVdbxQRERERs+keQBEREREvowIoIiIi4mVUAEVERES8jAqgiIiIiJdRARQRERHxMiqAIiIiIl5GBVBERETEy6gAioiIiHgZFUARERERL6MCKCJymQYNGkTfvn3NjiEicsFUAEVERES8jAqgiMgF+vrrr2ndujWBgYFUr16dpKQknn76aWbOnMn333+PxWLBYrGwbNkyANLT07nrrruIiIggMjKSW265hf3791f8vP9cOXzxxRepUaMGYWFhDBs2jJKSEnPeoIh4DR+zA4iIuIOjR4/Sv39/xo0bx6233kpeXh4rV64kOTmZgwcPkpuby/Tp0wGIjIyktLSUXr16kZiYyMqVK/Hx8eEf//gHvXv3ZvPmzfj5+QGwZMkSAgICWLZsGfv372fw4MFUr16dV155xcy3KyIeTgVQROQCHD16lLKyMm677Tbq168PQOvWrQEIDAykuLiYmjVrVqz/6aef4nA4+Oijj7BYLABMnz6diIgIli1bxvXXXw+An58f06ZNIygoiJYtW/LSSy/x9NNP8/LLL2O16ksaEakcOruIiFyAtm3bcu2119K6dWvuvPNOpk6dysmTJ8+5/qZNm9izZw+hoaGEhIQQEhJCZGQkRUVF7N2794yfGxQUVPHnxMRE8vPzSU9Pr9T3IyLeTVcARUQugM1mY9GiRaxatYqFCxfywQcf8Le//Y1ff/31rOvn5+eTkJDAZ5999oe/q1GjRmXHFRE5LxVAEZELZLFY6NatG926deOFF16gfv36fPvtt/j5+WG3289Yt0OHDsyZM4fo6GjCwsLO+TM3bdrE6dOnCQwMBGDNmjWEhIQQGxtbqe9FRLybvgIWEbkAv/76K6+++irr1q3j4MGDzJ07l2PHjtG8eXPi4uLYvHkzO3fuJDs7m9LSUu69916ioqK45ZZbWLlyJWlpaSxbtozHHnuMQ4cOVfzckpIShg4dyrZt25g3bx5jxozhkUce0f1/IlKpdAVQROQChIWFsWLFCt59911yc3OpX78+b731Fn369KFjx44sW7aMjh07kp+fz9KlS+nevTsrVqzg2Wef5bbbbiMvL486depw7bXXnnFF8Nprr6VJkyZcffXVFBcX079/f/7+97+b90ZFxCtYDMMwzA4hIuKNBg0axKlTp/juu+/MjiIiXkbfMYiIiIh4GRVAERERES+jr4BFREREvIyuAIqIiIh4GRVAERERES+jAigiIiLiZVQARURERLyMCqCIiIiIl1EBFBEREfEyKoAiIiIiXkYFUERERMTLqACKiIiIeBkVQBEREREvowIoIiIi4mVUAEVERES8jAqgiIiIiJdRARQRERHxMv8fkZH8wotxHvMAAAAASUVORK5CYII=)\n", "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "with Live(report=\"notebook\") as live:\n", " for date in experiment_batches:\n", " live.log_param(\"begin\", date[0])\n", " live.log_param(\"end\", date[1])\n", "\n", " metrics = eval_drift(\n", " df.loc[df.dteday.between(reference_dates[0], reference_dates[1])],\n", " df.loc[df.dteday.between(date[0], date[1])],\n", " column_mapping=data_columns,\n", " )\n", "\n", " for feature in metrics:\n", " live.log_metric(feature[0], round(feature[1], 3))\n", "\n", " live.next_step()" ] }, { "cell_type": "markdown", "metadata": { "id": "Pc3jDX1q-y3c" }, "source": [ "To explore the results from CLI:" ] }, { "cell_type": "code", "execution_count": 15, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "executionInfo": { "elapsed": 1434, "status": "ok", "timestamp": 1697468158085, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "6OAsURiL-Ge2", "outputId": "0fb47be1-f524-41f1-8c74-d4663d721290" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\rReading plot's data from workspace: 0% 0/7 [00:00\n", "\n", "\n", " \n", " DVC Plot\n", " \n", "\n", " \n", " \n", " \n", " \n", " \n", "\n", "\n", " \n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "
\n", " \n", "
\n", " \n", "\n", "" ], "text/plain": [ "" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import IPython\n", "\n", "IPython.display.HTML(filename=\"dvc_plots/index.html\")" ] }, { "cell_type": "markdown", "metadata": { "id": "CCdF_ipAIY7k" }, "source": [ "## In multiple experiments (one per step)" ] }, { "cell_type": "code", "execution_count": 17, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "executionInfo": { "elapsed": 1213, "status": "ok", "timestamp": 1697468159295, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "0x81BAI--2Gm", "outputId": "7fb22cea-d367-41b0-f27d-a99e9d6081dc" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "/content\n", "/content/experiments\n", "hint: Using 'master' as the name for the initial branch. This default branch name\n", "hint: is subject to change. To configure the initial branch name to use in all\n", "hint: of your new repositories, which will suppress this warning, call:\n", "hint: \n", "hint: \tgit config --global init.defaultBranch \n", "hint: \n", "hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and\n", "hint: 'development'. The just-created branch can be renamed via this command:\n", "hint: \n", "hint: \tgit branch -m \n", "Initialized empty Git repository in /content/experiments/.git/\n", "fatal: pathspec '.gitignore' did not match any files\n", "On branch master\n", "\n", "Initial commit\n", "\n", "nothing to commit (create/copy files and use \"git add\" to track)\n", "Initialized DVC repository.\n", "\n", "You can now commit the changes to git.\n", "\n", "+---------------------------------------------------------------------+\n", "| |\n", "| DVC has enabled anonymous aggregate usage analytics. |\n", "| Read the analytics documentation (and how to opt-out) here: |\n", "| |\n", "| |\n", "+---------------------------------------------------------------------+\n", "\n", "What's next?\n", "------------\n", "- Check out the documentation: \n", "- Get help and share ideas: \n", "- Star us on GitHub: \n", "[master (root-commit) 469083d] Init DVC\n", " 3 files changed, 6 insertions(+)\n", " create mode 100644 .dvc/.gitignore\n", " create mode 100644 .dvc/config\n", " create mode 100644 .dvcignore\n" ] } ], "source": [ "# Setup a git repo with dvc\n", "\n", "%cd /content\n", "!rm -rf experiments && mkdir experiments\n", "%cd experiments\n", "\n", "!git init\n", "!git add .gitignore\n", "!git commit -m \"Init repo\"\n", "!dvc init\n", "!git commit -m \"Init DVC\"" ] }, { "cell_type": "code", "execution_count": 18, "metadata": { "executionInfo": { "elapsed": 2355, "status": "ok", "timestamp": 1697468161649, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "VfVLDwfD39qO" }, "outputs": [], "source": [ "from dvclive import Live\n", "\n", "for step, date in enumerate(experiment_batches):\n", " with Live() as live:\n", " live.log_param(\"step\", step)\n", " live.log_param(\"begin\", date[0])\n", " live.log_param(\"end\", date[1])\n", "\n", " metrics = eval_drift(\n", " df.loc[df.dteday.between(reference_dates[0], reference_dates[1])],\n", " df.loc[df.dteday.between(date[0], date[1])],\n", " column_mapping=data_columns,\n", " )\n", "\n", " for feature in metrics:\n", " live.log_metric(feature[0], round(feature[1], 3))" ] }, { "cell_type": "code", "execution_count": 19, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 238 }, "executionInfo": { "elapsed": 433, "status": "ok", "timestamp": 1697468162078, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "ijcN3PaZ6fM0", "outputId": "2d26f834-604f-4e28-8924-f5d97ae92596" }, "outputs": [ { "data": { "text/html": [ "\n", "
\n", "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
ExperimentrevtypCreatedparentStateExecutorweathersittempatemphumwindspeedholidayworkingdaystepbeginend
0NoneworkspacebaselineNoneNoneNoneNone0.231NaNNaN0.0620.0120.2750.5933.02011-02-15 00:00:002011-02-21 23:00:00
1Nonemasterbaseline02:55 PMNoneNoneNoneNaNNaNNaNNaNNaNNaNNaNNaNNoneNone
2elite-mobse4d6acdbranch_commit02:56 PMNoneNoneNone0.231NaNNaN0.0620.0120.2750.5933.02011-02-15 00:00:002011-02-21 23:00:00
3buxom-shes439f6e1branch_commit02:56 PMNoneNoneNone0.1550.3990.5370.6840.6110.5880.6992.02011-02-07 00:00:002011-02-14 23:00:00
4hammy-skipb5b80b5branch_commit02:55 PMNoneNoneNone0.9851.0001.0001.0001.0000.9800.851NaN2011-01-01 00:00:002011-01-29 23:00:00
5girly-sere2ba9568branch_base02:55 PMNoneNoneNone0.7790.0980.1070.0300.1710.5450.6531.02011-01-29 00:00:002011-02-07 23:00:00
\n", "
\n", "
\n", "\n", "
\n", " \n", "\n", " \n", "\n", " \n", "
\n", "\n", "\n", "
\n", " \n", "\n", "\n", "\n", " \n", "
\n", "
\n", "
\n" ], "text/plain": [ " Experiment rev typ Created parent State Executor \\\n", "0 None workspace baseline None None None None \n", "1 None master baseline 02:55 PM None None None \n", "2 elite-mobs e4d6acd branch_commit 02:56 PM None None None \n", "3 buxom-shes 439f6e1 branch_commit 02:56 PM None None None \n", "4 hammy-skip b5b80b5 branch_commit 02:55 PM None None None \n", "5 girly-sere 2ba9568 branch_base 02:55 PM None None None \n", "\n", " weathersit temp atemp hum windspeed holiday workingday step \\\n", "0 0.231 NaN NaN 0.062 0.012 0.275 0.593 3.0 \n", "1 NaN NaN NaN NaN NaN NaN NaN NaN \n", "2 0.231 NaN NaN 0.062 0.012 0.275 0.593 3.0 \n", "3 0.155 0.399 0.537 0.684 0.611 0.588 0.699 2.0 \n", "4 0.985 1.000 1.000 1.000 1.000 0.980 0.851 NaN \n", "5 0.779 0.098 0.107 0.030 0.171 0.545 0.653 1.0 \n", "\n", " begin end \n", "0 2011-02-15 00:00:00 2011-02-21 23:00:00 \n", "1 None None \n", "2 2011-02-15 00:00:00 2011-02-21 23:00:00 \n", "3 2011-02-07 00:00:00 2011-02-14 23:00:00 \n", "4 2011-01-01 00:00:00 2011-01-29 23:00:00 \n", "5 2011-01-29 00:00:00 2011-02-07 23:00:00 " ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import dvc.api\n", "\n", "pd.DataFrame(dvc.api.exp_show())" ] }, { "cell_type": "markdown", "metadata": { "id": "TQE5aBWl-sef" }, "source": [ "To explore the results from CLI:" ] }, { "cell_type": "code", "execution_count": 20, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "executionInfo": { "elapsed": 1221, "status": "ok", "timestamp": 1697468163295, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "oZtY-97bQj-Q", "outputId": "14eb8d4c-c9ce-4bb8-caba-42e46d45bb65" }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \n", " Experiment Created weathersit temp atemp hum windspeed holiday workingday step begin end \n", " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \n", " workspace - 0.231 0 0 0.062 0.012 0.275 0.593 3 2011-02-15 00:00:00 2011-02-21 23:00:00 \n", " master 02:55 PM - - - - - - - - - - \n", " ├── e4d6acd [elite-mobs] 02:56 PM 0.231 0 0 0.062 0.012 0.275 0.593 3 2011-02-15 00:00:00 2011-02-21 23:00:00 \n", " ├── 439f6e1 [buxom-shes] 02:56 PM 0.155 0.399 0.537 0.684 0.611 0.588 0.699 2 2011-02-07 00:00:00 2011-02-14 23:00:00 \n", " ├── b5b80b5 [hammy-skip] 02:55 PM 0.985 1 1 1 1 0.98 0.851 0 2011-01-01 00:00:00 2011-01-29 23:00:00 \n", " └── 2ba9568 [girly-sere] 02:55 PM 0.779 0.098 0.107 0.03 0.171 0.545 0.653 1 2011-01-29 00:00:00 2011-02-07 23:00:00 \n", " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \n" ] } ], "source": [ "!dvc exp show" ] }, { "cell_type": "code", "execution_count": 20, "metadata": { "executionInfo": { "elapsed": 464, "status": "ok", "timestamp": 1697468163757, "user": { "displayName": "Francesco Motoko", "userId": "00974636158007469548" }, "user_tz": -120 }, "id": "QoYexufp-qw2" }, "outputs": [], "source": [] } ], "metadata": { "colab": { "authorship_tag": "ABX9TyNJAdha/v4n9zLqIfGakg0E", "provenance": [], "toc_visible": true }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 0 } ================================================ FILE: examples/DVCLive-Fabric.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": { "id": "QKSE19fW_Dnj" }, "source": [ "# DVCLive and Lightning Fabric" ] }, { "cell_type": "markdown", "metadata": { "id": "q-C_4R_o_QGG" }, "source": [ "## Install dvclive" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "-XFbvwq7TSwN", "outputId": "15d0e3b5-bb4a-4b3e-d37f-21608d1822ed" }, "outputs": [], "source": [ "%pip install \"dvclive[lightning]\"" ] }, { "cell_type": "markdown", "metadata": { "id": "I6S6Uru1_Y0x" }, "source": [ "## Initialize DVC Repository" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" }, "id": "WcbvUl2uTV0y", "outputId": "aff9740c-26db-483d-ce30-cfef395f3cbb" }, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "cell_type": "markdown", "metadata": { "id": "LmY4PLMh_cUk" }, "source": [ "## Imports" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "85qErT5yTEbN" }, "outputs": [], "source": [ "from types import SimpleNamespace\n", "\n", "import torch\n", "import torch.nn.functional as F # noqa: N812\n", "import torchvision.transforms as T # noqa: N812\n", "from lightning.fabric import Fabric, seed_everything\n", "from lightning.fabric.utilities.rank_zero import rank_zero_only\n", "from torch import nn, optim\n", "from torch.optim.lr_scheduler import StepLR\n", "from torchmetrics.classification import Accuracy\n", "from torchvision.datasets import MNIST\n", "\n", "from dvclive.fabric import DVCLiveLogger\n", "\n", "DATASETS_PATH = \"Datasets\"" ] }, { "cell_type": "markdown", "metadata": { "id": "UrmAHbhr_lgs" }, "source": [ "## Setup model code\n", "\n", "Adapted from https://github.com/Lightning-AI/pytorch-lightning/blob/master/examples/fabric/image_classifier/train_fabric.py.\n", "\n", "Look for the `logger` statements where DVCLiveLogger calls were added." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "UCzTygUnTHM8" }, "outputs": [], "source": [ "class Net(nn.Module):\n", " def __init__(self) -> None:\n", " super().__init__()\n", " self.conv1 = nn.Conv2d(1, 32, 3, 1)\n", " self.conv2 = nn.Conv2d(32, 64, 3, 1)\n", " self.dropout1 = nn.Dropout(0.25)\n", " self.dropout2 = nn.Dropout(0.5)\n", " self.fc1 = nn.Linear(9216, 128)\n", " self.fc2 = nn.Linear(128, 10)\n", "\n", " def forward(self, x):\n", " x = self.conv1(x)\n", " x = F.relu(x)\n", " x = self.conv2(x)\n", " x = F.relu(x)\n", " x = F.max_pool2d(x, 2)\n", " x = self.dropout1(x)\n", " x = torch.flatten(x, 1)\n", " x = self.fc1(x)\n", " x = F.relu(x)\n", " x = self.dropout2(x)\n", " x = self.fc2(x)\n", " return F.log_softmax(x, dim=1)\n", "\n", "\n", "def run(hparams):\n", " # Create the DVCLive Logger\n", " logger = DVCLiveLogger(report=\"notebook\")\n", "\n", " # Log dict of hyperparameters\n", " logger.log_hyperparams(hparams.__dict__)\n", "\n", " # Create the Lightning Fabric object. The parameters like accelerator, strategy,\n", " # devices etc. will be proided by the command line. See all options: `lightning\n", " # run model --help`\n", " fabric = Fabric()\n", "\n", " seed_everything(hparams.seed) # instead of torch.manual_seed(...)\n", "\n", " transform = T.Compose([T.ToTensor(), T.Normalize((0.1307,), (0.3081,))])\n", "\n", " # Let rank 0 download the data first, then everyone will load MNIST\n", " with fabric.rank_zero_first(\n", " local=False\n", " ): # set `local=True` if your filesystem is not shared between machines\n", " train_dataset = MNIST(\n", " DATASETS_PATH,\n", " download=fabric.is_global_zero,\n", " train=True,\n", " transform=transform,\n", " )\n", " test_dataset = MNIST(\n", " DATASETS_PATH,\n", " download=fabric.is_global_zero,\n", " train=False,\n", " transform=transform,\n", " )\n", "\n", " train_loader = torch.utils.data.DataLoader(\n", " train_dataset,\n", " batch_size=hparams.batch_size,\n", " )\n", " test_loader = torch.utils.data.DataLoader(\n", " test_dataset, batch_size=hparams.batch_size\n", " )\n", "\n", " # don't forget to call `setup_dataloaders` to prepare for dataloaders for\n", " # distributed training.\n", " train_loader, test_loader = fabric.setup_dataloaders(train_loader, test_loader)\n", "\n", " model = Net() # remove call to .to(device)\n", " optimizer = optim.Adadelta(model.parameters(), lr=hparams.lr)\n", "\n", " # don't forget to call `setup` to prepare for model / optimizer for\n", " # distributed training. The model is moved automatically to the right device.\n", " model, optimizer = fabric.setup(model, optimizer)\n", "\n", " scheduler = StepLR(optimizer, step_size=1, gamma=hparams.gamma)\n", "\n", " # use torchmetrics instead of manually computing the accuracy\n", " test_acc = Accuracy(task=\"multiclass\", num_classes=10).to(fabric.device)\n", "\n", " # EPOCH LOOP\n", " for epoch in range(1, hparams.epochs + 1):\n", " # TRAINING LOOP\n", " model.train()\n", " for batch_idx, (data, target) in enumerate(train_loader):\n", " # NOTE: no need to call `.to(device)` on the data, target\n", " optimizer.zero_grad()\n", " output = model(data)\n", " loss = F.nll_loss(output, target)\n", " fabric.backward(loss) # instead of loss.backward()\n", "\n", " optimizer.step()\n", " if (batch_idx == 0) or ((batch_idx + 1) % hparams.log_interval == 0):\n", " done = (batch_idx * len(data)) / len(train_loader.dataset)\n", " pct = 100.0 * batch_idx / len(train_loader)\n", " print( # noqa: T201\n", " f\"-> Epoch: {epoch} [{done} ({pct:.0f}%)]\\tLoss: {loss.item():.6f}\"\n", " )\n", "\n", " # Log dict of metrics\n", " logger.log_metrics({\"loss\": loss.item()})\n", "\n", " if hparams.dry_run:\n", " break\n", "\n", " scheduler.step()\n", "\n", " # TESTING LOOP\n", " model.eval()\n", " test_loss = 0\n", " with torch.no_grad():\n", " for data, target in test_loader:\n", " # NOTE: no need to call `.to(device)` on the data, target\n", " output = model(data)\n", " test_loss += F.nll_loss(output, target, reduction=\"sum\").item()\n", "\n", " # WITHOUT TorchMetrics\n", " # pred = output.argmax(dim=1, keepdim=True) # get the index of the max\n", " # log-probability correct += pred.eq(target.view_as(pred)).sum().item()\n", "\n", " # WITH TorchMetrics\n", " test_acc(output, target)\n", "\n", " if hparams.dry_run:\n", " break\n", "\n", " # all_gather is used to aggregated the value across processes\n", " test_loss = fabric.all_gather(test_loss).sum() / len(test_loader.dataset)\n", " acc = 100 * test_acc.compute()\n", "\n", " print( # noqa: T201\n", " f\"\\nTest set: Average loss: {test_loss:.4f}, Accuracy: ({acc:.0f}%)\\n\"\n", " )\n", "\n", " # log additional metrics\n", " logger.log_metrics(\n", " {\"test_loss\": test_loss, \"test_acc\": 100 * test_acc.compute()}\n", " )\n", "\n", " test_acc.reset()\n", "\n", " if hparams.dry_run:\n", " break\n", "\n", " # When using distributed training, use `fabric.save`\n", " # to ensure the current process is allowed to save a checkpoint\n", " if hparams.save_model:\n", " fabric.save(\"mnist_cnn.pt\", model.state_dict())\n", "\n", " # `logger.experiment` provides access to the `dvclive.Live` instance where you\n", " # can use additional logging methods. Check that `rank_zero_only.rank == 0` to\n", " # avoid logging in other processes.\n", " if rank_zero_only.rank == 0:\n", " logger.experiment.log_artifact(\"mnist_cnn.pt\")\n", "\n", " # Call finalize to save final results as a DVC experiment\n", " logger.finalize(\"success\")" ] }, { "cell_type": "markdown", "metadata": { "id": "o5_v9lRDAM7l" }, "source": [ "## Train the model" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/", "height": 1000 }, "id": "BbCXen1PTM4V", "outputId": "b79c90eb-74cc-474d-c0dd-21245064bca8" }, "outputs": [], "source": [ "hparams = SimpleNamespace(\n", " batch_size=64,\n", " epochs=5,\n", " lr=1.0,\n", " gamma=0.7,\n", " dry_run=False,\n", " seed=1,\n", " log_interval=10,\n", " save_model=True,\n", ")\n", "run(hparams)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "DnqCrlbLAopV" }, "outputs": [], "source": [] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.12.2" } }, "nbformat": 4, "nbformat_minor": 0 } ================================================ FILE: examples/DVCLive-HuggingFace.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": { "id": "3SJ8SY6ldmsS" }, "source": [ "### How to do Experiment tracking with DVCLive\n", "\n", "What you will learn?\n", "\n", "- Fine-tuning a model on a binary text classification task\n", "- Track machine learning experiments with DVCLive\n", "- Visualize results and create a report\n" ] }, { "cell_type": "markdown", "metadata": { "id": "nxiSBytidmsU" }, "source": [ "#### Setup (Install Dependencies & Setup Git)\n", "\n", "- Install accelerate , Datasets , evaluate , transformers and dvclive\n", "- Start a Git repo. Your experiments will be saved in a commit but hidden in\n", " order to not clutter your repo.\n", "- Initialize DVC\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "CLRgy2W4dmsU" }, "outputs": [], "source": [ "!pip install datasets dvclive evaluate pandas 'transformers[torch]' --upgrade" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "fo0sq84UdmsV" }, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "cell_type": "markdown", "metadata": { "id": "T5WYJ31UdmsV" }, "source": [ "### Fine-tuning a model on a text classification task\n", "\n", "#### Loading the dataset\n", "\n", "We will use the [imdb](https://huggingface.co/datasets/imdb) Large Movie Review Dataset. This is a dataset for binary\n", "sentiment classification containing a set of 25K movie reviews for training and\n", "25K for testing.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "41fP0WCbdmsV" }, "outputs": [], "source": [ "from datasets import load_dataset\n", "from transformers import AutoTokenizer\n", "\n", "dataset = load_dataset(\"imdb\")" ] }, { "cell_type": "markdown", "metadata": { "id": "V3gDKbbSdmsV" }, "source": [ "#### Preprocessing the data\n", "\n", "We use `transformers.AutoTokenizer` which transforms the inputs and put them in a format\n", "the model expects.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "uVr5lufodmsV" }, "outputs": [], "source": [ "tokenizer = AutoTokenizer.from_pretrained(\"distilbert-base-cased\")\n", "\n", "\n", "def tokenize_function(examples):\n", " return tokenizer(examples[\"text\"], padding=\"max_length\", truncation=True)\n", "\n", "\n", "small_train_dataset = (\n", " dataset[\"train\"]\n", " .shuffle(seed=42)\n", " .select(range(2000))\n", " .map(tokenize_function, batched=True)\n", ")\n", "small_eval_dataset = (\n", " dataset[\"test\"]\n", " .shuffle(seed=42)\n", " .select(range(200))\n", " .map(tokenize_function, batched=True)\n", ")" ] }, { "cell_type": "markdown", "metadata": { "id": "g9sELYMHdmsV" }, "source": [ "#### Define evaluation metrics\n", "\n", "f1 is a metric for combining precision and recall metrics in one unique value, so\n", "we take this criteria for evaluating the models.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "wmJoy5V-dmsW" }, "outputs": [], "source": [ "import evaluate\n", "import numpy as np\n", "\n", "metric = evaluate.load(\"f1\")\n", "\n", "\n", "def compute_metrics(eval_pred):\n", " logits, labels = eval_pred\n", " predictions = np.argmax(logits, axis=-1)\n", " return metric.compute(predictions=predictions, references=labels)" ] }, { "cell_type": "markdown", "metadata": { "id": "NwFntrIKdmsW" }, "source": [ "### Training and Tracking experiments with DVCLive\n", "\n", "Track experiments in DVC by changing a few lines of your Python code.\n", "Save model artifacts using `HF_DVCLIVE_LOG_MODEL=true`." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "-A1oXCxE4zGi" }, "outputs": [], "source": [ "%env HF_DVCLIVE_LOG_MODEL=true" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "gKKSTh0ZdmsW" }, "outputs": [], "source": [ "from transformers import AutoModelForSequenceClassification, Trainer, TrainingArguments\n", "from transformers.integrations import DVCLiveCallback\n", "\n", "model = AutoModelForSequenceClassification.from_pretrained(\n", " \"distilbert-base-cased\", num_labels=2\n", ")\n", "for param in model.base_model.parameters():\n", " param.requires_grad = False\n", "\n", "lr = 3e-4\n", "\n", "training_args = TrainingArguments(\n", " eval_strategy=\"epoch\",\n", " learning_rate=lr,\n", " logging_strategy=\"epoch\",\n", " num_train_epochs=5,\n", " output_dir=\"output\",\n", " overwrite_output_dir=True,\n", " load_best_model_at_end=True,\n", " save_strategy=\"epoch\",\n", " weight_decay=0.01,\n", ")\n", "\n", "trainer = Trainer(\n", " model=model,\n", " args=training_args,\n", " train_dataset=small_train_dataset,\n", " eval_dataset=small_eval_dataset,\n", " compute_metrics=compute_metrics,\n", ")\n", "trainer.train()" ] }, { "cell_type": "markdown", "metadata": { "id": "KKJCw0Vj6UTw" }, "source": [ "To customize tracking, include `transformers.integrations.DVCLiveCallback` in the `Trainer` callbacks and pass additional keyword arguments to `dvclive.Live`." ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "M4FKUYTi5zYQ" }, "outputs": [], "source": [ "from dvclive import Live\n", "\n", "lr = 1e-4\n", "\n", "training_args = TrainingArguments(\n", " eval_strategy=\"epoch\",\n", " learning_rate=lr,\n", " logging_strategy=\"epoch\",\n", " num_train_epochs=5,\n", " output_dir=\"output\",\n", " overwrite_output_dir=True,\n", " load_best_model_at_end=True,\n", " save_strategy=\"epoch\",\n", " weight_decay=0.01,\n", ")\n", "\n", "trainer = Trainer(\n", " model=model,\n", " args=training_args,\n", " train_dataset=small_train_dataset,\n", " eval_dataset=small_eval_dataset,\n", " compute_metrics=compute_metrics,\n", " callbacks=[DVCLiveCallback(live=Live(report=\"notebook\"), log_model=True)],\n", ")\n", "trainer.train()" ] }, { "cell_type": "markdown", "metadata": { "id": "l29wqAaDdmsW" }, "source": [ "### Comparing Experiments\n", "\n", "We create a dataframe with the experiments in order to visualize it.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "wwMwHvVtdmsW" }, "outputs": [], "source": [ "import dvc.api\n", "import pandas as pd\n", "\n", "columns = [\"Experiment\", \"epoch\", \"eval.f1\"]\n", "\n", "df = pd.DataFrame(dvc.api.exp_show(), columns=columns)\n", "\n", "df.dropna(inplace=True)\n", "df.reset_index(drop=True, inplace=True)\n", "df" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "TNBGUqoCdmsW" }, "outputs": [], "source": [ "!dvc plots diff $(dvc exp list --names-only)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "sL5pH4X5dmsW" }, "outputs": [], "source": [ "from IPython.display import HTML\n", "\n", "HTML(filename=\"./dvc_plots/index.html\")" ] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.7" } }, "nbformat": 4, "nbformat_minor": 4 } ================================================ FILE: examples/DVCLive-PyTorch-Lightning.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": { "id": "A812CVYi_B2b" }, "source": [ "\"Open" ] }, { "cell_type": "markdown", "metadata": { "id": "gPh2FiPo_B2e" }, "source": [ "# DVCLive and PyTorch Lightning" ] }, { "cell_type": "markdown", "metadata": { "id": "m0XW9Ml7_B2e" }, "source": [ "## Setup" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "QivH1_cU_B2f" }, "outputs": [], "source": [ "%pip install \"dvclive[lightning]\"" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "pn_5GW1f_B2g" }, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "cell_type": "markdown", "metadata": { "id": "zC9hk7kibFTX" }, "source": [ "### Define LightningModule" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "t5PxdljP_B2h" }, "outputs": [], "source": [ "import lightning.pytorch as pl\n", "import torch\n", "\n", "\n", "class LitAutoEncoder(pl.LightningModule):\n", " def __init__(self, encoder_size=64, lr=1e-3): # noqa: ARG002\n", " super().__init__()\n", " self.save_hyperparameters()\n", " self.encoder = torch.nn.Sequential(\n", " torch.nn.Linear(28 * 28, encoder_size),\n", " torch.nn.ReLU(),\n", " torch.nn.Linear(encoder_size, 3),\n", " )\n", " self.decoder = torch.nn.Sequential(\n", " torch.nn.Linear(3, encoder_size),\n", " torch.nn.ReLU(),\n", " torch.nn.Linear(encoder_size, 28 * 28),\n", " )\n", "\n", " def training_step(self, batch, batch_idx): # noqa: ARG002\n", " x, _y = batch\n", " x = x.view(x.size(0), -1)\n", " z = self.encoder(x)\n", " x_hat = self.decoder(z)\n", " train_mse = torch.nn.functional.mse_loss(x_hat, x)\n", " self.log(\"train_mse\", train_mse)\n", " return train_mse\n", "\n", " def validation_step(self, batch, batch_idx): # noqa: ARG002\n", " x, _y = batch\n", " x = x.view(x.size(0), -1)\n", " z = self.encoder(x)\n", " x_hat = self.decoder(z)\n", " val_mse = torch.nn.functional.mse_loss(x_hat, x)\n", " self.log(\"val_mse\", val_mse)\n", " return val_mse\n", "\n", " def configure_optimizers(self):\n", " return torch.optim.Adam(self.parameters(), lr=self.hparams.lr)" ] }, { "cell_type": "markdown", "metadata": { "id": "St0ElX9obqRS" }, "source": [ "### Dataset and loaders" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "T5s53qgr_B2h" }, "outputs": [], "source": [ "from torchvision import transforms\n", "from torchvision.datasets import MNIST\n", "\n", "transform = transforms.ToTensor()\n", "train_set = MNIST(root=\"MNIST\", download=True, train=True, transform=transform)\n", "validation_set = MNIST(root=\"MNIST\", download=True, train=False, transform=transform)\n", "train_loader = torch.utils.data.DataLoader(train_set)\n", "validation_loader = torch.utils.data.DataLoader(validation_set)" ] }, { "cell_type": "markdown", "metadata": { "id": "ttiwwreH_B2i" }, "source": [ "# Tracking experiments with DVCLive" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "sE6qj6BMoDkn" }, "outputs": [], "source": [ "from dvclive.lightning import DVCLiveLogger" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "XDqNY8pL_B2i" }, "outputs": [], "source": [ "for encoder_size in (64, 128):\n", " for lr in (1e-3, 0.1):\n", " model = LitAutoEncoder(encoder_size=encoder_size, lr=lr)\n", " trainer = pl.Trainer(\n", " limit_train_batches=200,\n", " limit_val_batches=100,\n", " max_epochs=5,\n", " logger=DVCLiveLogger(log_model=True, report=\"notebook\"),\n", " )\n", " trainer.fit(model, train_loader, validation_loader)" ] }, { "cell_type": "markdown", "metadata": { "id": "7zEi0BXp_B2i" }, "source": [ "## Comparing results" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "1aHmLHmf_B2i" }, "outputs": [], "source": [ "import dvc.api\n", "import pandas as pd\n", "\n", "columns = [\"Experiment\", \"encoder_size\", \"lr\", \"train.mse\", \"val.mse\"]\n", "\n", "df = pd.DataFrame(dvc.api.exp_show(), columns=columns)\n", "\n", "df.dropna(inplace=True)\n", "df.reset_index(drop=True, inplace=True)\n", "df" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "db42qeHEGqTA" }, "outputs": [], "source": [ "from plotly.express import parallel_coordinates\n", "\n", "fig = parallel_coordinates(df, columns, color=\"val.mse\")\n", "fig.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "3cfvi0Uk_B2j" }, "outputs": [], "source": [ "!dvc plots diff $(dvc exp list --names-only)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "id": "Zx5n2zbn_B2j" }, "outputs": [], "source": [ "from IPython.display import HTML\n", "\n", "HTML(filename=\"./dvc_plots/index.html\")" ] } ], "metadata": { "accelerator": "GPU", "colab": { "gpuType": "T4", "provenance": [], "toc_visible": true }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.16" } }, "nbformat": 4, "nbformat_minor": 0 } ================================================ FILE: examples/DVCLive-Quickstart.ipynb ================================================ { "cells": [ { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "\"Open" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# DVCLive Quickstart" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Install dvclive" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%pip install dvclive" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Initialize DVC Repository" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Setup code" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# @title Training helpers. { display-mode: \"form\" }\n", "\n", "import numpy as np\n", "import torch\n", "import torchvision\n", "\n", "from dvclive import Live\n", "\n", "device = \"cuda:0\" if torch.cuda.is_available() else \"cpu\"\n", "\n", "\n", "def transform(dataset):\n", " \"\"\"Get inputs and targets from dataset.\"\"\"\n", " x = dataset.data.reshape(len(dataset.data), 1, 28, 28) / 255\n", " y = dataset.targets\n", " return x.to(device), y.to(device)\n", "\n", "\n", "def train_one_epoch(model, criterion, x, y, lr, weight_decay):\n", " model.train()\n", " optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)\n", " y_pred = model(x)\n", " loss = criterion(y_pred, y)\n", " optimizer.zero_grad()\n", " loss.backward()\n", " optimizer.step()\n", "\n", "\n", "def predict(model, x):\n", " \"\"\"Get model prediction scores.\"\"\"\n", " model.eval()\n", " with torch.no_grad():\n", " return model(x)\n", "\n", "\n", "def get_metrics(y, y_pred, y_pred_label):\n", " \"\"\"Get loss and accuracy metrics.\"\"\"\n", " metrics = {}\n", " criterion = torch.nn.CrossEntropyLoss()\n", " metrics[\"loss\"] = criterion(y_pred, y).item()\n", " metrics[\"acc\"] = (y_pred_label == y).sum().item() / len(y)\n", " return metrics\n", "\n", "\n", "def evaluate(model, x, y):\n", " \"\"\"Evaluate model and save metrics.\"\"\"\n", " scores = predict(model, x)\n", " _, labels = torch.max(scores, 1)\n", " actual = [int(v) for v in y]\n", " predicted = [int(v) for v in labels]\n", "\n", " metrics = get_metrics(y, scores, labels)\n", "\n", " return metrics, actual, predicted\n", "\n", "\n", "def get_missclassified_image(actual, predicted, dataset):\n", " confusion = {}\n", " for n, (a, p) in enumerate(zip(actual, predicted)):\n", " image = np.array(dataset[n][0]) / 255\n", " confusion[(a, p)] = image\n", "\n", " max_i, max_j = 0, 0\n", " for i, j in confusion:\n", " max_i = max(i, max_i)\n", " max_j = max(j, max_j)\n", "\n", " frame_size = 30\n", " image_shape = (28, 28)\n", " incorrect_color = np.array((255, 100, 100), dtype=\"uint8\")\n", " label_color = np.array((100, 100, 240), dtype=\"uint8\")\n", "\n", " out_matrix = (\n", " np.ones(\n", " shape=((max_i + 2) * frame_size, (max_j + 2) * frame_size, 3), dtype=\"uint8\"\n", " )\n", " * 240\n", " )\n", "\n", " for i in range(max_i + 1):\n", " if (i, i) in confusion:\n", " image = confusion[(i, i)]\n", " xs = (i + 1) * frame_size + 1\n", " xe = (i + 2) * frame_size - 1\n", " ys = 1\n", " ye = frame_size - 1\n", " for c in range(3):\n", " out_matrix[xs:xe, ys:ye, c] = (1 - image) * label_color[c]\n", " out_matrix[ys:ye, xs:xe, c] = (1 - image) * label_color[c]\n", "\n", " for i, j in confusion: # noqa: PLC0206\n", " image = confusion[(i, j)]\n", " assert image.shape == image_shape # noqa: S101\n", " xs = (i + 1) * frame_size + 1\n", " xe = (i + 2) * frame_size - 1\n", " ys = (j + 1) * frame_size + 1\n", " ye = (j + 2) * frame_size - 1\n", " assert (xe - xs, ye - ys) == image_shape # noqa: S101\n", " if i != j:\n", " for c in range(3):\n", " out_matrix[xs:xe, ys:ye, c] = (1 - image) * incorrect_color[c]\n", "\n", " return out_matrix" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# @title Initialize model and dataset. { display-mode: \"form\" }\n", "\n", "model = torch.nn.Sequential(\n", " torch.nn.Flatten(),\n", " torch.nn.Linear(28 * 28, 128),\n", " torch.nn.ReLU(),\n", " torch.nn.Dropout(0.1),\n", " torch.nn.Linear(128, 64),\n", " torch.nn.ReLU(),\n", " torch.nn.Dropout(0.1),\n", " torch.nn.Linear(64, 10),\n", ").to(device)\n", "\n", "criterion = torch.nn.CrossEntropyLoss()\n", "\n", "mnist_train = torchvision.datasets.MNIST(\"data\", download=True)\n", "x_train, y_train = transform(mnist_train)\n", "mnist_test = torchvision.datasets.MNIST(\"data\", download=True, train=False)\n", "x_test, y_test = transform(mnist_test)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Tracking experiments with DVCLive" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# You can modify these parameters to see how they affect the training\n", "# And run the cell several times\n", "params = {\"epochs\": 5, \"lr\": 0.003, \"weight_decay\": 0}\n", "\n", "best_test_acc = 0\n", "\n", "with Live(report=\"notebook\") as live:\n", " live.log_params(params)\n", "\n", " for _ in range(params[\"epochs\"]):\n", " train_one_epoch(\n", " model, criterion, x_train, y_train, params[\"lr\"], params[\"weight_decay\"]\n", " )\n", "\n", " # Train Evaluation\n", " metrics_train, acual_train, predicted_train = evaluate(model, x_train, y_train)\n", "\n", " for k, v in metrics_train.items():\n", " live.log_metric(f\"train/{k}\", v)\n", "\n", " live.log_sklearn_plot(\n", " \"confusion_matrix\",\n", " acual_train,\n", " predicted_train,\n", " name=\"train/confusion_matrix\",\n", " )\n", "\n", " # Test Evaluation\n", " metrics_test, actual, predicted = evaluate(model, x_test, y_test)\n", "\n", " for k, v in metrics_test.items():\n", " live.log_metric(f\"test/{k}\", v)\n", "\n", " live.log_sklearn_plot(\n", " \"confusion_matrix\", actual, predicted, name=\"test/confusion_matrix\"\n", " )\n", "\n", " live.log_image(\n", " \"misclassified.jpg\", get_missclassified_image(actual, predicted, mnist_test)\n", " )\n", "\n", " # Save best model\n", " if metrics_test[\"acc\"] > best_test_acc:\n", " torch.save(model.state_dict(), \"model.pt\")\n", "\n", " live.next_step()\n", "\n", " live.log_artifact(\"model.pt\")" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Comparing results" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import dvc.api\n", "import pandas as pd\n", "\n", "columns = [\"epochs\", \"lr\", \"weight_decay\", \"test.acc\"]\n", "\n", "df = pd.DataFrame(dvc.api.exp_show(), columns=columns)\n", "\n", "df.dropna(inplace=True)\n", "df.reset_index(drop=True, inplace=True)\n", "df" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from plotly.express import parallel_coordinates\n", "\n", "fig = parallel_coordinates(df, columns, color=\"test.acc\")\n", "fig.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!dvc plots diff $(dvc exp list --names-only)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from IPython.display import HTML\n", "\n", "HTML(filename=\"./dvc_plots/index.html\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.16" } }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: examples/DVCLive-YOLO.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\"Open" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# DVCLive and Ultralytics YOLOv8" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%pip install dvclive ultralytics\n", "import ultralytics\n", "\n", "ultralytics.checks()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Tracking experiments with DVCLive" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "If `dvclive` is installed, Ultralytics YOLO v8 will automatically use DVCLive for tracking experiments." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!yolo train model=yolov8n.pt data=coco8.yaml epochs=5 imgsz=512\n", "!yolo train model=yolov8n.pt data=coco8.yaml epochs=5 imgsz=640\n", "!yolo train model=yolov8n.pt data=coco8.yaml epochs=10 imgsz=640\n", "!yolo train model=yolov8s.pt data=coco8.yaml epochs=10 imgsz=640\n", "!yolo train model=yolov8m.pt data=coco8.yaml epochs=10 imgsz=640" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Comparing results" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import dvc.api\n", "import pandas as pd\n", "\n", "columns = [\"Experiment\", \"epochs\", \"imgsz\", \"model\", \"metrics.mAP50-95(B)\"]\n", "\n", "df = pd.DataFrame(dvc.api.exp_show(), columns=columns)\n", "\n", "df.dropna(inplace=True)\n", "df.reset_index(drop=True, inplace=True)\n", "df" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from plotly.express import parallel_coordinates\n", "\n", "fig = parallel_coordinates(df, columns, color=\"metrics.mAP50-95(B)\")\n", "fig.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!dvc plots diff $(dvc exp list --names-only)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from IPython.display import HTML\n", "\n", "HTML(filename=\"./dvc_plots/index.html\")" ] } ], "metadata": { "language_info": { "name": "python" }, "orig_nbformat": 4 }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: examples/DVCLive-scikit-learn.ipynb ================================================ { "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\"Open" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# DVCLive and scikit-learn" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "!pip install dvclive scikit-learn" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "!git init -q\n", "!git config --local user.email \"you@example.com\"\n", "!git config --local user.name \"Your Name\"\n", "!dvc init -q\n", "!git commit -m \"DVC init\"" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "from sklearn.datasets import make_circles\n", "from sklearn.model_selection import train_test_split\n", "\n", "X, y = make_circles(noise=0.3, factor=0.5, random_state=42)\n", "\n", "X_train, X_test, y_train, y_test = train_test_split(\n", " X,\n", " y,\n", " random_state=42)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Tracking experiments with DVCLive" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "from dvclive import Live\n", "\n", "from sklearn.ensemble import RandomForestClassifier\n", "from sklearn.metrics import f1_score\n", "\n", "for n_estimators in (10, 50, 100):\n", "\n", " with Live() as live:\n", "\n", " live.log_param(\"n_estimators\", n_estimators)\n", "\n", " clf = RandomForestClassifier(n_estimators=n_estimators)\n", " clf.fit(X_train, y_train)\n", "\n", " y_train_pred = clf.predict(X_train)\n", "\n", " live.log_metric(\"train/f1\", f1_score(y_train, y_train_pred, average=\"weighted\"), plot=False)\n", " live.log_sklearn_plot(\n", " \"confusion_matrix\", y_train, y_train_pred, name=\"train/confusion_matrix\",\n", " title=\"Train Confusion Matrix\")\n", "\n", " y_test_pred = clf.predict(X_test)\n", "\n", " live.log_metric(\"test/f1\", f1_score(y_test, y_test_pred, average=\"weighted\"), plot=False)\n", " live.log_sklearn_plot(\n", " \"confusion_matrix\", y_test, y_test_pred, name=\"test/confusion_matrix\",\n", " title=\"Test Confusion Matrix\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Comparing results" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "import dvc.api\n", "import pandas as pd\n", "\n", "columns = [\"Experiment\", \"train.f1\", \"test.f1\", \"n_estimators\"]\n", "df = pd.DataFrame(dvc.api.exp_show(), columns=columns)\n", "\n", "df.dropna(inplace=True)\n", "df.reset_index(drop=True, inplace=True)\n", "df" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "!dvc plots diff $(dvc exp list --names-only)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "vscode": { "languageId": "plaintext" } }, "outputs": [], "source": [ "from IPython.display import HTML\n", "HTML(filename='./dvc_plots/index.html')" ] } ], "metadata": { "language_info": { "name": "python" }, "orig_nbformat": 4 }, "nbformat": 4, "nbformat_minor": 2 } ================================================ FILE: noxfile.py ================================================ """Automation using nox.""" import glob import os import nox nox.options.default_venv_backend = "uv|virtualenv" nox.options.reuse_existing_virtualenvs = True nox.options.sessions = "lint", "tests" project = nox.project.load_toml() python_versions = nox.project.python_versions(project) @nox.session(python=python_versions) def tests(session: nox.Session) -> None: session.install(".[dev]") session.run( "pytest", "--cov", "--cov-config=pyproject.toml", *session.posargs, env={"COVERAGE_FILE": f".coverage.{session.python}"}, ) @nox.session(python=python_versions) def core_tests(session: nox.Session) -> None: session.install(".[tests]") session.run( "pytest", "--ignore=tests/frameworks", "--cov", "--cov-config=pyproject.toml", *session.posargs, env={"COVERAGE_FILE": f".coverage.{session.python}"}, ) @nox.session def lint(session: nox.Session) -> None: session.install("pre-commit") session.install("-e", ".[dev]") args = *(session.posargs or ("--show-diff-on-failure",)), "--all-files" session.run("pre-commit", "run", *args) session.run("python", "-m", "mypy") @nox.session def safety(session: nox.Session) -> None: """Scan dependencies for insecure packages.""" session.install(".[dev]") session.install("safety") session.run("safety", "check", "--full-report") @nox.session def build(session: nox.Session) -> None: session.install("twine", "uv") session.run("uv", "build") dists = glob.glob("dist/*") session.run("twine", "check", *dists, silent=True) @nox.session def dev(session: nox.Session) -> None: """Sets up a python development environment for the project.""" args = session.posargs or ("venv",) venv_dir = os.fsdecode(os.path.abspath(args[0])) session.log(f"Setting up virtual environment in {venv_dir}") session.install("virtualenv") session.run("virtualenv", venv_dir, silent=True) python = os.path.join(venv_dir, "bin/python") session.run(python, "-m", "pip", "install", "-e", ".[dev]", external=True) ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools>=77", "setuptools_scm[toml]>=8"] build-backend = "setuptools.build_meta" [project] name = "dvclive" description = "Experiments logger for ML projects." readme = "README.md" keywords = [ "ai", "metrics", "collaboration", "data-science", "data-version-control", "developer-tools", "git", "machine-learning", "reproducibility" ] license = "Apache-2.0" license-files = ["LICENSE"] maintainers = [{name = "Iterative", email = "support@dvc.org"}] authors = [{name = "Iterative", email = "support@dvc.org"}] requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14" ] dynamic = ["version"] dependencies = [ "dvc>=3.48.4", "dvc-render>=1.0.0,<2", "dvc-studio-client>=0.20,<1", "funcy", "gto", "ruamel.yaml", "scmrepo>=3,<4", "psutil", "pynvml" ] [project.optional-dependencies] image = ["numpy", "pillow"] sklearn = ["scikit-learn>=1.5.0"] plots = ["scikit-learn", "pandas", "numpy"] markdown = ["matplotlib"] tests = [ "pytest>=7.2.0,<9.0", "pytest-sugar>=0.9.6,<2.0", "pytest-cov>=3.0.0,<8.0", "pytest-mock>=3.8.2,<4.0", "dvclive[image,plots,markdown]", "ipython", "pytest_voluptuous", "dpath", "transformers[torch]", "tf-keras" ] mmcv = ["mmcv"] tf = ["tensorflow"] xgb = ["xgboost"] lgbm = ["lightgbm"] huggingface = ["transformers", "datasets"] fastai = ["fastai"] lightning = ["lightning>=2.0", "torch", "jsonargparse[signatures]>=4.26.1"] optuna = ["optuna"] all = [ "dvclive[image,mmcv,tf,xgb,lgbm,huggingface,fastai,lightning,optuna,plots,markdown]" ] dev = [ "dvclive[image,tf,xgb,lgbm,huggingface,fastai,lightning,optuna,plots,markdown,tests]", "mypy==1.18.2", "types-PyYAML" ] [project.urls] Homepage = "https://github.com/iterative/dvclive" Documentation = "https://dvc.org/doc/dvclive" Repository = "https://github.com/iterative/dvclive" Changelog = "https://github.com/iterative/dvclive/releases" Issues = "https://github.com/iterative/dvclive/issues" [tool.setuptools.packages.find] exclude = ["tests", "tests.*"] where = ["src"] namespaces = false [tool.setuptools_scm] write_to = "src/dvclive/_dvclive_version.py" [tool.pytest.ini_options] addopts = "-ra" markers = """ vscode: mark a test that verifies behavior that VS Code relies on studio: mark a test that verifies behavior that Studio relies on """ [tool.coverage.run] branch = true source = ["dvclive", "tests"] [tool.coverage.paths] source = ["src", "*/site-packages"] [tool.coverage.report] show_missing = true exclude_lines = [ "pragma: no cover", "if __name__ == .__main__.:", "if typing.TYPE_CHECKING:", "if TYPE_CHECKING:", "raise NotImplementedError", "raise AssertionError", "@overload" ] [tool.mypy] # Error output show_column_numbers = true show_error_codes = true show_error_context = true show_traceback = true pretty = true check_untyped_defs = false # Warnings warn_no_return = true warn_redundant_casts = true warn_unreachable = true ignore_missing_imports = true files = ["src", "tests"] [tool.codespell] ignore-words-list = "fpr" [tool.ruff.lint] ignore = ["PLC0415", "G004"] select = [ "F", "E", "W", "C90", "I", "N", "UP", "YTT", "ASYNC", "S", "BLE", "B", "A", "C4", "DTZ", "T10", "EXE", "ISC", "ICN", "LOG", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TC", "TCH", "INT", "ARG", "PGH", "PLC", "PLE", "PLR", "PLW", "TRY", "NPY", "FLY", "PERF", "FURB", "RUF" ] [tool.ruff.lint.per-file-ignores] "noxfile.py" = ["D", "PTH"] "tests/*" = ["S101", "INP001", "SLF001", "ARG001", "ARG002", "ARG005", "PLR2004", "NPY002"] "examples/*.ipynb" = ["PERF401"] [tool.ruff.lint.pylint] max-args = 10 ================================================ FILE: src/dvclive/__init__.py ================================================ from .live import Live # noqa: F401 ================================================ FILE: src/dvclive/dvc.py ================================================ # ruff: noqa: SLF001 import copy import logging import os from pathlib import Path from typing import TYPE_CHECKING, Any, Optional from dvclive import env from dvclive.plots import Image, Metric from dvclive.serialize import dump_yaml from dvclive.utils import StrPath, rel_path if TYPE_CHECKING: from dvc.repo import Repo from dvc.stage import Stage logger = logging.getLogger("dvclive") def _dvc_dir(dirname: StrPath) -> str: return os.path.join(dirname, ".dvc") def _find_dvc_root(root: Optional[StrPath] = None) -> Optional[str]: if not root: root = os.getcwd() root = os.path.realpath(root) if not os.path.isdir(root): raise NotADirectoryError(f"'{root}'") while True: if os.path.exists(_dvc_dir(root)): return root if os.path.ismount(root): break root = os.path.dirname(root) return None def get_dvc_repo() -> Optional["Repo"]: from dvc.exceptions import NotDvcRepoError from dvc.repo import Repo from dvc.scm import Git, SCMError from scmrepo.exceptions import SCMError as GitSCMError try: return Repo() except (NotDvcRepoError, SCMError): try: return Repo.init(Git().root_dir) except GitSCMError: return None def make_dvcyaml(live) -> None: # noqa: C901 dvcyaml_dir = Path(live.dvc_file).parent.absolute().as_posix() dvcyaml = {} if live._params: dvcyaml["params"] = [rel_path(live.params_file, dvcyaml_dir)] if live._metrics or live.summary: dvcyaml["metrics"] = [rel_path(live.metrics_file, dvcyaml_dir)] plots: list[Any] = [] plots_path = Path(live.plots_dir) plots_metrics_path = plots_path / Metric.subfolder if plots_metrics_path.exists(): metrics_config = {rel_path(plots_metrics_path, dvcyaml_dir): {"x": "step"}} plots.append(metrics_config) if live._images: images_path = rel_path(plots_path / Image.subfolder, dvcyaml_dir) plots.append(images_path) if live._plots: for plot in live._plots.values(): plot_path = rel_path(plot.output_path, dvcyaml_dir) plots.append({plot_path: plot.plot_config}) if plots: dvcyaml["plots"] = plots if live._artifacts: dvcyaml["artifacts"] = copy.deepcopy(live._artifacts) for artifact in dvcyaml["artifacts"].values(): # type: ignore[attr-defined] artifact["path"] = rel_path(artifact["path"], dvcyaml_dir) if not os.path.exists(live.dvc_file): dump_yaml(dvcyaml, live.dvc_file) else: update_dvcyaml(live, dvcyaml) def update_dvcyaml(live, updates): from dvc.utils.serialize import modify_yaml dvcyaml_dir = os.path.abspath(os.path.dirname(live.dvc_file)) dvclive_dir = os.path.relpath(live.dir, dvcyaml_dir) + "/" def _drop_stale_dvclive_entries(entries): non_dvclive = [] for e in entries: if isinstance(e, str): if dvclive_dir not in e: non_dvclive.append(e) elif isinstance(e, dict) and len(e) == 1: if dvclive_dir not in next(iter(e.keys())): non_dvclive.append(e) else: non_dvclive.append(e) return non_dvclive def _update_entries(old, new, key): keepers = _drop_stale_dvclive_entries(old.get(key, [])) old[key] = keepers + new.get(key, []) if not old[key]: del old[key] return old with modify_yaml(live.dvc_file) as orig: orig = _update_entries(orig, updates, "params") # noqa: PLW2901 orig = _update_entries(orig, updates, "metrics") # noqa: PLW2901 orig = _update_entries(orig, updates, "plots") # noqa: PLW2901 old_artifacts = { name: meta for name, meta in orig.get("artifacts", {}).items() if dvclive_dir not in meta.get("path", dvclive_dir) } orig["artifacts"] = {**old_artifacts, **updates.get("artifacts", {})} if not orig["artifacts"]: del orig["artifacts"] def get_exp_name(name, scm, baseline_rev) -> str: from dvc.exceptions import InvalidArgumentError from dvc.repo.experiments.refs import ExpRefInfo from dvc.repo.experiments.utils import ( check_ref_format, gen_random_name, get_random_exp_name, ) name = name or os.getenv(env.DVC_EXP_NAME) if name and scm and baseline_rev: ref = ExpRefInfo(baseline_sha=baseline_rev, name=name) if scm.get_ref(str(ref)): logger.warning(f"Experiment conflicts with existing experiment '{name}'.") else: try: check_ref_format(scm, ref) except InvalidArgumentError as e: logger.warning(e) else: return name if scm and baseline_rev: return get_random_exp_name(scm, baseline_rev) if name: return name return gen_random_name() def find_overlapping_stage(dvc_repo: "Repo", path: StrPath) -> Optional["Stage"]: abs_path = str(Path(path).absolute()) for stage in dvc_repo.index.stages: for out in stage.outs: if str(out.fs_path) in abs_path: return stage return None def ensure_dir_is_tracked(directory: str, dvc_repo: "Repo") -> None: from pathspec import PathSpec dir_spec = PathSpec.from_lines("gitwildmatch", [directory]) outs_spec = PathSpec.from_lines( "gitwildmatch", [str(o) for o in dvc_repo.index.outs] ) paths_to_track = [ f for f in dvc_repo.scm.untracked_files() if (dir_spec.match_file(f) and not outs_spec.match_file(f)) ] if paths_to_track: dvc_repo.scm.add(paths_to_track) ================================================ FILE: src/dvclive/env.py ================================================ DVCLIVE_LOGLEVEL = "DVCLIVE_LOGLEVEL" DVCLIVE_OPEN = "DVCLIVE_OPEN" DVCLIVE_RESUME = "DVCLIVE_RESUME" DVCLIVE_TEST = "DVCLIVE_TEST" DVC_EXP_BASELINE_REV = "DVC_EXP_BASELINE_REV" DVC_EXP_NAME = "DVC_EXP_NAME" DVC_ROOT = "DVC_ROOT" ================================================ FILE: src/dvclive/error.py ================================================ from typing import Any class DvcLiveError(Exception): pass class InvalidDataTypeError(DvcLiveError): def __init__(self, name, val): self.name = name self.val = val super().__init__(f"Data '{name}' has not supported type {val}") class InvalidDvcyamlError(DvcLiveError): def __init__(self): super().__init__("`dvcyaml` path must have filename 'dvc.yaml'") class InvalidImageNameError(DvcLiveError): def __init__(self, name): self.name = name super().__init__(f"Cannot log image with name '{name}'") class InvalidPlotTypeError(DvcLiveError): def __init__(self, name): from .plots import SKLEARN_PLOTS self.name = name super().__init__( f"Plot type '{name}' is not supported." f"\nSupported types are: {list(SKLEARN_PLOTS)}" ) class InvalidParameterTypeError(DvcLiveError): def __init__(self, msg: Any): super().__init__(msg) class InvalidReportModeError(DvcLiveError): def __init__(self, val): super().__init__( f"`report` can only be `None`, `auto`, `html`, `notebook` or `md`. " f"Got {val} instead." ) ================================================ FILE: src/dvclive/fabric.py ================================================ # mypy: disable-error-code="no-redef" from argparse import Namespace from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Optional, Union try: from lightning.fabric.loggers.logger import Logger, rank_zero_experiment from lightning.fabric.utilities.logger import ( _add_prefix, _convert_params, _sanitize_callable_params, ) from lightning.fabric.utilities.rank_zero import rank_zero_only except ImportError: from lightning_fabric.loggers.logger import ( # type: ignore[assignment] Logger, rank_zero_experiment, ) from lightning_fabric.utilities.logger import ( _add_prefix, _convert_params, _sanitize_callable_params, ) from lightning_fabric.utilities.rank_zero import rank_zero_only from torch import is_tensor from dvclive.plots import Metric from dvclive.utils import standardize_metric_name if TYPE_CHECKING: from dvclive import Live class DVCLiveLogger(Logger): LOGGER_JOIN_CHAR = "/" def __init__( self, run_name: Optional[str] = None, prefix: str = "", experiment: Optional["Live"] = None, **kwargs: Any, ): super().__init__() self._version = run_name self._prefix = prefix self._experiment = experiment self._kwargs = kwargs @property def name(self) -> str: return "DvcLiveLogger" @property def version(self) -> Union[int, str]: if self._version is None: self._version = "" return self._version @property @rank_zero_experiment def experiment(self) -> "Live": if self._experiment is not None: return self._experiment assert ( # noqa: S101 rank_zero_only.rank == 0 # type: ignore[attr-defined] ), "tried to init DVCLive in non global_rank=0" # type: ignore[attr-defined] from dvclive import Live self._experiment = Live(**self._kwargs) return self._experiment @rank_zero_only def log_metrics( self, metrics: Mapping[str, Union[int, float, str]], step: Optional[int] = None, sync: Optional[bool] = True, ) -> None: assert ( # noqa: S101 rank_zero_only.rank == 0 # type: ignore[attr-defined] ), "experiment tried to log from global_rank != 0" if step: self.experiment.step = step else: self.experiment.step = self.experiment.step + 1 metrics = _add_prefix(metrics, self._prefix, self.LOGGER_JOIN_CHAR) # type: ignore[assignment,arg-type] for metric_name, metric_val in metrics.items(): val = metric_val if is_tensor(val): # type: ignore[unreachable] val = val.cpu().detach().item() # type: ignore[union-attr,unreachable] name = standardize_metric_name(metric_name, __name__) if Metric.could_log(val): self.experiment.log_metric(name=name, val=val) else: raise ValueError( # noqa: TRY003 f"\n you tried to log {val} which is currently not supported." "Try a scalar/tensor." ) if sync: self.experiment.sync() @rank_zero_only def log_hyperparams(self, params: Union[dict[str, Any], Namespace]) -> None: """Record hyperparameters. Args: params: a dictionary-like container with the hyperparameters """ params = _convert_params(params) params = _sanitize_callable_params(params) params = self._sanitize_params(params) self.experiment.log_params(params) @rank_zero_only def finalize(self, status: str) -> None: # noqa: ARG002 if self._experiment is not None: self.experiment.end() @staticmethod def _sanitize_params(params: Union[dict[str, Any], Namespace]) -> dict[str, Any]: from argparse import Namespace # logging of arrays with dimension > 1 is not supported, sanitize as string params = { k: str(v) if hasattr(v, "ndim") and v.ndim > 1 else v for k, v in params.items() } # logging of argparse.Namespace is not supported, sanitize as string params = { k: str(v) if isinstance(v, Namespace) else v for k, v in params.items() } return params # noqa: RET504 def __getstate__(self) -> dict[str, Any]: state = self.__dict__.copy() state["_experiment"] = None return state ================================================ FILE: src/dvclive/fastai.py ================================================ import inspect from typing import Optional from fastai.callback.core import Callback from dvclive import Live from dvclive.utils import standardize_metric_name def _inside_fine_tune(): """ Hack to find out if fastai is calling `after_fit` at the end of the "freeze" stage part of `learn.fine_tune` . """ fine_tune = False fit_one_cycle = False for frame in inspect.stack(): if frame.function == "fine_tune": fine_tune = True if frame.function == "fit_one_cycle": fit_one_cycle = True if fine_tune and fit_one_cycle: return True return False class DVCLiveCallback(Callback): def __init__( self, with_opt: bool = False, live: Optional[Live] = None, **kwargs, ): super().__init__() self.with_opt = with_opt self.live = live if live is not None else Live(**kwargs) self.freeze_stage_ended = False def before_fit(self): if hasattr(self, "lr_finder") or hasattr(self, "gather_preds"): return params = { "model": type(self.learn.model).__qualname__, "batch_size": getattr(self.dls, "bs", None), "batch_per_epoch": len(getattr(self.dls, "train", [])), "frozen": bool(getattr(self.opt, "frozen_idx", -1)), "frozen_idx": getattr(self.opt, "frozen_idx", -1), "transforms": f"{getattr(self.dls, 'tfms', None)}", } self.live.log_params(params) def after_epoch(self): if hasattr(self, "lr_finder") or hasattr(self, "gather_preds"): return logged_metrics = False for key, value in zip( self.learn.recorder.metric_names, self.learn.recorder.log ): if key == "epoch": continue self.live.log_metric(standardize_metric_name(key, __name__), float(value)) logged_metrics = True # When resuming (i.e. passing `start_epoch` to learner) # fast.ai calls after_epoch but we don't want to increase the step. if logged_metrics: self.live.next_step() def after_fit(self): if hasattr(self, "lr_finder") or hasattr(self, "gather_preds"): return if _inside_fine_tune() and not self.freeze_stage_ended: self.freeze_stage_ended = True else: if hasattr(self, "save_model") and self.save_model.last_saved_path: self.live.log_artifact(str(self.save_model.last_saved_path)) self.live.end() ================================================ FILE: src/dvclive/huggingface.py ================================================ # ruff: noqa: ARG002 import logging import os from typing import Literal, Optional, Union from transformers import ( TrainerCallback, TrainerControl, TrainerState, TrainingArguments, ) from transformers.trainer import Trainer from dvclive import Live from dvclive.utils import standardize_metric_name logger = logging.getLogger("dvclive") class DVCLiveCallback(TrainerCallback): def __init__( self, live: Optional[Live] = None, log_model: Optional[Union[Literal["all"], bool]] = None, **kwargs, ): logger.warning( "This callback is deprecated and will be removed in DVCLive 4.0" " in favor of `transformers.integrations.DVCLiveCallback`" " https://dvc.org/doc/dvclive/ml-frameworks/huggingface." ) super().__init__() self._log_model = log_model self.live = live if live is not None else Live(**kwargs) def on_train_begin( self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs, ): self.live.log_params(args.to_dict()) def on_log( self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs, ): logs = kwargs["logs"] for key, value in logs.items(): self.live.log_metric(standardize_metric_name(key, __name__), value) self.live.next_step() def on_save( self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs, ): if self._log_model == "all" and state.is_world_process_zero: assert args.output_dir is not None # noqa: S101 self.live.log_artifact(args.output_dir) def on_train_end( self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs, ): if self._log_model is True and state.is_world_process_zero: fake_trainer = Trainer( args=args, model=kwargs.get("model"), tokenizer=kwargs.get("tokenizer"), eval_dataset=["fake"], ) name = "best" if args.load_best_model_at_end else "last" assert args.output_dir is not None # noqa: S101 output_dir = os.path.join(args.output_dir, name) fake_trainer.save_model(output_dir) self.live.log_artifact(output_dir, name=name, type="model", copy=True) self.live.end() ================================================ FILE: src/dvclive/keras.py ================================================ # ruff: noqa: ARG002 from typing import Optional import tensorflow as tf from dvclive import Live from dvclive.utils import standardize_metric_name class DVCLiveCallback(tf.keras.callbacks.Callback): def __init__( self, save_weights_only: bool = False, live: Optional[Live] = None, **kwargs, ): super().__init__() self.save_weights_only = save_weights_only self.live = live if live is not None else Live(**kwargs) def on_epoch_end(self, epoch: int, logs: Optional[dict] = None): logs = logs or {} for metric, value in logs.items(): self.live.log_metric(standardize_metric_name(metric, __name__), value) self.live.next_step() def on_train_end(self, logs: Optional[dict] = None): self.live.end() ================================================ FILE: src/dvclive/lgbm.py ================================================ from typing import Optional from dvclive import Live class DVCLiveCallback: def __init__(self, live: Optional[Live] = None, **kwargs): super().__init__() self.live = live if live is not None else Live(**kwargs) def __call__(self, env): multi_eval = len(env.evaluation_result_list) > 1 for eval_result in env.evaluation_result_list: data_name, eval_name, result = eval_result[:3] self.live.log_metric( f"{data_name}/{eval_name}" if multi_eval else eval_name, result ) self.live.next_step() ================================================ FILE: src/dvclive/lightning.py ================================================ # mypy: disable-error-code="no-redef" import inspect from collections.abc import Mapping from pathlib import Path from typing import Optional, Union from typing_extensions import override try: from lightning.pytorch.callbacks.model_checkpoint import ModelCheckpoint from lightning.pytorch.loggers.logger import Logger from lightning.pytorch.loggers.utilities import _scan_checkpoints from lightning.pytorch.utilities import rank_zero_only except ImportError: from pytorch_lightning.callbacks.model_checkpoint import ( # type: ignore[assignment] ModelCheckpoint, ) from pytorch_lightning.loggers.logger import Logger # type: ignore[assignment] from pytorch_lightning.utilities import rank_zero_only try: from pytorch_lightning.utilities.logger import _scan_checkpoints except ImportError: from pytorch_lightning.loggers.utilities import ( # type: ignore[assignment] _scan_checkpoints, ) from dvclive.fabric import DVCLiveLogger as FabricDVCLiveLogger def _should_sync(): """ Find out if pytorch_lightning is calling `log_metrics` from the functions where we actually want to sync. For example, prevents calling sync when external callbacks call `log_metrics` or during the multiple `update_eval_step_metrics`. """ return any( frame.function in ( "update_train_step_metrics", "update_train_epoch_metrics", "log_eval_end_metrics", ) for frame in inspect.stack() ) class DVCLiveLogger(Logger, FabricDVCLiveLogger): def __init__( self, run_name: Optional[str] = "dvclive_run", prefix="", log_model: Union[str, bool] = False, experiment=None, **kwargs, ): super().__init__( run_name=run_name, prefix=prefix, experiment=experiment, **kwargs, ) self._log_model = log_model self._logged_model_time: dict[str, float] = {} self._checkpoint_callback: Optional[ModelCheckpoint] = None self._all_checkpoint_paths: list[str] = [] @rank_zero_only def log_metrics( self, metrics: Mapping[str, Union[int, float, str]], step: Optional[int] = None, sync: Optional[bool] = False, ) -> None: if not sync and _should_sync(): sync = True super().log_metrics(metrics, step, sync) def after_save_checkpoint(self, checkpoint_callback: ModelCheckpoint) -> None: if self._log_model in [True, "all"]: self._checkpoint_callback = checkpoint_callback self._scan_checkpoints(checkpoint_callback) if self._log_model == "all" or ( self._log_model is True and checkpoint_callback.save_top_k == -1 ): self._save_checkpoints(checkpoint_callback) @override @rank_zero_only def finalize(self, status: str) -> None: # Log best model. if self._checkpoint_callback: self._scan_checkpoints(self._checkpoint_callback) self._save_checkpoints(self._checkpoint_callback) best_model_path = self._checkpoint_callback.best_model_path self.experiment.log_artifact( best_model_path, name="best", type="model", copy=True ) super().finalize(status) def _scan_checkpoints(self, checkpoint_callback: ModelCheckpoint) -> None: # get checkpoints to be saved with associated score checkpoints = _scan_checkpoints(checkpoint_callback, self._logged_model_time) # update model time and append path to list of all checkpoints for t, p, _, _ in checkpoints: self._logged_model_time[p] = t self._all_checkpoint_paths.append(p) def _save_checkpoints(self, checkpoint_callback: ModelCheckpoint) -> None: # drop unused checkpoints if not self.experiment._resume and checkpoint_callback.dirpath: # noqa: SLF001 for p in Path(checkpoint_callback.dirpath).iterdir(): if str(p) not in self._all_checkpoint_paths: p.unlink(missing_ok=True) # save directory self.experiment.log_artifact(checkpoint_callback.dirpath) ================================================ FILE: src/dvclive/live.py ================================================ import builtins import glob import json import logging import math import os import queue import shutil import tempfile import threading from pathlib import Path, PurePath from typing import TYPE_CHECKING, Any, Literal, Optional, Union if TYPE_CHECKING: import matplotlib as mpl import numpy as np import pandas as pd import PIL from dvc.repo import Repo from IPython.display import DisplayHandle from dvc.exceptions import DvcException from dvc.utils.studio import get_repo_url, get_subrepo_relpath from funcy import set_in from ruamel.yaml.representer import RepresenterError from . import env from .dvc import ( ensure_dir_is_tracked, find_overlapping_stage, get_dvc_repo, get_exp_name, make_dvcyaml, ) from .error import ( InvalidDataTypeError, InvalidDvcyamlError, InvalidImageNameError, InvalidParameterTypeError, InvalidPlotTypeError, InvalidReportModeError, ) from .monitor_system import _SystemMonitor from .plots import PLOT_TYPES, SKLEARN_PLOTS, CustomPlot, Image, Metric, NumpyEncoder from .report import BLANK_NOTEBOOK_REPORT, make_report from .serialize import dump_json, dump_yaml, load_yaml from .studio import get_dvc_studio_config, post_to_studio from .utils import ( StrPath, catch_and_warn, clean_and_copy_into, convert_datapoints_to_list_of_dicts, env2bool, inside_notebook, matplotlib_installed, open_file_in_browser, parse_metrics, ) from .vscode import ( cleanup_dvclive_step_completed, mark_dvclive_only_ended, mark_dvclive_only_started, mark_dvclive_step_completed, ) logger = logging.getLogger("dvclive") logger.setLevel(os.getenv(env.DVCLIVE_LOGLEVEL, "WARNING").upper()) handler = logging.StreamHandler() formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s") handler.setFormatter(formatter) logger.addHandler(handler) ParamLike = Union[int, float, str, bool, list["ParamLike"], dict[str, "ParamLike"]] NULL_SHA: str = "0" * 40 class Live: def __init__( self, dir: str = "dvclive", # noqa: A002 resume: bool = False, report: Optional[Literal["md", "notebook", "html"]] = None, save_dvc_exp: bool = True, dvcyaml: Union[str, os.PathLike, bool, None] = "dvc.yaml", cache_images: bool = False, exp_name: Optional[str] = None, exp_message: Optional[str] = None, monitor_system: bool = False, ): """ Initializes a DVCLive logger. A `Live()` instance is required in order to log machine learning parameters, metrics and other metadata. Warning: `Live()` will remove all existing DVCLive related files under dir unless `resume=True`. Args: dir (str | Path): where to save DVCLive's outputs. Defaults to `"dvclive"`. resume (bool): if `True`, DVCLive will try to read the previous step from the metrics_file and start from that point. Defaults to `False`. report ("html", "md", "notebook", None): any of `"html"`, `"notebook"`, `"md"` or `None`. See `Live.make_report()`. Defaults to None. save_dvc_exp (bool): if `True`, DVCLive will create a new DVC experiment as part of `Live.end()`. Defaults to `True`. If you are using DVCLive inside a DVC Pipeline and running with `dvc exp run`, the option will be ignored. dvcyaml (str | Path | None): where to write dvc.yaml file, which adds DVC configuration for metrics, plots, and parameters as part of `Live.next_step()` and `Live.end()`. If `None`, no dvc.yaml file is written. Defaults to `"dvc.yaml"`. See `Live.make_dvcyaml()`. If a string or Path like `"subdir/dvc.yaml"`, DVCLive will write the configuration to that path (file must be named "dvc.yaml"). If `False`, DVCLive will not write to "dvc.yaml" (useful if you are tracking DVCLive metrics, plots, and parameters independently and want to avoid duplication). cache_images (bool): if `True`, DVCLive will cache any images logged with `Live.log_image()` as part of `Live.end()`. Defaults to `False`. If running a DVC pipeline, `cache_images` will be ignored, and you should instead cache images as pipeline outputs. exp_name (str | None): if not `None`, and `save_dvc_exp` is `True`, the provided string will be passed to `dvc exp save --name`. If DVCLive is used inside `dvc exp run`, the option will be ignored, use `dvc exp run --name` instead. exp_message (str | None): if not `None`, and `save_dvc_exp` is `True`, the provided string will be passed to `dvc exp save --message`. If DVCLive is used inside `dvc exp run`, the option will be ignored, use `dvc exp run --message` instead. monitor_system (bool): if `True`, DVCLive will monitor GPU, CPU, ram, and disk usage. Defaults to `False`. """ self.summary: dict[str, Any] = {} self._dir: str = dir self._resume: bool = resume or env2bool(env.DVCLIVE_RESUME) self._save_dvc_exp: bool = save_dvc_exp self._step: Optional[int] = None self._metrics: dict[str, Any] = {} self._images: dict[str, Image] = {} self._params: dict[str, Any] = {} self._plots: dict[str, Any] = {} self._artifacts: dict[str, dict] = {} self._inside_with = False self._dvcyaml = dvcyaml self._cache_images = cache_images self._report_mode: Optional[str] = report self._report_notebook: Optional[DisplayHandle] = None self._init_report() self._baseline_rev: str = os.getenv(env.DVC_EXP_BASELINE_REV, NULL_SHA) self._exp_name: Optional[str] = exp_name or os.getenv(env.DVC_EXP_NAME) self._exp_message: Optional[str] = exp_message self._subdir: Optional[str] = None self._repo_url: Optional[str] = None self._experiment_rev: Optional[str] = None self._inside_dvc_exp: bool = False self._inside_dvc_pipeline: bool = False self._dvc_repo: Optional[Repo] = None self._include_untracked: list[str] = [] if env2bool(env.DVCLIVE_TEST): self._init_test() else: self._init_dvc() os.makedirs(self.dir, exist_ok=True) if self._resume: self._init_resume() else: self._init_cleanup() self._latest_studio_step: int = self.step if resume else -1 self._studio_events_to_skip: set[str] = set() self._dvc_studio_config: dict[str, Any] = {} self._num_points_sent_to_studio: dict[str, int] = {} self._studio_queue = None self._init_studio() self._system_monitor: Optional[_SystemMonitor] = None # Monitoring thread if monitor_system: self.monitor_system() def _init_resume(self): self._read_params() self.summary = self.read_latest() self._step = self.read_step() if self._step != 0: logger.info(f"Resuming from step {self._step}") self._step += 1 logger.debug(f"{self._step=}") def _init_cleanup(self): for plot_type in PLOT_TYPES: shutil.rmtree( Path(self.plots_dir) / plot_type.subfolder, ignore_errors=True ) for f in ( self.metrics_file, self.params_file, os.path.join(self.dir, "report.html"), os.path.join(self.dir, "report.md"), ): if f and os.path.exists(f): os.remove(f) for dvc_file in glob.glob(os.path.join(self.dir, "**dvc.yaml")): os.remove(dvc_file) @catch_and_warn(DvcException, logger) def _init_dvc(self): # noqa: C901 from dvc.scm import NoSCM if os.getenv(env.DVC_ROOT, None): self._inside_dvc_pipeline = True self._init_dvc_pipeline() self._dvc_repo = get_dvc_repo() scm = self._dvc_repo.scm if self._dvc_repo else None if isinstance(scm, NoSCM): scm = None if scm: self._baseline_rev = scm.get_rev() self._exp_name = get_exp_name(self._exp_name, scm, self._baseline_rev) logger.info(f"Logging to experiment '{self._exp_name}'") dvc_logger = logging.getLogger("dvc") dvc_logger.setLevel(os.getenv(env.DVCLIVE_LOGLEVEL, "WARNING").upper()) self._dvc_file = self._init_dvc_file() if not scm: if self._save_dvc_exp: logger.warning( "Can't save experiment without a Git Repo." "\nCreate a Git repo (`git init`) and commit (`git commit`)." ) self._save_dvc_exp = False return if scm.no_commits: if self._save_dvc_exp: logger.warning( "Can't save experiment to an empty Git Repo." "\nAdd a commit (`git commit`) to save experiments." ) self._save_dvc_exp = False return if self._dvcyaml and ( stage := find_overlapping_stage(self._dvc_repo, self.dvc_file) ): logger.warning( f"'{self.dvc_file}' is in outputs of stage '{stage.addressing}'." "\nRemove it from outputs to make DVCLive work as expected." ) if self._inside_dvc_pipeline: return self._subdir = get_subrepo_relpath(self._dvc_repo) self._repo_url = get_repo_url(self._dvc_repo) if self._save_dvc_exp: mark_dvclive_only_started(self._exp_name) self._include_untracked.append(self.dir) def _init_dvc_file(self) -> str: if self._dvcyaml is None: return "dvc.yaml" if isinstance(self._dvcyaml, bool): return "dvc.yaml" self._dvcyaml = os.fspath(self._dvcyaml) if ( isinstance(self._dvcyaml, str) and os.path.basename(self._dvcyaml) == "dvc.yaml" ): return self._dvcyaml raise InvalidDvcyamlError def _init_dvc_pipeline(self): if os.getenv(env.DVC_EXP_BASELINE_REV, None): # `dvc exp` execution self._inside_dvc_exp = True if self._save_dvc_exp: logger.info("Ignoring `save_dvc_exp` because `dvc exp run` is running") # `dvc repro` execution elif self._save_dvc_exp: logger.warning( "Ignoring `save_dvc_exp` because `dvc repro` is running." "\nUse `dvc exp run` to save experiment." ) self._save_dvc_exp = False def _init_studio(self): self._dvc_studio_config = get_dvc_studio_config(self) if not self._dvc_studio_config: logger.debug("Skipping `studio` report.") self._studio_events_to_skip.add("start") self._studio_events_to_skip.add("data") self._studio_events_to_skip.add("done") elif self._inside_dvc_exp: logger.debug("Skipping `studio` report `start` and `done` events.") self._studio_events_to_skip.add("start") self._studio_events_to_skip.add("done") else: post_to_studio(self, "start") def _init_report(self): if self._report_mode not in {None, "html", "notebook", "md"}: raise InvalidReportModeError(self._report_mode) if self._report_mode == "notebook": if inside_notebook(): from IPython.display import Markdown, display self._report_mode = "notebook" self._report_notebook = display( Markdown(BLANK_NOTEBOOK_REPORT), display_id=True ) else: logger.warning( "Report mode 'notebook' requires to be" " inside a notebook. Disabling report." ) self._report_mode = None if self._report_mode in ("notebook", "md") and not matplotlib_installed(): logger.warning( f"Report mode '{self._report_mode}' requires 'matplotlib'" " to be installed. Disabling report." ) self._report_mode = None logger.debug(f"{self._report_mode=}") def _init_test(self): """ Enables a test mode that writes to temporary paths and doesn't depend on the repository. This is needed to run integration tests in external libraries, such as HuggingFace Accelerate. """ with tempfile.TemporaryDirectory() as dirpath: self._dir = os.path.join(dirpath, self._dir) self._dvcyaml = os.fspath(self._dvcyaml) if isinstance(self._dvcyaml, str): self._dvc_file = os.path.join(dirpath, self._dvcyaml) self._save_dvc_exp = False logger.warning( "DVCLive testing mode enabled." f"Repo will be ignored and output will be written to {dirpath}." ) @property def dir(self) -> str: """Location of the directory to store outputs.""" return self._dir @property def params_file(self) -> str: return os.path.join(self.dir, "params.yaml") @property def metrics_file(self) -> str: return os.path.join(self.dir, "metrics.json") @property def dvc_file(self) -> str: """Path for dvc.yaml file.""" return self._dvc_file @property def plots_dir(self) -> str: return os.path.join(self.dir, "plots") @property def artifacts_dir(self) -> str: return os.path.join(self.dir, "artifacts") @property def report_file(self) -> Optional[str]: if self._report_mode in ("html", "md"): suffix = self._report_mode return os.path.join(self.dir, f"report.{suffix}") return None @property def step(self) -> int: return self._step or 0 @step.setter def step(self, value: int) -> None: self._step = value logger.debug(f"Step: {self.step}") def monitor_system( self, interval: float = 0.05, # seconds num_samples: int = 20, directories_to_monitor: Optional[dict[str, str]] = None, ) -> None: """Monitor GPU, CPU, ram, and disk resources and log them to DVC Live. Args: interval (float): the time interval between samples in seconds. To keep the sampling interval small, the maximum value allowed is 0.1 seconds. Default to 0.05. num_samples (int): the number of samples to collect before the aggregation. The value should be between 1 and 30 samples. Default to 20. directories_to_monitor (Optional[Dict[str, str]]): a dictionary with the information about which directories to monitor. The `key` would be the name of the metric and the `value` is the path to the directory. The metric tracked concerns the partition that contains the directory. Default to `{"main": "/"}`. Raises: ValueError: if the keys in `directories_to_monitor` contains invalid characters as defined by `os.path.normpath`. """ if directories_to_monitor is None: directories_to_monitor = {"main": "/"} if self._system_monitor is not None: self._system_monitor.end() self._system_monitor = _SystemMonitor( live=self, interval=interval, num_samples=num_samples, directories_to_monitor=directories_to_monitor, ) def sync(self): self.make_summary() if self._dvcyaml: self.make_dvcyaml() self.make_report() self.post_data_to_studio() def next_step(self): """ Signals that the current iteration has ended and increases step value by one. DVCLive uses `step` to track the history of the metrics logged with `Live.log_metric()`. You can use `Live.next_step()` to increase the step by one. In addition to increasing the `step` number, it will call `Live.make_report()`, `Live.make_dvcyaml()`, and `Live.make_summary()` by default. """ if self._step is None: self._step = 0 self.sync() mark_dvclive_step_completed(self.step) self.step += 1 def log_metric( self, name: str, val: Union[float, str], timestamp: bool = False, plot: bool = True, ): """ On each `Live.log_metric(name, val)` call `DVCLive` will create a metrics history file in `{Live.plots_dir}/metrics/{name}.tsv`. Each subsequent call to `Live.log_metric(name, val)` will add a new row to `{Live.plots_dir}/metrics/{name}.tsv`. In addition, `DVCLive` will store the latest value logged in `Live.summary`, so it can be serialized with calls to `live.make_summary()`, `live.next_step()` or when exiting the `Live` context block. Args: name (str): name of the metric being logged. val (int | float | str): the value to be logged. timestamp (bool): whether to automatically log timestamp in the metrics history file. plot (bool): whether to add the metric value to the metrics history file for plotting. If `False`, the metric will only be saved to the metrics summary. Raises: `InvalidDataTypeError`: thrown if the provided `val` does not have a supported type. """ if not Metric.could_log(val): raise InvalidDataTypeError(name, type(val)) if not isinstance(val, str) and (math.isnan(val) or math.isinf(val)): val = str(val) if name in self._metrics: metric = self._metrics[name] else: metric = Metric(name, self.plots_dir) self._metrics[name] = metric metric.step = self.step if plot: metric.dump(val, timestamp=timestamp) self.summary = set_in(self.summary, metric.summary_keys, val) logger.debug(f"Logged {name}: {val}") def log_image( self, name: str, val: "Union[np.ndarray, mpl.figure.Figure, PIL.Image.Image, StrPath]", ): """ Saves the given image `val` to the output file `name`. Supported values for val are: - A valid NumPy array (convertible to an image via `PIL.Image.fromarray`) - A `matplotlib.figure.Figure` instance - A `PIL.Image` instance - A path to an image file (`str` or `Path`). It should be in a format that is readable by `PIL.Image.open()` The images will be saved in `{Live.plots_dir}/images/{name}`. When using `Live(cache_images=True)`, the images directory will also be cached as part of `Live.end()`. In that case, a `.dvc` file will be saved to track it, and the directory will be added to a `.gitignore` file to prevent Git tracking. By default the images will be overwritten on each step. However, you can log images using the following pattern `live.log_image(f"folder/{live.step}.png", img)`. In `DVC Studio` and the `DVC Extension for VSCode`, folders following this pattern will be rendered using an image slider. Args: name (str): name of the image file that this command will output val (np.ndarray | matplotlib.figure.Figure | PIL.Image | StrPath): image to be saved. See the list of supported values in the description. Raises: `InvalidDataTypeError`: thrown if the provided `val` does not have a supported type. """ if not Image.could_log(val): raise InvalidDataTypeError(name, type(val)) # If we're given a path, try loading the image first. This might error out. if isinstance(val, (str, PurePath)): from PIL import Image as ImagePIL suffix = Path(val).suffix if not Path(name).suffix and suffix in Image.suffixes: name = f"{name}{suffix}" val = ImagePIL.open(val) # See if the image name is valid if Path(name).suffix not in Image.suffixes: raise InvalidImageNameError(name) if name in self._images: image = self._images[name] else: image = Image(name, self.plots_dir) self._images[name] = image image.step = self.step image.dump(val) logger.debug(f"Logged {name}: {val}") def log_plot( self, name: str, datapoints: "Union[pd.DataFrame, np.ndarray, list[dict]]", x: str, y: Union[str, list[str]], template: Optional[str] = "linear", title: Optional[str] = None, x_label: Optional[str] = None, y_label: Optional[str] = None, ): """ The method will dump the provided datapoints to `{Live.dir}/plots/custom/{name}.json`and store the provided properties to be included in the plots section written by `Live.make_dvcyaml()`. The plot can be rendered with `DVC CLI`, `VSCode Extension` or `DVC Studio`. Args: name (StrPath): name of the output file. datapoints (pd.DataFrame | np.ndarray | List[Dict]): Pandas DataFrame, Numpy Array or List of dictionaries containing the data for the plot. x (str): name of the key (present in the dictionaries) to use as the x axis. y (str | list[str]): name of the key or keys (present in the dictionaries) to use the y axis. template (str): name of the `DVC plots template` to use. Defaults to `"linear"`. title (str): title to be displayed. Defaults to `"{Live.dir}/plots/custom/{name}.json"`. x_label (str): label for the x axis. Defaults to the name passed as `x`. y_label (str): label for the y axis. Defaults to the name passed as `y`. Raises: `InvalidDataTypeError`: thrown if the provided `datapoints` does not have a supported type. """ # Convert the given datapoints to List[Dict] datapoints = convert_datapoints_to_list_of_dicts(datapoints=datapoints) if not CustomPlot.could_log(datapoints): raise InvalidDataTypeError(name, type(datapoints)) if name in self._plots: plot = self._plots[name] else: plot = CustomPlot( name, self.plots_dir, x=x, y=y, template=template, title=title, x_label=x_label, y_label=y_label, ) self._plots[name] = plot plot.step = self.step plot.dump(datapoints) logger.debug(f"Logged {name}") def log_sklearn_plot( self, kind: str, labels: "Union[list, np.ndarray]", predictions: "Union[list, tuple, np.ndarray]", name: Optional[str] = None, title: Optional[str] = None, x_label: Optional[str] = None, y_label: Optional[str] = None, normalized: Optional[bool] = None, **kwargs, ): """ Generates a scikit learn plot and saves the data in `{Live.dir}/plots/sklearn/{name}.json`. The method will compute and dump the `kind` plot to `{Live.dir}/plots/sklearn/{name}` in a format compatible with dvc plots. It will also store the provided properties to be included in the plots section written by `Live.make_dvcyaml()`. Args: kind ("calibration" | "confusion_matrix" | "det" | "precision_recall" | "roc"): a supported plot type. labels (List | np.ndarray): array of ground truth labels. predictions (List | np.ndarray): array of predicted labels (for `"confusion_matrix"`) or predicted probabilities (for other plots). name (str): optional name of the output file. If not provided, `kind` will be used as name. title (str): optional title to be displayed. x_label (str): optional label for the x axis. y_label (str): optional label for the y axis. normalized (bool): optional, `confusion_matrix` with values normalized to `<0, 1>` range. kwargs: additional arguments to tune the result. Arguments are passed to the scikit-learn function (e.g. `drop_intermediate=True` for the `"roc"` type). Raises: InvalidPlotTypeError: thrown if the provided `kind` does not correspond to any of the supported plots. """ val = (labels, predictions) plot_config = { k: v for k, v in { "title": title, "x_label": x_label, "y_label": y_label, "normalized": normalized, }.items() if v is not None } name = name or kind if name in self._plots: plot = self._plots[name] elif kind in SKLEARN_PLOTS and SKLEARN_PLOTS[kind].could_log(val): plot = SKLEARN_PLOTS[kind](name, self.plots_dir, **plot_config) self._plots[plot.name] = plot else: raise InvalidPlotTypeError(name) plot.step = self.step plot.dump(val, **kwargs) logger.debug(f"Logged {name}") def _read_params(self): if os.path.isfile(self.params_file): params = load_yaml(self.params_file) self._params.update(params) def _dump_params(self): try: dump_yaml(self._params, self.params_file) except RepresenterError as exc: raise InvalidParameterTypeError(exc.args[0]) from exc def log_params(self, params: dict[str, ParamLike]): """ On each `Live.log_params(params)` call, DVCLive will write keys/values pairs in the params dict to `{Live.dir}/params.yaml`: Also see `Live.log_param()`. Args: params (Dict[str, ParamLike]): dictionary with name/value pairs of parameters to be logged. Raises: `InvalidParameterTypeError`: thrown if the parameter value is not among supported types. """ self._params.update(params) self._dump_params() logger.debug(f"Logged {params} parameters to {self.params_file}") def log_param(self, name: str, val: ParamLike): """ On each `Live.log_param(name, val)` call, DVCLive will write the name parameter to `{Live.dir}/params.yaml` with the corresponding `val`. Also see `Live.log_params()`. Args: name (str): name of the parameter being logged. val (ParamLike): the value to be logged. Raises: `InvalidParameterTypeError`: thrown if the parameter value is not among supported types. """ self.log_params({name: val}) def log_artifact( self, path: StrPath, type: Optional[str] = None, # noqa: A002 name: Optional[str] = None, desc: Optional[str] = None, labels: Optional[list[str]] = None, meta: Optional[dict[str, Any]] = None, copy: bool = False, cache: bool = True, ): """ Tracks an existing directory or file with DVC. Log path, saving its contents to DVC storage. Also annotate with any included metadata fields (for example, to be consumed in the model registry or automation scenarios). If `cache=True` (which is the default), uses `dvc add` to track path with DVC, saving it to the DVC cache and generating a `{path}.dvc` file that acts as a pointer to the cached data. If you include any of the optional metadata fields (type, name, desc, labels, meta), it will add an artifact and all the metadata passed as arguments to the corresponding `dvc.yaml` (unless `dvcyaml=None`). Passing `type="model"` will include it in the model registry. Args: path (StrPath): an existing directory or file. type (Optional[str]): an optional type of the artifact. Common types are `"model"` or `"dataset"`. name (Optional[str]): an optional custom name of an artifact. If not provided the `path` stem (last part of the path without the file extension) will be used as the artifact name. desc (Optional[str]): an optional description of an artifact. labels (Optional[List[str]]): optional labels describing the artifact. meta (Optional[Dict[str, Any]]): optional metainformation in `key: value` format. copy (bool): copy a directory or file at path into the `dvclive/artifacts` location (default) before tracking it. The new path is used instead of the original one to track the artifact. Useful if you don't want to track the original path in your repo (for example, it is outside the repo or in a Git-ignored directory). cache (bool): cache the files with DVC to track them outside of Git. Defaults to `True`, but set to `False` if you want to annotate metadata about the artifact without storing a copy in the DVC cache. If running a DVC pipeline, `cache` will be ignored, and you should instead cache artifacts as pipeline outputs. Raises: `InvalidDataTypeError`: thrown if the provided `path` does not have a supported type. """ if not isinstance(path, (str, PurePath)): raise InvalidDataTypeError(path, builtins.type(path)) if self._dvc_repo is not None: from gto.constants import assert_name_is_valid from gto.exceptions import ValidationError if copy: path = clean_and_copy_into(path, self.artifacts_dir) if cache: self.cache(path) if any((type, name, desc, labels, meta)): name = name or Path(path).stem try: assert_name_is_valid(name) self._artifacts[name] = { k: v for k, v in locals().items() if k in ("path", "type", "desc", "labels", "meta") and v is not None } except ValidationError: logger.warning( "Can't use '%s' as artifact name (ID)." " It will not be included in the `artifacts` section.", name, ) else: logger.warning( "A DVC repo is required to log artifacts. " f"Skipping `log_artifact({path})`." ) @catch_and_warn(DvcException, logger) def cache(self, path): if self._inside_dvc_pipeline: existing_stage = find_overlapping_stage(self._dvc_repo, path) if existing_stage: if existing_stage.cmd: logger.info( f"Skipping `dvc add {path}` because it is already being" " tracked automatically as an output of the DVC pipeline." ) return # skip caching logger.warning( f"To track '{path}' automatically in the DVC pipeline:" f"\n1. Run `dvc remove {existing_stage.addressing}` " "to stop tracking it outside the pipeline." "\n2. Add it as an output of the pipeline stage." ) else: logger.warning( f"To track '{path}' automatically in the DVC pipeline, " "add it as an output of the pipeline stage." ) stage = self._dvc_repo.add(str(path)) dvc_file = stage[0].addressing if self._save_dvc_exp: self._include_untracked.append(dvc_file) self._include_untracked.append(str(Path(dvc_file).parent / ".gitignore")) def make_summary(self): """ Serializes a summary of the logged metrics (`Live.summary`) to `Live.metrics_file`. The `Live.summary` object will contain the latest value of each metric logged with `Live.log_metric()`. It can be also modified manually. `Live.next_step()` and `Live.end()` will call `Live.make_summary()` internally, so you don't need to call both. The summary is usable by `dvc metrics`. """ if self._step is not None: self.summary["step"] = self.step dump_json(self.summary, self.metrics_file, cls=NumpyEncoder) def make_report(self): """ Generates a report from the logged data. `Live.next_step()` and `Live.end()` will call `Live.make_report()` internally, so you don't need to call both. On each call, DVCLive will collect all the data logged in `{Live.dir}`, generate a report and save it in `{Live.dir}/report.{format}`. The format can be HTML or Markdown depending on the value of the `report` argument passed to `Live()`. """ if self._report_mode is not None: make_report(self) if self._report_mode == "html" and env2bool(env.DVCLIVE_OPEN): open_file_in_browser(self.report_file) @catch_and_warn(DvcException, logger) def make_dvcyaml(self): """ Writes DVC configuration for metrics, plots, and parameters to `Live.dvc_file`. Creates `dvc.yaml`, which describes and configures metrics, plots, and parameters. DVC tools use this file to show reports and experiments tables. `Live.next_step()` and `Live.end()` will call `Live.make_dvcyaml()` internally, so you don't need to call both (unless `dvcyaml=None`). """ make_dvcyaml(self) def _get_live_data(self) -> Optional[dict[str, Any]]: params = load_yaml(self.params_file) if os.path.isfile(self.params_file) else {} plots, metrics = parse_metrics(self) # Plots can grow large, we don't want to keep in memory data # that we 100% sent already plots_to_send = {} plots_start_idx = {} for name, plot in plots.items(): num_points_sent = self._num_points_sent_to_studio.get(name, 0) plots_to_send[name] = plot[num_points_sent:] plots_start_idx[name] = num_points_sent return { "params": params, "plots": plots_to_send, "plots_start_idx": plots_start_idx, "metrics": metrics, "images": list(self._images.values()), "step": self.step, } def post_data_to_studio(self): if not self._studio_queue: self._studio_queue = queue.Queue() def worker(): error_occurred = False while True: item, data = self._studio_queue.get() try: if not error_occurred: post_to_studio(item, "data", data) except Exception: logger.exception("Failed to post data to studio") error_occurred = True finally: self._studio_queue.task_done() threading.Thread(target=worker, daemon=True).start() self._studio_queue.put((self, self._get_live_data())) def _wait_for_studio_updates_posted(self): if self._studio_queue: logger.debug("Waiting for studio updates to be posted") self._studio_queue.join() def end(self): """ Signals that the current experiment has ended. `Live.end()` gets automatically called when exiting the context manager. It is also called when the training ends for each of the supported ML Frameworks By default, `Live.end()` will call `Live.make_summary()`, `Live.make_dvcyaml()`, and `Live.make_report()`. If `save_dvc_exp=True`, it will save a new DVC experiment and write a `dvc.yaml` file configuring what DVC will show for logged plots, metrics, and parameters. """ if self._inside_with: # Prevent `live.end` calls inside context manager return if self._images and self._cache_images: images_path = Path(self.plots_dir) / Image.subfolder self.cache(images_path) # If next_step called before end, don't want to update step number if "step" in self.summary: self.step = self.summary["step"] # Kill threads that monitor the system metrics if self._system_monitor is not None: self._system_monitor.end() self.sync() if self._inside_dvc_exp and self._dvc_repo: catch_and_warn(DvcException, logger)(ensure_dir_is_tracked)( self.dir, self._dvc_repo ) if self._dvcyaml: catch_and_warn(DvcException, logger)(self._dvc_repo.scm.add)( self.dvc_file ) self.save_dvc_exp() self._wait_for_studio_updates_posted() # Mark experiment as done post_to_studio(self, "done") cleanup_dvclive_step_completed() def read_step(self): latest = self.read_latest() return latest.get("step", 0) def read_latest(self): if Path(self.metrics_file).exists(): with open(self.metrics_file, encoding="utf-8") as fobj: return json.load(fobj) return {} def __enter__(self): self._inside_with = True return self def __exit__(self, exc_type, exc_val, exc_tb): self._inside_with = False self.end() @catch_and_warn(DvcException, logger, mark_dvclive_only_ended) def save_dvc_exp(self): if self._save_dvc_exp: if self._dvcyaml: self._include_untracked.append(self.dvc_file) self._experiment_rev = self._dvc_repo.experiments.save( name=self._exp_name, include_untracked=self._include_untracked, force=True, message=self._exp_message, ) ================================================ FILE: src/dvclive/monitor_system.py ================================================ import logging import os from statistics import mean from threading import Event, Thread from typing import Union import psutil from funcy import merge_with try: from pynvml import ( NVMLError, nvmlDeviceGetCount, nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo, nvmlDeviceGetUtilizationRates, nvmlInit, nvmlShutdown, ) GPU_AVAILABLE = True except ImportError: GPU_AVAILABLE = False logger = logging.getLogger("dvclive") GIGABYTES_DIVIDER = 1024.0**3 MINIMUM_CPU_USAGE_TO_BE_ACTIVE = 20 METRIC_CPU_COUNT = "system/cpu/count" METRIC_CPU_USAGE_PERCENT = "system/cpu/usage (%)" METRIC_CPU_PARALLELIZATION_PERCENT = "system/cpu/parallelization (%)" METRIC_RAM_USAGE_PERCENT = "system/ram/usage (%)" METRIC_RAM_USAGE_GB = "system/ram/usage (GB)" METRIC_RAM_TOTAL_GB = "system/ram/total (GB)" METRIC_DISK_USAGE_PERCENT = "system/disk/usage (%)" METRIC_DISK_USAGE_GB = "system/disk/usage (GB)" METRIC_DISK_TOTAL_GB = "system/disk/total (GB)" METRIC_GPU_COUNT = "system/gpu/count" METRIC_GPU_USAGE_PERCENT = "system/gpu/usage (%)" METRIC_VRAM_USAGE_PERCENT = "system/vram/usage (%)" METRIC_VRAM_USAGE_GB = "system/vram/usage (GB)" METRIC_VRAM_TOTAL_GB = "system/vram/total (GB)" class _SystemMonitor: _plot_blacklist_prefix: tuple = ( METRIC_CPU_COUNT, METRIC_RAM_TOTAL_GB, METRIC_DISK_TOTAL_GB, METRIC_GPU_COUNT, METRIC_VRAM_TOTAL_GB, ) def __init__( self, live, interval: float, # seconds num_samples: int, directories_to_monitor: dict[str, str], ): self._live = live self._interval = self._check_interval(interval, max_interval=0.1) self._num_samples = self._check_num_samples( num_samples, min_num_samples=1, max_num_samples=30 ) self._disks_to_monitor = self._check_directories_to_monitor( directories_to_monitor ) self._warn_cpu_problem = True self._warn_gpu_problem = True self._warn_disk_doesnt_exist: dict[str, bool] = {} self._shutdown_event = Event() Thread( target=self._monitoring_loop, ).start() def _check_interval(self, interval: float, max_interval: float) -> float: if interval > max_interval: logger.warning( f"System monitoring `interval` should be less than {max_interval} " f"seconds. Setting `interval` to {max_interval} seconds." ) return max_interval return interval def _check_num_samples( self, num_samples: int, min_num_samples: int, max_num_samples: int ) -> int: min_num_samples = 1 max_num_samples = 30 if not min_num_samples < num_samples < max_num_samples: num_samples = max(min(num_samples, max_num_samples), min_num_samples) logger.warning( f"System monitoring `num_samples` should be between {min_num_samples} " f"and {max_num_samples}. Setting `num_samples` to {num_samples}." ) return num_samples def _check_directories_to_monitor( self, directories_to_monitor: dict[str, str] ) -> dict[str, str]: disks_to_monitor = {} for disk_name, disk_path in directories_to_monitor.items(): if disk_name != os.path.normpath(disk_name): raise ValueError( # noqa: TRY003 "Keys for `directories_to_monitor` should be a valid name" f", but got '{disk_name}'." ) disks_to_monitor[disk_name] = disk_path return disks_to_monitor def _monitoring_loop(self): while not self._shutdown_event.is_set(): self._metrics = {} last_metrics = {} for _ in range(self._num_samples): try: last_metrics = self._get_metrics() except psutil.Error: if self._warn_cpu_problem: logger.exception("Failed to monitor CPU metrics") self._warn_cpu_problem = False except NVMLError: if self._warn_gpu_problem: logger.exception("Failed to monitor GPU metrics") self._warn_gpu_problem = False self._metrics = merge_with(sum, self._metrics, last_metrics) self._shutdown_event.wait(self._interval) if self._shutdown_event.is_set(): break for name, values in self._metrics.items(): blacklisted = any( name.startswith(prefix) for prefix in self._plot_blacklist_prefix ) self._live.log_metric( name, values / self._num_samples, timestamp=True, plot=None if blacklisted else True, ) def _get_metrics(self) -> dict[str, Union[float, int]]: return { **self._get_gpu_info(), **self._get_cpu_info(), **self._get_ram_info(), **self._get_disk_info(), } def _get_ram_info(self) -> dict[str, Union[float, int]]: ram_info = psutil.virtual_memory() return { METRIC_RAM_USAGE_PERCENT: ram_info.percent, METRIC_RAM_USAGE_GB: ram_info.used / GIGABYTES_DIVIDER, METRIC_RAM_TOTAL_GB: ram_info.total / GIGABYTES_DIVIDER, } def _get_cpu_info(self) -> dict[str, Union[float, int]]: num_cpus = psutil.cpu_count() cpus_percent = psutil.cpu_percent(percpu=True) return { METRIC_CPU_COUNT: num_cpus, METRIC_CPU_USAGE_PERCENT: mean(cpus_percent), METRIC_CPU_PARALLELIZATION_PERCENT: len( [ percent for percent in cpus_percent if percent >= MINIMUM_CPU_USAGE_TO_BE_ACTIVE ] ) * 100 / num_cpus, } def _get_disk_info(self) -> dict[str, Union[float, int]]: result = {} for disk_name, disk_path in self._disks_to_monitor.items(): try: disk_info = psutil.disk_usage(disk_path) except OSError: if self._warn_disk_doesnt_exist.get(disk_name, True): logger.warning( f"Couldn't find directory '{disk_path}', ignoring it." ) self._warn_disk_doesnt_exist[disk_name] = False continue disk_metrics = { f"{METRIC_DISK_USAGE_PERCENT}/{disk_name}": disk_info.percent, f"{METRIC_DISK_USAGE_GB}/{disk_name}": disk_info.used / GIGABYTES_DIVIDER, f"{METRIC_DISK_TOTAL_GB}/{disk_name}": disk_info.total / GIGABYTES_DIVIDER, } disk_metrics = {k.rstrip("/"): v for k, v in disk_metrics.items()} result.update(disk_metrics) return result def _get_gpu_info(self) -> dict[str, Union[float, int]]: if not GPU_AVAILABLE: return {} nvmlInit() num_gpus = nvmlDeviceGetCount() gpu_metrics = { "system/gpu/count": num_gpus, } for gpu_idx in range(num_gpus): gpu_handle = nvmlDeviceGetHandleByIndex(gpu_idx) memory_info = nvmlDeviceGetMemoryInfo(gpu_handle) usage_info = nvmlDeviceGetUtilizationRates(gpu_handle) gpu_metrics.update( { f"{METRIC_GPU_USAGE_PERCENT}/{gpu_idx}": ( 100 * usage_info.memory / usage_info.gpu if usage_info.gpu else 0 ), f"{METRIC_VRAM_USAGE_PERCENT}/{gpu_idx}": ( 100 * memory_info.used / memory_info.total ), f"{METRIC_VRAM_USAGE_GB}/{gpu_idx}": ( memory_info.used / GIGABYTES_DIVIDER ), f"{METRIC_VRAM_TOTAL_GB}/{gpu_idx}": ( memory_info.total / GIGABYTES_DIVIDER ), } ) nvmlShutdown() return gpu_metrics def end(self): self._shutdown_event.set() ================================================ FILE: src/dvclive/optuna.py ================================================ # ruff: noqa: ARG002 from dvclive import Live class DVCLiveCallback: def __init__(self, metric_name="metric", **kwargs) -> None: kwargs["dir"] = kwargs.get("dir", "dvclive-optuna") kwargs.pop("save_dvc_exp", None) self.metric_name = metric_name self.live_kwargs = kwargs def __call__(self, study, trial) -> None: with Live(**self.live_kwargs) as live: self._log_metrics(trial.values, live) live.log_params(trial.params) def _log_metrics(self, values, live): if values is None: return if isinstance(self.metric_name, str): if len(values) > 1: # Broadcast default name for multi-objective optimization. names = [f"{self.metric_name}_{i}" for i in range(len(values))] else: names = [self.metric_name] elif len(self.metric_name) != len(values): msg = ( "Running multi-objective optimization " f"with {len(values)} objective values, " f"but {len(self.metric_name)} names specified. " "Match objective values and names," "or use default broadcasting." ) raise ValueError(msg) else: names = [*self.metric_name] metrics = dict(zip(names, values)) for k, v in metrics.items(): live.summary[k] = v ================================================ FILE: src/dvclive/plots/__init__.py ================================================ from .custom import CustomPlot from .image import Image from .metric import Metric from .sklearn import Calibration, ConfusionMatrix, Det, PrecisionRecall, Roc from .utils import NumpyEncoder # noqa: F401 SKLEARN_PLOTS = { "calibration": Calibration, "confusion_matrix": ConfusionMatrix, "det": Det, "precision_recall": PrecisionRecall, "roc": Roc, } PLOT_TYPES = (*SKLEARN_PLOTS.values(), Metric, Image, CustomPlot) ================================================ FILE: src/dvclive/plots/base.py ================================================ import abc from pathlib import Path class Data(abc.ABC): def __init__(self, name: str, output_folder: str) -> None: self.name = name self.output_folder: Path = Path(output_folder) / self.subfolder self._step: int = -1 @property def step(self) -> int: return self._step @step.setter def step(self, val: int) -> None: self._step = val @property @abc.abstractmethod def output_path(self) -> Path: pass @property @abc.abstractmethod def subfolder(self): pass @staticmethod @abc.abstractmethod def could_log(val) -> bool: pass @abc.abstractmethod def dump(self, val, **kwargs): pass ================================================ FILE: src/dvclive/plots/custom.py ================================================ from pathlib import Path from typing import Optional, Union from dvclive.serialize import dump_json from .base import Data class CustomPlot(Data): suffixes = (".json",) subfolder = "custom" def __init__( self, name: str, output_folder: str, x: str, y: Union[str, list[str]], template: Optional[str], title: Optional[str] = None, x_label: Optional[str] = None, y_label: Optional[str] = None, ) -> None: super().__init__(name, output_folder) self.name = self.name.replace(".json", "") if not template: template = None config = { "template": template, "x": x, "y": y, "title": title, "x_label": x_label, "y_label": y_label, } self._plot_config = {k: v for k, v in config.items() if v is not None} @property def output_path(self) -> Path: _path = Path(f"{self.output_folder / self.name}.json") _path.parent.mkdir(exist_ok=True, parents=True) return _path @staticmethod def could_log(val: object) -> bool: return isinstance(val, list) and all(isinstance(x, dict) for x in val) @property def plot_config(self): return self._plot_config def dump(self, val, **kwargs) -> None: # noqa: ARG002 dump_json(val, self.output_path) ================================================ FILE: src/dvclive/plots/image.py ================================================ from pathlib import Path, PurePath from dvclive.utils import isinstance_without_import from .base import Data class Image(Data): suffixes = (".jpg", ".jpeg", ".gif", ".png") subfolder = "images" @property def output_path(self) -> Path: _path = self.output_folder / self.name _path.parent.mkdir(exist_ok=True, parents=True) return _path @staticmethod def could_log(val: object) -> bool: acceptable = { ("numpy", "ndarray"), ("matplotlib.figure", "Figure"), ("PIL.Image", "Image"), } for cls in type(val).mro(): if any(isinstance_without_import(val, *cls) for cls in acceptable): return True return isinstance(val, (PurePath, str)) def dump(self, val, **kwargs) -> None: # noqa: ARG002 if isinstance_without_import(val, "numpy", "ndarray"): from PIL import Image as ImagePIL ImagePIL.fromarray(val).save(self.output_path) elif isinstance_without_import(val, "matplotlib.figure", "Figure"): import matplotlib.pyplot as plt val.savefig(self.output_path) plt.close(val) elif isinstance_without_import(val, "PIL.Image", "Image"): val.save(self.output_path) ================================================ FILE: src/dvclive/plots/metric.py ================================================ import csv import os import time from pathlib import Path from .base import Data from .utils import NUMPY_SCALARS class Metric(Data): suffixes = (".csv", ".tsv") subfolder = "metrics" @staticmethod def could_log(val: object) -> bool: if isinstance(val, (int, float, str)): return True return ( val.__class__.__module__ == "numpy" and val.__class__.__name__ in NUMPY_SCALARS ) @property def output_path(self) -> Path: _path = Path(f"{self.output_folder / self.name}.tsv") _path.parent.mkdir(exist_ok=True, parents=True) return _path def dump(self, val, **kwargs) -> None: row = {} if kwargs.get("timestamp", False): row["timestamp"] = int(time.time() * 1000) row["step"] = self.step row[os.path.basename(self.name)] = val existed = self.output_path.exists() with open(self.output_path, "a", encoding="utf-8", newline="") as fobj: writer = csv.DictWriter( fobj, row.keys(), delimiter="\t", lineterminator=os.linesep ) if not existed: writer.writeheader() writer.writerow(row) @property def summary_keys(self) -> list[str]: return os.path.normpath(self.name).split(os.path.sep) ================================================ FILE: src/dvclive/plots/sklearn.py ================================================ from dvclive.serialize import dump_json from .custom import CustomPlot class SKLearnPlot(CustomPlot): subfolder = "sklearn" @staticmethod def could_log(val: object) -> bool: return isinstance(val, tuple) and len(val) == 2 # noqa: PLR2004 class Roc(SKLearnPlot): def __init__(self, name: str, output_folder: str, **plot_config) -> None: plot_config["template"] = plot_config.get("template", "simple") plot_config["title"] = plot_config.get( "title", "Receiver operating characteristic (ROC)" ) plot_config["x_label"] = plot_config.get("x_label", "False Positive Rate") plot_config["y_label"] = plot_config.get("y_label", "True Positive Rate") plot_config["x"] = "fpr" plot_config["y"] = "tpr" super().__init__(name, output_folder, **plot_config) def dump(self, val, **kwargs) -> None: from sklearn import metrics fpr, tpr, roc_thresholds = metrics.roc_curve( y_true=val[0], y_score=val[1], **kwargs ) roc = { "roc": [ {"fpr": fp, "tpr": tp, "threshold": t} for fp, tp, t in zip(fpr, tpr, roc_thresholds) ] } dump_json(roc, self.output_path) class PrecisionRecall(SKLearnPlot): def __init__(self, name: str, output_folder: str, **plot_config) -> None: plot_config["template"] = plot_config.get("template", "simple") plot_config["title"] = plot_config.get("title", "Precision-Recall Curve") plot_config["x_label"] = plot_config.get("x_label", "Recall") plot_config["y_label"] = plot_config.get("y_label", "Precision") plot_config["x"] = "recall" plot_config["y"] = "precision" super().__init__(name, output_folder, **plot_config) def dump(self, val, **kwargs) -> None: from sklearn import metrics precision, recall, prc_thresholds = metrics.precision_recall_curve( y_true=val[0], y_score=val[1], **kwargs ) prc = { "precision_recall": [ {"precision": p, "recall": r, "threshold": t} for p, r, t in zip(precision, recall, prc_thresholds) ] } dump_json(prc, self.output_path) class Det(SKLearnPlot): def __init__(self, name: str, output_folder: str, **plot_config) -> None: plot_config["template"] = plot_config.get("template", "simple") plot_config["title"] = plot_config.get( "title", "Detection error tradeoff (DET)" ) plot_config["x_label"] = plot_config.get("x_label", "False Positive Rate") plot_config["y_label"] = plot_config.get("y_label", "False Negative Rate") plot_config["x"] = "fpr" plot_config["y"] = "fnr" super().__init__(name, output_folder, **plot_config) def dump(self, val, **kwargs) -> None: from sklearn import metrics fpr, fnr, roc_thresholds = metrics.det_curve( y_true=val[0], y_score=val[1], **kwargs ) det = { "det": [ {"fpr": fp, "fnr": fn, "threshold": t} for fp, fn, t in zip(fpr, fnr, roc_thresholds) ] } dump_json(det, self.output_path) class ConfusionMatrix(SKLearnPlot): def __init__(self, name: str, output_folder: str, **plot_config) -> None: plot_config["template"] = ( "confusion_normalized" if plot_config.pop("normalized", None) else plot_config.get("template", "confusion") ) plot_config["title"] = plot_config.get("title", "Confusion Matrix") plot_config["x_label"] = plot_config.get("x_label", "True Label") plot_config["y_label"] = plot_config.get("y_label", "Predicted Label") plot_config["x"] = "actual" plot_config["y"] = "predicted" super().__init__(name, output_folder, **plot_config) def dump(self, val, **kwargs) -> None: # noqa: ARG002 cm = [ {"actual": str(actual), "predicted": str(predicted)} for actual, predicted in zip(val[0], val[1]) ] dump_json(cm, self.output_path) class Calibration(SKLearnPlot): def __init__(self, name: str, output_folder: str, **plot_config) -> None: plot_config["template"] = plot_config.get("template", "simple") plot_config["title"] = plot_config.get("title", "Calibration Curve") plot_config["x_label"] = plot_config.get( "x_label", "Mean Predicted Probability" ) plot_config["y_label"] = plot_config.get("y_label", "Fraction of Positives") plot_config["x"] = "prob_pred" plot_config["y"] = "prob_true" super().__init__(name, output_folder, **plot_config) def dump(self, val, **kwargs) -> None: from sklearn import calibration prob_true, prob_pred = calibration.calibration_curve( y_true=val[0], y_prob=val[1], **kwargs ) _calibration = { "calibration": [ {"prob_true": pt, "prob_pred": pp} for pt, pp in zip(prob_true, prob_pred) ] } dump_json(_calibration, self.output_path) ================================================ FILE: src/dvclive/plots/utils.py ================================================ import json NUMPY_INTS = [ "intc", "intp", "int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64", ] NUMPY_FLOATS = ["float16", "float32", "float64"] NUMPY_SCALARS = NUMPY_INTS + NUMPY_FLOATS class NumpyEncoder(json.JSONEncoder): def default(self, o): if o.__class__.__module__ == "numpy": if o.__class__.__name__ in NUMPY_INTS: return int(o) if o.__class__.__name__ in NUMPY_FLOATS: return float(o) return super().default(o) ================================================ FILE: src/dvclive/py.typed ================================================ ================================================ FILE: src/dvclive/report.py ================================================ # ruff: noqa: SLF001 import base64 import json from pathlib import Path from typing import TYPE_CHECKING from dvc_render.html import render_html from dvc_render.image import ImageRenderer from dvc_render.markdown import render_markdown from dvc_render.table import TableRenderer from dvc_render.vega import VegaRenderer from dvclive.error import InvalidReportModeError from dvclive.plots import SKLEARN_PLOTS, CustomPlot, Image, Metric from dvclive.plots.sklearn import SKLearnPlot from dvclive.serialize import load_yaml from dvclive.utils import parse_tsv if TYPE_CHECKING: from dvclive import Live BLANK_NOTEBOOK_REPORT = """
DVCLive Report
""" def get_scalar_renderers(metrics_path): renderers = [] for suffix in Metric.suffixes: for file in metrics_path.rglob(f"*{suffix}"): data = parse_tsv(file) for row in data: row["rev"] = "workspace" name = file.relative_to(metrics_path.parent).with_suffix("") name = name.as_posix() title = name.replace(metrics_path.name, "").strip("/") name = name.replace(metrics_path.name, "static") properties = {"x": "step", "y": file.stem, "title": title} renderers.append(VegaRenderer(data, name, **properties)) return renderers def get_image_renderers(images_folder): renderers = [] for suffix in Image.suffixes: all_images = Path(images_folder).rglob(f"*{suffix}") for file in sorted(all_images): base64_str = base64.b64encode(file.read_bytes()).decode() src = f"data:image;base64,{base64_str}" name = str(file.relative_to(images_folder)) data = [ { ImageRenderer.SRC_FIELD: src, ImageRenderer.TITLE_FIELD: name, } ] renderers.append(ImageRenderer(data, name)) return renderers def get_custom_plot_renderers(plots_folder, live): renderers = [] for suffix in CustomPlot.suffixes: for file in Path(plots_folder).rglob(f"*{suffix}"): name = file.relative_to(plots_folder).with_suffix("").as_posix() logged_plot = live._plots[name] properties = logged_plot.plot_config data = json.loads(file.read_text()) for row in data: row["rev"] = "workspace" renderers.append(VegaRenderer(data, name, **properties)) return renderers def get_sklearn_plot_renderers(plots_folder, live): renderers = [] for suffix in SKLearnPlot.suffixes: for file in Path(plots_folder).rglob(f"*{suffix}"): name = file.relative_to(plots_folder).with_suffix("").as_posix() properties = {} logged_plot = live._plots[name] for default_name, plot_class in SKLEARN_PLOTS.items(): if isinstance(logged_plot, plot_class): properties = logged_plot.plot_config data_field = default_name break data = json.loads(file.read_text()) if data_field in data: data = data[data_field] for row in data: row["rev"] = "workspace" renderers.append(VegaRenderer(data, name, **properties)) return renderers def get_metrics_renderers(dvclive_summary): metrics_path = Path(dvclive_summary) if metrics_path.exists(): return [ TableRenderer( [json.loads(metrics_path.read_text(encoding="utf-8"))], metrics_path.name, ) ] return [] def get_params_renderers(dvclive_params): params_path = Path(dvclive_params) if params_path.exists(): return [ TableRenderer( [load_yaml(params_path)], params_path.name, ) ] return [] def make_report(live: "Live"): plots_path = Path(live.plots_dir) renderers = [] renderers.extend(get_params_renderers(live.params_file)) renderers.extend(get_metrics_renderers(live.metrics_file)) renderers.extend(get_scalar_renderers(plots_path / Metric.subfolder)) renderers.extend(get_image_renderers(plots_path / Image.subfolder)) renderers.extend( get_sklearn_plot_renderers(plots_path / SKLearnPlot.subfolder, live) ) renderers.extend(get_custom_plot_renderers(plots_path / CustomPlot.subfolder, live)) if live._report_mode == "html": render_html(renderers, live.report_file, refresh_seconds=5) elif live._report_mode == "notebook": from IPython.display import Markdown md = render_markdown(renderers) if live._report_notebook is not None: new_report = Markdown(md) # type: ignore [assignment] live._report_notebook.update(new_report) elif live._report_mode == "md": render_markdown(renderers, live.report_file) else: raise InvalidReportModeError(live._report_mode) ================================================ FILE: src/dvclive/serialize.py ================================================ import json import os from collections import OrderedDict from dvclive.error import DvcLiveError class YAMLError(DvcLiveError): pass class YAMLFileCorruptedError(YAMLError): def __init__(self, path): super().__init__(path, "YAML file structure is corrupted") def load_yaml(path, typ="safe"): from ruamel.yaml import YAML from ruamel.yaml import YAMLError as _YAMLError yaml = YAML(typ=typ) with open(path, encoding="utf-8") as fd: try: return yaml.load(fd.read()) except _YAMLError: raise YAMLFileCorruptedError(path) from _YAMLError def get_yaml(): from ruamel.yaml import YAML yaml = YAML() yaml.default_flow_style = False # tell Dumper to represent OrderedDict as normal dict yaml_repr_cls = yaml.Representer yaml_repr_cls.add_representer(OrderedDict, yaml_repr_cls.represent_dict) return yaml def dump_yaml(content, output_file): yaml = get_yaml() make_dir(output_file) with open(output_file, "w", encoding="utf-8") as fd: yaml.dump(content, fd) def dump_json(content, output_file, indent=4, **kwargs): make_dir(output_file) with open(output_file, "w", encoding="utf-8") as f: json.dump(content, f, indent=indent, **kwargs) f.write("\n") def make_dir(output_file): output_dir = os.path.dirname(output_file) if output_dir: os.makedirs(output_dir, exist_ok=True) ================================================ FILE: src/dvclive/studio.py ================================================ # ruff: noqa: SLF001 import base64 import logging import math import os from collections.abc import Mapping from pathlib import PureWindowsPath from typing import TYPE_CHECKING, Any, Literal, Optional from dvc.exceptions import DvcException from dvc_studio_client.config import get_studio_config from dvc_studio_client.post_live_metrics import post_live_metrics from dvclive.utils import StrPath, rel_path from .utils import catch_and_warn if TYPE_CHECKING: from dvclive.live import Live from dvclive.plots.image import Image logger = logging.getLogger("dvclive") def _cast_to_numbers(datapoints: Mapping): for datapoint in datapoints: for k, v in datapoint.items(): if k == "step": datapoint[k] = int(v) elif k == "timestamp": continue else: float_v = float(v) if math.isnan(float_v) or math.isinf(float_v): datapoint[k] = str(v) else: datapoint[k] = float_v return datapoints def _adapt_path(live: "Live", name: StrPath): if live._dvc_repo is not None: name = rel_path(name, live._dvc_repo.root_dir) if os.name == "nt": name = str(PureWindowsPath(name).as_posix()) return name def _adapt_image(image_path: StrPath): with open(image_path, "rb") as fobj: return base64.b64encode(fobj.read()).decode("utf-8") def _adapt_images(live: "Live", images: "list[Image]"): return { _adapt_path(live, image.output_path): {"image": _adapt_image(image.output_path)} for image in images if image.step > live._latest_studio_step } def _get_studio_updates(live: "Live", data: dict[str, Any]): params = data["params"] plots = data["plots"] plots_start_idx = data["plots_start_idx"] metrics = data["metrics"] images = data["images"] params_file = live.params_file params_file = _adapt_path(live, params_file) params = {params_file: params} metrics_file = live.metrics_file metrics_file = _adapt_path(live, metrics_file) metrics = {metrics_file: {"data": metrics}} plots_to_send = {} for name, plot in plots.items(): path = _adapt_path(live, name) start_idx = plots_start_idx.get(name, 0) num_points_sent = live._num_points_sent_to_studio.get(name, 0) plots_to_send[path] = _cast_to_numbers(plot[num_points_sent - start_idx :]) plots_to_send = {k: {"data": v} for k, v in plots_to_send.items()} plots_to_send.update(_adapt_images(live, images)) return metrics, params, plots_to_send def get_dvc_studio_config(live: "Live"): config = {} if live._dvc_repo: config = live._dvc_repo.config.get("studio") return get_studio_config(dvc_studio_config=config) def increment_num_points_sent_to_studio(live, plots_sent, data): for name in data["plots"]: path = _adapt_path(live, name) plot = plots_sent.get(path, {}) if "data" in plot: num_points_sent = live._num_points_sent_to_studio.get(name, 0) live._num_points_sent_to_studio[name] = num_points_sent + len(plot["data"]) return live @catch_and_warn(DvcException, logger) def post_to_studio( # noqa: C901 live: "Live", event: Literal["start", "data", "done"], data: Optional[dict[str, Any]] = None, ): if event in live._studio_events_to_skip: return kwargs = {} if event == "start": if message := live._exp_message: kwargs["message"] = message if subdir := live._subdir: kwargs["subdir"] = subdir elif event == "data": assert data is not None # noqa: S101 metrics, params, plots = _get_studio_updates(live, data) kwargs["step"] = data["step"] kwargs["metrics"] = metrics kwargs["params"] = params kwargs["plots"] = plots elif event == "done" and live._experiment_rev: kwargs["experiment_rev"] = live._experiment_rev response = post_live_metrics( event, live._baseline_rev, live._exp_name, # type: ignore[arg-type] "dvclive", dvc_studio_config=live._dvc_studio_config, studio_repo_url=live._repo_url, **kwargs, # type: ignore[arg-type] ) if not response: logger.warning(f"`post_to_studio` `{event}` failed.") if event == "start": live._studio_events_to_skip.add("start") live._studio_events_to_skip.add("data") live._studio_events_to_skip.add("done") elif event == "data": assert data is not None # noqa: S101 live = increment_num_points_sent_to_studio(live, plots, data) live._latest_studio_step = data["step"] if event == "done": live._studio_events_to_skip.add("done") live._studio_events_to_skip.add("data") ================================================ FILE: src/dvclive/utils.py ================================================ import csv import json import os import re import shutil import webbrowser from pathlib import Path, PurePath from platform import uname from typing import TYPE_CHECKING, Union from .error import InvalidDataTypeError if TYPE_CHECKING: import numpy as np import pandas as pd else: try: import pandas as pd except ImportError: pd = None try: import numpy as np except ImportError: np = None StrPath = Union[str, PurePath] def run_once(f): def wrapper(*args, **kwargs): if not wrapper.has_run: wrapper.has_run = True return f(*args, **kwargs) return None wrapper.has_run = False return wrapper @run_once def open_file_in_browser(file) -> bool: path = Path(file) url = str(path) if "Microsoft" in uname().release else path.resolve().as_uri() return webbrowser.open(url) def env2bool(var, undefined=False): """ undefined: return value if env var is unset """ var = os.getenv(var, None) if var is None: return undefined return bool(re.search("1|y|yes|true", var, flags=re.IGNORECASE)) def standardize_metric_name(metric_name: str, framework: str) -> str: """Map framework-specific format to DVCLive standard. Use `{split}/` as prefix in order to separate by subfolders. Use `{train|eval}` as split name. """ if framework == "dvclive.fastai": metric_name = metric_name.replace("train_", "train/") metric_name = metric_name.replace("valid_", "eval/") elif framework == "dvclive.huggingface": for split in ("train", "eval"): metric_name = metric_name.replace(f"{split}_", f"{split}/") elif framework == "dvclive.keras": if "val_" in metric_name: metric_name = metric_name.replace("val_", "eval/") else: metric_name = f"train/{metric_name}" elif framework in ["dvclive.lightning", "dvclive.fabric"]: parts = metric_name.split("_") split, freq, rest = None, None, None if any(parts[0].endswith(split) for split in ["train", "val", "test"]): split = parts.pop(0) # Only set freq if split was also found. # Otherwise we end up conflicting with out internal `step` property. if parts[-1] in ["step", "epoch"]: freq = parts.pop() rest = "_".join(parts) parts = [part for part in (split, freq, rest) if part] metric_name = "/".join(parts) return metric_name def parse_tsv(path): with open(path, encoding="utf-8", newline="") as fd: reader = csv.DictReader(fd, delimiter="\t") return list(reader) def parse_json(path): with open(path, encoding="utf-8") as fd: return json.load(fd) def parse_metrics(live): from .plots import Metric metrics_path = Path(live.plots_dir) / Metric.subfolder history = {} for suffix in Metric.suffixes: for scalar_file in metrics_path.rglob(f"*{suffix}"): history[str(scalar_file)] = parse_tsv(scalar_file) latest = parse_json(live.metrics_file) return history, latest def matplotlib_installed() -> bool: try: import matplotlib as mpl # noqa: F401 except ImportError: return False return True def inside_colab() -> bool: try: from google import colab # noqa: F401 except ImportError: return False return True def inside_notebook() -> bool: if inside_colab(): return True try: shell = get_ipython().__class__.__name__ # type: ignore[name-defined] except NameError: return False if shell == "ZMQInteractiveShell": import IPython return IPython.__version__ >= "6.0.0" return False def clean_and_copy_into(src: StrPath, dst: StrPath) -> str: Path(dst).mkdir(exist_ok=True) basename = os.path.basename(os.path.normpath(src)) dst_path = Path(os.path.join(dst, basename)) if dst_path.is_file() or dst_path.is_symlink(): dst_path.unlink() elif dst_path.is_dir(): shutil.rmtree(dst_path) if os.path.isdir(src): shutil.copytree(src, dst_path) else: shutil.copy2(src, dst_path) return str(dst_path) def isinstance_without_import(val, module, name): for cls in type(val).mro(): if (cls.__module__, cls.__name__) == (module, name): return True return False def catch_and_warn(exception, logger, on_finally=None): def decorator(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except exception as e: logger.warning(f"Error in {func.__name__}: {e}") finally: if on_finally is not None: on_finally() return wrapper return decorator def rel_path(path, dvc_root_path): absolute_path = Path(path).absolute() return str(Path(os.path.relpath(absolute_path, dvc_root_path)).as_posix()) def read_history(live, metric): from dvclive.plots.metric import Metric history, _ = parse_metrics(live) steps = [] values = [] name = os.path.join(live.plots_dir, Metric.subfolder, f"{metric}.tsv") for e in history[name]: steps.append(int(e["step"])) values.append(float(e[metric])) return steps, values def read_latest(live, metric_name): _, latest = parse_metrics(live) return latest["step"], latest[metric_name] def convert_datapoints_to_list_of_dicts( datapoints: Union[list[dict], "pd.DataFrame", "np.ndarray"], ) -> list[dict]: """ Convert the given datapoints to a list of dictionaries. Args: datapoints: The input datapoints to be converted. Returns: A list of dictionaries representing the datapoints. Raises: TypeError: `datapoints` must be pd.DataFrame, np.ndarray, or List[Dict] """ if isinstance(datapoints, list): return datapoints if pd and isinstance(datapoints, pd.DataFrame): return datapoints.to_dict(orient="records") if np and isinstance(datapoints, np.ndarray): # This is a structured array if datapoints.dtype.names is not None: return [dict(zip(datapoints.dtype.names, row)) for row in datapoints] # This is a regular array return [dict(enumerate(row)) for row in datapoints] # Raise an error if the input is not a supported type raise InvalidDataTypeError("datapoints", type(datapoints)) ================================================ FILE: src/dvclive/vscode.py ================================================ import json import os from typing import Optional, Union from dvclive.dvc import _find_dvc_root from dvclive.utils import StrPath from . import env def _dvc_exps_run_dir(dirname: StrPath) -> str: return os.path.join(dirname, ".dvc", "tmp", "exps", "run") def _dvclive_only_signal_file(root_dir: StrPath) -> str: dvc_exps_run_dir = _dvc_exps_run_dir(root_dir) return os.path.join(dvc_exps_run_dir, "DVCLIVE_ONLY") def _dvclive_step_completed_signal_file(root_dir: StrPath) -> str: dvc_exps_run_dir = _dvc_exps_run_dir(root_dir) return os.path.join(dvc_exps_run_dir, "DVCLIVE_STEP_COMPLETED") def _find_non_queue_root() -> Optional[str]: return os.getenv(env.DVC_ROOT) or _find_dvc_root() def _write_file(file: str, contents: dict[str, Union[str, int]]): import builtins with builtins.open(file, "w", encoding="utf-8") as fobj: # NOTE: force flushing/writing empty file to disk, otherwise when # run in certain contexts (pytest) file may not actually be written fobj.write(json.dumps(contents, sort_keys=True, ensure_ascii=False)) fobj.flush() os.fsync(fobj.fileno()) def mark_dvclive_step_completed(step: int) -> None: """ https://github.com/iterative/vscode-dvc/issues/4528 Signal DVC VS Code extension that a step has been completed for an experiment running in the queue """ non_queue_root_dir = _find_non_queue_root() if not non_queue_root_dir: return exp_run_dir = _dvc_exps_run_dir(non_queue_root_dir) os.makedirs(exp_run_dir, exist_ok=True) signal_file = _dvclive_step_completed_signal_file(non_queue_root_dir) _write_file(signal_file, {"pid": os.getpid(), "step": step}) def cleanup_dvclive_step_completed() -> None: non_queue_root_dir = _find_non_queue_root() if not non_queue_root_dir: return signal_file = _dvclive_step_completed_signal_file(non_queue_root_dir) if not os.path.exists(signal_file): return os.remove(signal_file) def mark_dvclive_only_started(exp_name: str) -> None: """ Signal DVC VS Code extension that an experiment is running in the workspace. """ root_dir = _find_dvc_root() if not root_dir: return exp_run_dir = _dvc_exps_run_dir(root_dir) os.makedirs(exp_run_dir, exist_ok=True) signal_file = _dvclive_only_signal_file(root_dir) _write_file(signal_file, {"pid": os.getpid(), "exp_name": exp_name}) def mark_dvclive_only_ended() -> None: root_dir = _find_dvc_root() if not root_dir: return signal_file = _dvclive_only_signal_file(root_dir) if not os.path.exists(signal_file): return os.remove(signal_file) ================================================ FILE: src/dvclive/xgb.py ================================================ # ruff: noqa: ARG002 from typing import Optional from warnings import warn from xgboost.callback import TrainingCallback from dvclive import Live class DVCLiveCallback(TrainingCallback): def __init__( self, metric_data: Optional[str] = None, live: Optional[Live] = None, **kwargs, ): super().__init__() if metric_data is not None: warn( "`metric_data` is deprecated and will be removed", category=DeprecationWarning, stacklevel=2, ) self._metric_data = metric_data self.live = live if live is not None else Live(**kwargs) def after_iteration(self, model, epoch, evals_log): if self._metric_data: evals_log = {"": evals_log[self._metric_data]} for subdir, data in evals_log.items(): for key, values in data.items(): self.live.log_metric(f"{subdir}/{key}" if subdir else key, values[-1]) self.live.next_step() def after_training(self, model): self.live.end() return model ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ import sys import pytest from dvc_studio_client.env import DVC_STUDIO_TOKEN, DVC_STUDIO_URL, STUDIO_REPO_URL from dvclive.utils import rel_path @pytest.fixture def tmp_dir(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) return tmp_path @pytest.fixture def mocked_dvc_repo(tmp_dir, mocker): _dvc_repo = mocker.MagicMock() _dvc_repo.index.stages = [] _dvc_repo.scm.get_rev.return_value = "f" * 40 _dvc_repo.scm.get_ref.return_value = None _dvc_repo.scm.no_commits = False _dvc_repo.experiments.save.return_value = "e" * 40 _dvc_repo.root_dir = _dvc_repo.scm.root_dir = tmp_dir _dvc_repo.fs.relpath = rel_path _dvc_repo.config = {} mocker.patch("dvclive.live.get_dvc_repo", return_value=_dvc_repo) return _dvc_repo @pytest.fixture def mocked_dvc_subrepo(tmp_dir, mocker, mocked_dvc_repo): mocked_dvc_repo.root_dir = tmp_dir / "subdir" return mocked_dvc_repo @pytest.fixture def dvc_repo(tmp_dir): from dvc.repo import Repo from scmrepo.git import Git Git.init(tmp_dir) repo = Repo.init(tmp_dir) repo.scm.add_commit(".", "init") return repo @pytest.fixture(autouse=True) def _capture_wrap(): # https://github.com/pytest-dev/pytest/issues/5502#issuecomment-678368525 sys.stderr.close = lambda *args: None sys.stdout.close = lambda *args: None @pytest.fixture(autouse=True) def _mocked_webbrowser_open(mocker): mocker.patch("webbrowser.open") @pytest.fixture(autouse=True) def _mocked_ci(monkeypatch): monkeypatch.setenv("CI", "false") @pytest.fixture def mocked_studio_post(mocker, monkeypatch): valid_response = mocker.MagicMock() valid_response.status_code = 200 mocked_post = mocker.patch("requests.post", return_value=valid_response) monkeypatch.setenv(DVC_STUDIO_URL, "https://0.0.0.0") monkeypatch.setenv(STUDIO_REPO_URL, "STUDIO_REPO_URL") monkeypatch.setenv(DVC_STUDIO_TOKEN, "STUDIO_TOKEN") return mocked_post, valid_response ================================================ FILE: tests/frameworks/test_fabric.py ================================================ from argparse import Namespace from unittest.mock import Mock import numpy as np import pytest try: import torch from dvclive.fabric import DVCLiveLogger except ImportError: pytest.skip("skipping lightning tests", allow_module_level=True) class BoringModel(torch.nn.Module): def __init__(self): super().__init__() self.layer = torch.nn.Linear(32, 2, bias=False) def forward(self, x): x = self.layer(x) return torch.nn.functional.mse_loss(x, torch.ones_like(x)) @pytest.mark.parametrize("step_idx", [10, None]) def test_dvclive_log_metrics(tmp_path, mocked_dvc_repo, step_idx): logger = DVCLiveLogger(dir=tmp_path) metrics = { "float": 0.3, "int": 1, "FloatTensor": torch.tensor(0.1), "IntTensor": torch.tensor(1), } logger.log_metrics(metrics, step_idx) def test_dvclive_log_hyperparams(tmp_path, mocked_dvc_repo): logger = DVCLiveLogger(dir=tmp_path) hparams = { "float": 0.3, "int": 1, "string": "abc", "bool": True, "dict": {"a": {"b": "c"}}, "list": [1, 2, 3], "namespace": Namespace(foo=Namespace(bar="buzz")), "layer": torch.nn.BatchNorm1d, "tensor": torch.empty(2, 2, 2), "array": np.empty([2, 2, 2]), } logger.log_hyperparams(hparams) def test_dvclive_finalize(monkeypatch, tmp_path, mocked_dvc_repo): """Test that the SummaryWriter closes in finalize.""" import dvclive monkeypatch.setattr(dvclive, "Live", Mock()) logger = DVCLiveLogger(dir=tmp_path) assert logger._experiment is None logger.finalize("any") # no log calls, no experiment created -> nothing to flush logger.experiment.assert_not_called() logger = DVCLiveLogger(dir=tmp_path) logger.log_hyperparams({"flush_me": 11.1}) # trigger creation of an experiment logger.finalize("any") # finalize flushes to experiment directory logger.experiment.end.assert_called() ================================================ FILE: tests/frameworks/test_fastai.py ================================================ import os import pytest from dvclive import Live from dvclive.plots.metric import Metric try: from fastai.callback.tracker import SaveModelCallback from fastai.tabular.all import ( Categorify, Normalize, ProgressCallback, TabularDataLoaders, accuracy, tabular_learner, ) from dvclive.fastai import DVCLiveCallback except ImportError: pytest.skip("skipping fastai tests", allow_module_level=True) @pytest.fixture def data_loader(): from pandas import DataFrame d = { "x1": [1, 1, 0, 0, 1, 1, 0, 0], "x2": [1, 0, 1, 0, 1, 0, 1, 0], "y": [1, 0, 0, 1, 1, 0, 0, 1], } df = DataFrame(d) return TabularDataLoaders.from_df( df, valid_idx=[4, 5, 6, 7], batch_size=2, cont_names=["x1", "x2"], procs=[Categorify, Normalize], y_names="y", ) def test_fastai_callback(tmp_dir, data_loader, mocker): learn = tabular_learner(data_loader, metrics=accuracy) learn.remove_cb(ProgressCallback) callback = DVCLiveCallback() live = callback.live spy = mocker.spy(live, "end") learn.fit_one_cycle(2, cbs=[callback]) spy.assert_called_once() assert (tmp_dir / live.dir).exists() assert (tmp_dir / live.params_file).exists() assert (tmp_dir / live.params_file).read_text() == ( "model: TabularModel\nbatch_size: 2\nbatch_per_epoch: 2\nfrozen: false" "\nfrozen_idx: 0\ntransforms: None\n" ) metrics_path = tmp_dir / live.plots_dir / Metric.subfolder train_path = metrics_path / "train" valid_path = metrics_path / "eval" assert train_path.is_dir() assert valid_path.is_dir() assert (metrics_path / "accuracy.tsv").exists() assert not (metrics_path / "epoch.tsv").exists() def test_fastai_pass_logger(): logger = Live("train_logs") assert DVCLiveCallback().live is not logger assert DVCLiveCallback(live=logger).live is logger def test_fast_ai_resume(tmp_dir, data_loader, mocker): learn = tabular_learner(data_loader, metrics=accuracy) learn.remove_cb(ProgressCallback) callback = DVCLiveCallback() live = callback.live spy = mocker.spy(live, "next_step") end = mocker.spy(live, "end") learn.fit_one_cycle(2, cbs=[callback]) assert spy.call_count == 2 assert end.call_count == 1 callback = DVCLiveCallback(resume=True) live = callback.live spy = mocker.spy(live, "next_step") learn.fit_one_cycle(3, cbs=[callback], start_epoch=live.step) assert spy.call_count == 1 def test_fast_ai_avoid_unnecessary_end_calls(tmp_dir, data_loader, mocker): """ `after_fit` might be called from different points and not all mean that the training has ended. """ learn = tabular_learner(data_loader, metrics=accuracy) learn.remove_cb(ProgressCallback) callback = DVCLiveCallback() live = callback.live end = mocker.spy(live, "end") after_fit = mocker.spy(callback, "after_fit") learn.fine_tune(2, cbs=[callback]) assert end.call_count == 1 assert after_fit.call_count == 2 def test_fastai_save_model_callback(tmp_dir, data_loader, mocker): learn = tabular_learner(data_loader, metrics=accuracy) learn.remove_cb(ProgressCallback) learn.model_dir = os.path.abspath("./") save_callback = SaveModelCallback() live_callback = DVCLiveCallback() log_artifact = mocker.patch.object(live_callback.live, "log_artifact") learn.fit_one_cycle(2, cbs=[save_callback, live_callback]) assert (tmp_dir / "model.pth").is_file() log_artifact.assert_called_with(str(save_callback.last_saved_path)) ================================================ FILE: tests/frameworks/test_huggingface.py ================================================ import os import pytest from dvclive import Live from dvclive.plots.metric import Metric from dvclive.serialize import load_yaml from dvclive.utils import parse_metrics try: import numpy as np import torch from torch import nn from transformers import ( PretrainedConfig, PreTrainedModel, Trainer, TrainingArguments, ) from transformers.integrations import DVCLiveCallback as ExternalCallback from dvclive.huggingface import DVCLiveCallback as InternalCallback except ImportError: pytest.skip("skipping huggingface tests", allow_module_level=True) def compute_metrics(eval_preds): """https://github.com/iterative/dvclive/pull/321#issuecomment-1266916039""" import time time.sleep(time.get_clock_info("time").resolution) return {"foo": 1} # From transformers/tests/trainer class RegressionDataset: def __init__(self, a=2, b=3, length=64, seed=42, label_names=None): np.random.seed(seed) self.label_names = ["labels"] if label_names is None else label_names self.length = length self.x = np.random.normal(size=(length,)).astype(np.float32) self.ys = [ a * self.x + b + np.random.normal(scale=0.1, size=(length,)) for _ in self.label_names ] self.ys = [y.astype(np.float32) for y in self.ys] def __len__(self): return self.length def __getitem__(self, i): result = {name: y[i] for name, y in zip(self.label_names, self.ys)} result["input_x"] = self.x[i] return result class RegressionModelConfig(PretrainedConfig): def __init__(self, a=0, b=0, double_output=False, random_torch=True, **kwargs): super().__init__(**kwargs) self.a = a self.b = b self.double_output = double_output self.random_torch = random_torch self.hidden_size = 1 class RegressionPreTrainedModel(PreTrainedModel): config_class = RegressionModelConfig # type: ignore[assignment] base_model_prefix = "regression" def __init__(self, config): super().__init__(config) self.a = nn.Parameter(torch.tensor(config.a).float()) self.b = nn.Parameter(torch.tensor(config.b).float()) self.double_output = config.double_output def forward(self, input_x, labels=None, **kwargs): y = input_x * self.a + self.b if labels is None: return (y, y) if self.double_output else (y,) loss = nn.functional.mse_loss(y, labels) return (loss, y, y) if self.double_output else (loss, y) @pytest.fixture def data(): return RegressionDataset(), RegressionDataset() @pytest.fixture def model(): config = RegressionModelConfig() return RegressionPreTrainedModel(config) @pytest.fixture def args(): return TrainingArguments( "foo", eval_strategy="epoch", num_train_epochs=2, save_strategy="epoch", report_to="none", # Disable auto-reporting to avoid duplication use_cpu=True, ) @pytest.mark.parametrize("callback", [ExternalCallback, InternalCallback]) def test_huggingface_integration(tmp_dir, model, args, data, mocker, callback): trainer = Trainer( model, args, train_dataset=data[0], eval_dataset=data[1], compute_metrics=compute_metrics, ) callback = callback() spy = mocker.spy(Live, "end") trainer.add_callback(callback) trainer.train() spy.assert_called_once() live = callback.live assert os.path.exists(live.dir) logs, _ = parse_metrics(live) scalars = os.path.join(live.plots_dir, Metric.subfolder) assert os.path.join(scalars, "eval", "foo.tsv") in logs assert os.path.join(scalars, "eval", "loss.tsv") in logs assert os.path.join(scalars, "train", "loss.tsv") in logs assert len(logs[os.path.join(scalars, "epoch.tsv")]) == 3 assert len(logs[os.path.join(scalars, "eval", "loss.tsv")]) == 2 params = load_yaml(live.params_file) assert params["num_train_epochs"] == 2 @pytest.mark.parametrize("log_model", ["all", True, False, None]) @pytest.mark.parametrize("best", [True, False]) @pytest.mark.parametrize("callback", [ExternalCallback, InternalCallback]) def test_huggingface_log_model( tmp_dir, mocked_dvc_repo, model, data, args, monkeypatch, mocker, log_model, best, callback, ): live = Live() log_artifact = mocker.patch.object(live, "log_artifact") if callback == ExternalCallback: monkeypatch.setenv("HF_DVCLIVE_LOG_MODEL", str(log_model)) live_callback = callback(live=live) else: live_callback = callback(live=live, log_model=log_model) args.load_best_model_at_end = best args.metric_for_best_model = "loss" trainer = Trainer( model, args, train_dataset=data[0], eval_dataset=data[1], compute_metrics=compute_metrics, ) trainer.add_callback(live_callback) trainer.train() expected_call_count = { "all": 2, True: 1, False: 0, None: 0, } assert log_artifact.call_count == expected_call_count[log_model] if log_model is True: name = "best" if best else "last" log_artifact.assert_called_with( os.path.join(args.output_dir, name), name=name, type="model", copy=True, ) @pytest.mark.parametrize("callback", [ExternalCallback, InternalCallback]) def test_huggingface_pass_logger(callback): logger = Live("train_logs") assert callback().live is not logger assert callback(live=logger).live is logger @pytest.mark.parametrize("report_to", ["all", "dvclive", "none"]) def test_huggingface_report_to(model, report_to): args = TrainingArguments("foo", report_to=report_to) trainer = Trainer( model, args, ) live_cbs = [ cb for cb in trainer.callback_handler.callbacks if isinstance(cb, ExternalCallback) ] if report_to == "none": assert not any(live_cbs) else: assert any(live_cbs) ================================================ FILE: tests/frameworks/test_keras.py ================================================ import os import pytest from dvclive import Live from dvclive.plots.metric import Metric from dvclive.utils import parse_metrics try: from dvclive.keras import DVCLiveCallback except ImportError: pytest.skip("skipping keras tests", allow_module_level=True) @pytest.fixture def xor_model(): import numpy as np import tensorflow as tf def make(): x = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y = np.array([[0], [1], [1], [0]]) model = tf.keras.Sequential() model.add(tf.keras.layers.Dense(8, input_dim=2)) model.add(tf.keras.layers.Activation("relu")) model.add(tf.keras.layers.Dense(1)) model.add(tf.keras.layers.Activation("sigmoid")) model.compile(loss="binary_crossentropy", optimizer="sgd", metrics=["accuracy"]) return model, x, y return make def test_keras_callback(tmp_dir, xor_model, mocker): model, x, y = xor_model() callback = DVCLiveCallback() live = callback.live spy = mocker.spy(live, "end") model.fit( x, y, epochs=1, batch_size=1, validation_split=0.2, callbacks=[callback], ) spy.assert_called_once() assert os.path.exists("dvclive") logs, _ = parse_metrics(callback.live) scalars = os.path.join(callback.live.plots_dir, Metric.subfolder) assert os.path.join(scalars, "train", "accuracy.tsv") in logs assert os.path.join(scalars, "eval", "accuracy.tsv") in logs def test_keras_callback_pass_logger(): logger = Live("train_logs") assert DVCLiveCallback().live is not logger assert DVCLiveCallback(live=logger).live is logger ================================================ FILE: tests/frameworks/test_lgbm.py ================================================ import os from sys import platform import pytest from dvclive import Live from dvclive.utils import parse_metrics try: import lightgbm as lgbm import pandas as pd from sklearn import datasets from sklearn.model_selection import train_test_split from dvclive.lgbm import DVCLiveCallback except ImportError: pytest.skip("skipping lightgbm tests", allow_module_level=True) @pytest.fixture def model_params(): return {"objective": "multiclass", "n_estimators": 5, "seed": 0} @pytest.fixture def iris_data(): iris = datasets.load_iris() x = pd.DataFrame(iris["data"], columns=iris["feature_names"]) y = iris["target"] x_train, x_test, y_train, y_test = train_test_split( x, y, test_size=0.33, random_state=42 ) return (x_train, y_train), (x_test, y_test) @pytest.mark.skipif(platform == "darwin", reason="LIBOMP Segmentation fault on MacOS") def test_lgbm_integration(tmp_dir, model_params, iris_data): model = lgbm.LGBMClassifier() model.set_params(**model_params) callback = DVCLiveCallback() model.fit( iris_data[0][0], iris_data[0][1], eval_set=(iris_data[1][0], iris_data[1][1]), eval_metric=["multi_logloss"], callbacks=[callback], ) assert os.path.exists("dvclive") logs, _ = parse_metrics(callback.live) assert "dvclive/plots/metrics/multi_logloss.tsv" in logs assert len(logs) == 1 assert len(next(iter(logs.values()))) == 5 @pytest.mark.skipif(platform == "darwin", reason="LIBOMP Segmentation fault on MacOS") def test_lgbm_integration_multi_eval(tmp_dir, model_params, iris_data): model = lgbm.LGBMClassifier() model.set_params(**model_params) callback = DVCLiveCallback() model.fit( iris_data[0][0], iris_data[0][1], eval_set=[ (iris_data[0][0], iris_data[0][1]), (iris_data[1][0], iris_data[1][1]), ], eval_metric=["multi_logloss"], callbacks=[callback], ) assert os.path.exists("dvclive") logs, _ = parse_metrics(callback.live) assert "dvclive/plots/metrics/training/multi_logloss.tsv" in logs assert "dvclive/plots/metrics/valid_1/multi_logloss.tsv" in logs assert len(logs) == 2 assert len(next(iter(logs.values()))) == 5 def test_lgbm_pass_logger(): logger = Live("train_logs") assert DVCLiveCallback().live is not logger assert DVCLiveCallback(live=logger).live is logger ================================================ FILE: tests/frameworks/test_lightning.py ================================================ import os from contextlib import redirect_stdout from io import StringIO from unittest import mock import pytest import yaml from dvclive.plots.metric import Metric from dvclive.serialize import load_yaml from dvclive.utils import parse_metrics try: import torch from lightning import LightningModule from lightning.pytorch import Trainer from lightning.pytorch.callbacks import ModelCheckpoint from lightning.pytorch.cli import LightningCLI from lightning.pytorch.demos.boring_classes import BoringModel from torch import nn from torch.nn import functional as F # noqa: N812 from torch.optim import SGD, Adam from torch.utils.data import DataLoader, Dataset from dvclive import Live from dvclive.lightning import DVCLiveLogger except ImportError: pytest.skip("skipping lightning tests", allow_module_level=True) class XORDataset(Dataset): def __init__(self, *args, **kwargs): self.ins = [[0, 0], [0, 1], [1, 0], [1, 1]] self.outs = [1, 0, 0, 1] def __getitem__(self, index): return torch.Tensor(self.ins[index]), torch.tensor( self.outs[index], dtype=torch.long ) def __len__(self): return len(self.ins) class LitXOR(LightningModule): def __init__( self, latent_dims=4, optim=SGD, optim_params={"lr": 0.01}, # noqa: B006 input_size=[256, 256, 256], # noqa: B006 ): super().__init__() self.save_hyperparameters() self.layer_1 = nn.Linear(2, latent_dims) self.layer_2 = nn.Linear(latent_dims, 2) def forward(self, *args, **kwargs): x = args[0] batch_size, _ = x.size() x = x.view(batch_size, -1) x = self.layer_1(x) x = F.relu(x) x = self.layer_2(x) return F.log_softmax(x, dim=1) def train_loader(self): dataset = XORDataset() return DataLoader(dataset, batch_size=1) def train_dataloader(self): return self.train_loader() def training_step(self, *args, **kwargs): batch = args[0] x, y = batch logits = self(x) loss = F.nll_loss(logits, y) self.log( "train_loss", loss, prog_bar=True, logger=True, on_step=True, on_epoch=True, ) return loss def configure_optimizers(self): return self.hparams.optim(self.parameters(), **self.hparams.optim_params) def predict_dataloader(self): pass def test_dataloader(self): pass def val_dataloader(self): pass def test_lightning_integration(tmp_dir, mocker): # init model model = LitXOR( latent_dims=8, optim=Adam, optim_params={"lr": 0.02}, input_size=[128, 128, 128] ) # init logger dvclive_logger = DVCLiveLogger("test_run", dir="logs") live = dvclive_logger.experiment spy = mocker.spy(live, "end") trainer = Trainer( logger=dvclive_logger, max_epochs=2, enable_checkpointing=False, log_every_n_steps=1, ) trainer.fit(model) spy.assert_called_once() assert os.path.exists("logs") assert not os.path.exists("DvcLiveLogger") scalars = os.path.join(dvclive_logger.experiment.plots_dir, Metric.subfolder) logs, _ = parse_metrics(dvclive_logger.experiment) assert len(logs) == 3 assert os.path.join(scalars, "train", "epoch", "loss.tsv") in logs assert os.path.join(scalars, "train", "step", "loss.tsv") in logs assert os.path.join(scalars, "epoch.tsv") in logs params_file = dvclive_logger.experiment.params_file assert os.path.exists(params_file) assert load_yaml(params_file) == { "latent_dims": 8, "optim": "Adam", "optim_params": {"lr": 0.02}, "input_size": [128, 128, 128], } def test_lightning_default_dir(tmp_dir): model = LitXOR() # If `dir` is not provided handle it properly, use default value dvclive_logger = DVCLiveLogger("test_run") trainer = Trainer( logger=dvclive_logger, max_epochs=2, enable_checkpointing=False, log_every_n_steps=1, ) trainer.fit(model) assert os.path.exists("dvclive") def test_lightning_kwargs(tmp_dir): model = LitXOR() # Handle kwargs passed to Live. dvclive_logger = DVCLiveLogger( dir="dir", report="md", dvcyaml=False, cache_images=True ) trainer = Trainer( logger=dvclive_logger, max_epochs=2, enable_checkpointing=False, log_every_n_steps=1, ) trainer.fit(model) assert os.path.exists("dir") assert os.path.exists("dir/report.md") assert not os.path.exists("dir/dvc.yaml") assert dvclive_logger.experiment._cache_images is True @pytest.mark.parametrize("log_model", [False, True, "all"]) @pytest.mark.parametrize("save_top_k", [1, -1]) def test_lightning_log_model(tmp_dir, mocker, log_model, save_top_k): model = LitXOR() dvclive_logger = DVCLiveLogger(dir="dir", log_model=log_model) checkpoint = ModelCheckpoint(dirpath="model", save_top_k=save_top_k) trainer = Trainer( logger=dvclive_logger, max_epochs=2, log_every_n_steps=1, callbacks=[checkpoint], ) log_artifact = mocker.patch.object(dvclive_logger.experiment, "log_artifact") trainer.fit(model) # Check that log_artifact is called. if log_model is False: log_artifact.assert_not_called() elif (log_model is True) and (save_top_k != -1): # called once to cache, then again to log best artifact assert log_artifact.call_count == 2 else: # once per epoch plus two calls at the end (see above) assert log_artifact.call_count == 4 # Check that checkpoint files does not grow with each run. num_checkpoints = len(os.listdir(tmp_dir / "model")) if log_model in [True, "all"]: trainer.fit(model) assert len(os.listdir(tmp_dir / "model")) == num_checkpoints log_artifact.assert_any_call( checkpoint.best_model_path, name="best", type="model", copy=True ) def test_lightning_steps(tmp_dir, mocker): model = LitXOR() # Handle kwargs passed to Live. dvclive_logger = DVCLiveLogger(dir="logs") live = dvclive_logger.experiment spy = mocker.spy(live, "sync") trainer = Trainer( logger=dvclive_logger, max_epochs=2, enable_checkpointing=False, # Log one time in the middle of the epoch log_every_n_steps=3, ) trainer.fit(model) history, latest = parse_metrics(dvclive_logger.experiment) assert latest["step"] == 7 assert latest["epoch"] == 1 scalars = os.path.join(dvclive_logger.experiment.plots_dir, Metric.subfolder) epoch_loss = history[os.path.join(scalars, "train", "epoch", "loss.tsv")] step_loss = history[os.path.join(scalars, "train", "step", "loss.tsv")] assert len(epoch_loss) == 2 assert len(step_loss) == 2 # call sync: # - 2x epoch end # - 2x log_every_n_steps # - 1x experiment end assert spy.call_count == 5 class ValLitXOR(LitXOR): def val_loader(self): dataset = XORDataset() return DataLoader(dataset, batch_size=1) def val_dataloader(self): return self.val_loader() def training_step(self, *args, **kwargs): batch = args[0] x, y = batch logits = self(x) loss = F.nll_loss(logits, y) self.log("train_loss", loss, on_step=True) return loss def validation_step(self, *args, **kwargs): batch = args[0] x, y = batch logits = self(x) loss = F.nll_loss(logits, y) self.log("val_loss", loss, on_step=False, on_epoch=True) return loss def test_lightning_force_init(tmp_dir, mocker): """Related to https://github.com/iterative/dvclive/issues/594 Don't call Live.__init__ on rank-nonzero processes. """ init = mocker.spy(Live, "__init__") DVCLiveLogger() init.assert_not_called() # LightningCLI tests # Copied from https://github.com/Lightning-AI/lightning/blob/e7afe04ee86b64c76a5446088b3b75d9c275e5bf/tests/tests_pytorch/test_cli.py class TestModel(BoringModel): def __init__(self, foo, bar=5): super().__init__() self.foo = foo self.bar = bar def _test_logger_init_args(logger_name, init, unresolved={}): # noqa: B006 cli_args = [f"--trainer.logger={logger_name}"] cli_args += [f"--trainer.logger.{k}={v}" for k, v in init.items()] cli_args += [f"--trainer.logger.dict_kwargs.{k}={v}" for k, v in unresolved.items()] cli_args.append("--print_config") out = StringIO() with ( mock.patch( "sys.argv", ["any.py"] + cli_args, # noqa: RUF005 ), redirect_stdout( # noqa: RUF100 out ), pytest.raises(SystemExit), ): LightningCLI(TestModel, run=False) data = yaml.safe_load(out.getvalue())["trainer"]["logger"] assert {k: data["init_args"][k] for k in init} == init if unresolved: assert data["dict_kwargs"] == unresolved def test_dvclive_logger_init_args(): _test_logger_init_args( "dvclive.lightning.DVCLiveLogger", { "run_name": "test_run", # Resolve from DVCLiveLogger.__init__ "dir": "results", # Resolve from Live.__init__ }, ) ================================================ FILE: tests/frameworks/test_optuna.py ================================================ import pytest from dvclive.serialize import load_yaml from dvclive.utils import parse_json try: import optuna from dvclive.optuna import DVCLiveCallback except ImportError: pytest.skip("skipping optuna tests", allow_module_level=True) def objective(trial): x = trial.suggest_float("x", -10, 10) return (x - 2) ** 2 def test_optuna_(tmp_dir, mocked_dvc_repo): n_trials = 5 metric_name = "custom_name" callback = DVCLiveCallback(metric_name=metric_name) study = optuna.create_study() study.optimize(objective, n_trials=n_trials, callbacks=[callback]) assert mocked_dvc_repo.experiments.save.call_count == n_trials metrics = parse_json("dvclive-optuna/metrics.json") assert metric_name in metrics params = load_yaml("dvclive-optuna/params.yaml") assert "x" in params assert not (tmp_dir / "dvclive-optuna" / "plots").exists() ================================================ FILE: tests/frameworks/test_xgboost.py ================================================ import os from contextlib import nullcontext import pytest from dvclive import Live from dvclive.plots.metric import Metric from dvclive.utils import parse_metrics try: import pandas as pd import xgboost as xgb from sklearn import datasets from sklearn.model_selection import train_test_split from dvclive.xgb import DVCLiveCallback except ImportError: pytest.skip("skipping xgboost tests", allow_module_level=True) @pytest.fixture def train_params(): return {"objective": "multi:softmax", "num_class": 3, "seed": 0} @pytest.fixture def iris_data(): iris = datasets.load_iris() x = pd.DataFrame(iris["data"], columns=iris["feature_names"]) y = iris["target"] return xgb.DMatrix(x, y) @pytest.fixture def iris_train_eval_data(): iris = datasets.load_iris() x_train, x_eval, y_train, y_eval = train_test_split( iris.data, iris.target, random_state=0 ) return (xgb.DMatrix(x_train, y_train), xgb.DMatrix(x_eval, y_eval)) @pytest.mark.parametrize( ("metric_data", "subdirs", "context"), [ ( "eval", ("",), pytest.warns(DeprecationWarning, match="`metric_data`.+deprecated"), ), (None, ("train", "eval"), nullcontext()), ], ) def test_xgb_integration( tmp_dir, train_params, iris_train_eval_data, metric_data, subdirs, context, mocker ): with context: callback = DVCLiveCallback(metric_data) live = callback.live spy = mocker.spy(live, "end") data_train, data_eval = iris_train_eval_data xgb.train( train_params, data_train, callbacks=[callback], num_boost_round=5, evals=[(data_train, "train"), (data_eval, "eval")], ) spy.assert_called_once() assert os.path.exists("dvclive") logs, _ = parse_metrics(callback.live) assert len(logs) == len(subdirs) assert list(map(len, logs.values())) == [5] * len(logs) scalars = os.path.join(callback.live.plots_dir, Metric.subfolder) assert all( os.path.join(scalars, subdir, "mlogloss.tsv") in logs for subdir in subdirs ) def test_xgb_pass_logger(): logger = Live("train_logs") assert DVCLiveCallback("eval_data").live is not logger assert DVCLiveCallback("eval_data", live=logger).live is logger ================================================ FILE: tests/plots/test_custom.py ================================================ import json from dvclive import Live from dvclive.plots.custom import CustomPlot def test_log_custom_plot(tmp_dir): live = Live() out = tmp_dir / live.plots_dir / CustomPlot.subfolder datapoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] live.log_plot( "custom_linear", datapoints, x="x", y="y", template="linear", title="custom_title", x_label="x_label", y_label="y_label", ) assert json.loads((out / "custom_linear.json").read_text()) == datapoints assert live._plots["custom_linear"].plot_config == { "template": "linear", "title": "custom_title", "x": "x", "y": "y", "x_label": "x_label", "y_label": "y_label", } def test_log_custom_plot_multi_y(tmp_dir): live = Live() out = tmp_dir / live.plots_dir / CustomPlot.subfolder datapoints = [{"x": 1, "y1": 2, "y2": 3}, {"x": 4, "y1": 5, "y2": 6}] live.log_plot( "custom_linear", datapoints, x="x", y=["y1", "y2"], template="linear", title="custom_title", x_label="x_label", y_label="y_label", ) assert json.loads((out / "custom_linear.json").read_text()) == datapoints assert live._plots["custom_linear"].plot_config == { "template": "linear", "title": "custom_title", "x": "x", "y": ["y1", "y2"], "x_label": "x_label", "y_label": "y_label", } def test_log_custom_plot_with_template_as_empty_string(tmp_dir): live = Live() out = tmp_dir / live.plots_dir / CustomPlot.subfolder datapoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] live.log_plot( "custom_linear", datapoints, x="x", y="y", template="", title="custom_title", x_label="x_label", y_label="y_label", ) assert json.loads((out / "custom_linear.json").read_text()) == datapoints # 'template' should not be in plot_config. Default template will be assigned later. assert live._plots["custom_linear"].plot_config == { "title": "custom_title", "x": "x", "y": "y", "x_label": "x_label", "y_label": "y_label", } ================================================ FILE: tests/plots/test_image.py ================================================ import matplotlib.pyplot as plt import numpy as np import pytest from PIL import Image from dvclive import Live from dvclive.error import InvalidImageNameError from dvclive.plots import Image as LiveImage # From https://stackoverflow.com/questions/5165317/how-can-i-extend-image-class class ExtendedImage(Image.Image): def __init__(self, img): self._img = img def __getattr__(self, key): return getattr(self._img, key) def test_pil(tmp_dir): live = Live() img = Image.new("RGB", (10, 10), (250, 250, 250)) live.log_image("image.png", img) assert (tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png").exists() def test_pil_omitting_extension_doesnt_save_without_valid_format(tmp_dir): live = Live() img = Image.new("RGB", (10, 10), (250, 250, 250)) with pytest.raises( InvalidImageNameError, match="Cannot log image with name 'whoops'" ): live.log_image("whoops", img) def test_pil_omitting_extension_sets_the_format_if_path_given(tmp_dir): live = Live() img = Image.new("RGB", (10, 10), (250, 250, 250)) # Save it first, we'll reload it and pass it's path to log_image again live.log_image("saved_with_format.png", img) # Now try saving without explicit format and check if the format is set correctly. live.log_image( "whoops", (tmp_dir / live.plots_dir / LiveImage.subfolder / "saved_with_format.png"), ) assert (tmp_dir / live.plots_dir / LiveImage.subfolder / "whoops.png").exists() def test_invalid_extension(tmp_dir): live = Live() img = Image.new("RGB", (10, 10), (250, 250, 250)) with pytest.raises( InvalidImageNameError, match="Cannot log image with name 'image\\.foo'" ): live.log_image("image.foo", img) @pytest.mark.parametrize("shape", [(10, 10), (10, 10, 3), (10, 10, 4)]) def test_numpy(tmp_dir, shape): from PIL import Image as ImagePIL live = Live() img = np.ones(shape, np.uint8) * 255 live.log_image("image.png", img) img_path = tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png" assert img_path.exists() val = np.asarray(ImagePIL.open(img_path)) assert np.array_equal(val, img) def test_path(tmp_dir): import numpy as np from PIL import Image as ImagePIL live = Live() image_data = np.random.randint(0, 255, (100, 100, 3)).astype(np.uint8) pil_image = ImagePIL.fromarray(image_data) image_path = tmp_dir / "temp.png" pil_image.save(image_path) live = Live() live.log_image("foo.png", image_path) live.end() plot_file = tmp_dir / live.plots_dir / "images" / "foo.png" assert plot_file.exists() val = np.asarray(ImagePIL.open(plot_file)) assert np.array_equal(val, image_data) def test_override_on_step(tmp_dir): live = Live() zeros = np.zeros((2, 2, 3), np.uint8) live.log_image("image.png", zeros) live.next_step() ones = np.ones((2, 2, 3), np.uint8) live.log_image("image.png", ones) img_path = tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png" assert np.array_equal(np.array(Image.open(img_path)), ones) def test_cleanup(tmp_dir): live = Live() img = np.ones((10, 10, 3), np.uint8) live.log_image("image.png", img) assert (tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png").exists() Live() assert not (tmp_dir / live.plots_dir / LiveImage.subfolder).exists() def test_custom_class(tmp_dir): live = Live() img = Image.new("RGB", (10, 10), (250, 250, 250)) extended_img = ExtendedImage(img) live.log_image("image.png", extended_img) assert (tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png").exists() def test_matplotlib(tmp_dir): live = Live() fig, ax = plt.subplots() ax.plot([1, 2, 3, 4]) assert plt.fignum_exists(fig.number) live.log_image("image.png", fig) assert not plt.fignum_exists(fig.number) assert (tmp_dir / live.plots_dir / LiveImage.subfolder / "image.png").exists() @pytest.mark.parametrize("cache", [False, True]) def test_cache_images(tmp_dir, dvc_repo, cache): live = Live(save_dvc_exp=False, cache_images=cache) img = Image.new("RGB", (10, 10), (250, 250, 250)) live.log_image("image.png", img) live.end() assert (tmp_dir / "dvclive" / "plots" / "images.dvc").exists() == cache ================================================ FILE: tests/plots/test_metric.py ================================================ import json import numpy as np import pytest from dvclive import Live from dvclive.plots.metric import Metric from dvclive.plots.utils import NUMPY_INTS, NUMPY_SCALARS from dvclive.utils import parse_tsv @pytest.mark.parametrize("dtype", NUMPY_SCALARS) def test_numpy(tmp_dir, dtype): scalar = np.random.rand(1).astype(dtype)[0] live = Live() live.log_metric("scalar", scalar) live.next_step() parsed = json.loads((tmp_dir / live.metrics_file).read_text()) assert isinstance(parsed["scalar"], int if dtype in NUMPY_INTS else float) tsv_file = tmp_dir / live.plots_dir / Metric.subfolder / "scalar.tsv" tsv_val = parse_tsv(tsv_file)[0]["scalar"] assert tsv_val == str(scalar) def test_name_with_dot(tmp_dir): """Regression test for #284""" live = Live() live.log_metric("scalar.foo.bar", 1.0) live.next_step() tsv_file = tmp_dir / live.plots_dir / Metric.subfolder / "scalar.foo.bar.tsv" assert tsv_file.exists() tsv_val = parse_tsv(tsv_file)[0]["scalar.foo.bar"] assert tsv_val == "1.0" ================================================ FILE: tests/plots/test_sklearn.py ================================================ # ruff: noqa: N806 import json import pytest from sklearn import calibration, metrics from dvclive import Live from dvclive.plots.sklearn import SKLearnPlot @pytest.fixture def y_true_y_pred_y_score(): from sklearn.datasets import make_classification from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split X, y = make_classification(random_state=0) X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0) clf = RandomForestClassifier(random_state=0) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) y_score = clf.predict_proba(X_test)[:, 1] return y_test, y_pred, y_score def test_log_calibration_curve(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, _, y_score = y_true_y_pred_y_score spy = mocker.spy(calibration, "calibration_curve") live.log_sklearn_plot("calibration", y_true, y_score) spy.assert_called_once_with(y_true, y_score) assert (out / "calibration.json").exists() def test_log_det_curve(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, _, y_score = y_true_y_pred_y_score spy = mocker.spy(metrics, "det_curve") live.log_sklearn_plot("det", y_true, y_score) spy.assert_called_once_with(y_true, y_score) assert (out / "det.json").exists() def test_log_roc_curve(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, _, y_score = y_true_y_pred_y_score spy = mocker.spy(metrics, "roc_curve") live.log_sklearn_plot("roc", y_true, y_score) spy.assert_called_once_with(y_true, y_score) assert (out / "roc.json").exists() def test_log_prc_curve(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, _, y_score = y_true_y_pred_y_score spy = mocker.spy(metrics, "precision_recall_curve") live.log_sklearn_plot("precision_recall", y_true, y_score) spy.assert_called_once_with(y_true=y_true, y_score=y_score) assert (out / "precision_recall.json").exists() def test_log_confusion_matrix(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, y_pred, _ = y_true_y_pred_y_score live.log_sklearn_plot("confusion_matrix", y_true, y_pred) cm = json.loads((out / "confusion_matrix.json").read_text()) assert isinstance(cm, list) assert isinstance(cm[0], dict) assert cm[0]["actual"] == str(y_true[0]) assert cm[0]["predicted"] == str(y_pred[0]) def test_dump_kwargs(tmp_dir, y_true_y_pred_y_score, mocker): live = Live() y_true, _, y_score = y_true_y_pred_y_score spy = mocker.spy(metrics, "roc_curve") live.log_sklearn_plot("roc", y_true, y_score, drop_intermediate=True) spy.assert_called_once_with(y_true, y_score, drop_intermediate=True) def test_override_on_step(tmp_dir): live = Live() live.log_sklearn_plot("confusion_matrix", [0, 0], [0, 0]) live.next_step() live.log_sklearn_plot("confusion_matrix", [0, 0], [1, 1]) plot_path = tmp_dir / live.plots_dir / SKLearnPlot.subfolder plot_path = plot_path / "confusion_matrix.json" assert json.loads(plot_path.read_text()) == [ {"actual": "0", "predicted": "1"}, {"actual": "0", "predicted": "1"}, ] def test_cleanup(tmp_dir, y_true_y_pred_y_score): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, y_pred, _ = y_true_y_pred_y_score live.log_sklearn_plot("confusion_matrix", y_true, y_pred) assert (out / "confusion_matrix.json").exists() Live() assert not (tmp_dir / live.plots_dir / SKLearnPlot.subfolder).exists() def test_custom_name(tmp_dir, y_true_y_pred_y_score): live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, y_pred, _ = y_true_y_pred_y_score live.log_sklearn_plot("confusion_matrix", y_true, y_pred, name="train/cm") live.log_sklearn_plot("confusion_matrix", y_true, y_pred, name="val/cm") # ".json" should be stripped from the name live.log_sklearn_plot("confusion_matrix", y_true, y_pred, name="cm.json") assert (out / "train" / "cm.json").exists() assert (out / "val" / "cm.json").exists() assert (out / "cm.json").exists() def test_custom_title(tmp_dir, y_true_y_pred_y_score): """https://github.com/iterative/dvclive/issues/453""" live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, y_pred, y_score = y_true_y_pred_y_score live.log_sklearn_plot( "confusion_matrix", y_true, y_pred, name="train/cm", title="Train Confusion Matrix", ) live.log_sklearn_plot( "confusion_matrix", y_true, y_pred, name="val/cm", title="Val Confusion Matrix" ) live.log_sklearn_plot( "precision_recall", y_true, y_score, name="val/prc", title="Val Precision Recall", ) assert (out / "train" / "cm.json").exists() assert (out / "val" / "cm.json").exists() assert (out / "val" / "prc.json").exists() assert live._plots["train/cm"].plot_config["title"] == "Train Confusion Matrix" assert live._plots["val/cm"].plot_config["title"] == "Val Confusion Matrix" assert live._plots["val/prc"].plot_config["title"] == "Val Precision Recall" def test_custom_labels(tmp_dir, y_true_y_pred_y_score): """https://github.com/iterative/dvclive/issues/453""" live = Live() out = tmp_dir / live.plots_dir / SKLearnPlot.subfolder y_true, _, y_score = y_true_y_pred_y_score live.log_sklearn_plot( "precision_recall", y_true, y_score, name="val/prc", x_label="x_test", y_label="y_test", ) assert (out / "val" / "prc.json").exists() assert live._plots["val/prc"].plot_config["x_label"] == "x_test" assert live._plots["val/prc"].plot_config["y_label"] == "y_test" ================================================ FILE: tests/test_cleanup.py ================================================ import pytest from dvclive import Live from dvclive.plots import Metric @pytest.mark.parametrize( "html", [True, False], ) @pytest.mark.parametrize( "dvcyaml", ["dvc.yaml", "logs/dvc.yaml"], ) def test_cleanup(tmp_dir, html, dvcyaml): dvclive = Live("logs", report="html" if html else None, dvcyaml=dvcyaml) dvclive.log_metric("m1", 1) dvclive.next_step() html_path = tmp_dir / dvclive.dir / "report.html" if html: html_path.touch() (tmp_dir / "logs" / "some_user_file.txt").touch() (tmp_dir / "dvc.yaml").touch() assert (tmp_dir / dvclive.plots_dir / Metric.subfolder / "m1.tsv").is_file() assert (tmp_dir / dvclive.metrics_file).is_file() assert (tmp_dir / dvclive.dvc_file).is_file() assert html_path.is_file() == html dvclive = Live("logs") assert (tmp_dir / "logs" / "some_user_file.txt").is_file() assert not (tmp_dir / dvclive.plots_dir / Metric.subfolder).exists() assert not (tmp_dir / dvclive.metrics_file).is_file() if dvcyaml == "dvc.yaml": assert (tmp_dir / dvcyaml).is_file() if dvcyaml == "logs/dvc.yaml": assert not (tmp_dir / dvcyaml).is_file() assert not (html_path).is_file() ================================================ FILE: tests/test_context_manager.py ================================================ import json from dvclive import Live from dvclive.plots import Metric def test_context_manager(tmp_dir): with Live(report="html") as live: live.summary["foo"] = 1.0 assert json.loads((tmp_dir / live.metrics_file).read_text()) == { # no `step` "foo": 1.0 } log_file = tmp_dir / live.plots_dir / Metric.subfolder / "foo.tsv" assert not log_file.exists() report_file = tmp_dir / live.report_file assert report_file.exists() def test_context_manager_skips_end_calls(tmp_dir): with Live() as live: live.summary["foo"] = 1.0 live.end() assert not (tmp_dir / live.metrics_file).exists() assert (tmp_dir / live.metrics_file).exists() ================================================ FILE: tests/test_dvc.py ================================================ import os import pytest from dvc.exceptions import DvcException from dvc.repo import Repo from dvc.scm import NoSCM from scmrepo.git import Git from dvclive import Live from dvclive.dvc import get_dvc_repo from dvclive.env import DVC_EXP_BASELINE_REV, DVC_EXP_NAME, DVC_ROOT, DVCLIVE_TEST def test_get_dvc_repo(tmp_dir): assert get_dvc_repo() is None Git.init(tmp_dir) assert isinstance(get_dvc_repo(), Repo) def test_get_dvc_repo_subdir(tmp_dir): Git.init(tmp_dir) subdir = tmp_dir / "sub" subdir.mkdir() os.chdir(subdir) assert get_dvc_repo().root_dir == str(tmp_dir) @pytest.mark.parametrize("save", [True, False]) def test_exp_save_on_end(tmp_dir, save, mocked_dvc_repo): live = Live(save_dvc_exp=save) live.end() assert live._baseline_rev is not None assert live._exp_name is not None if save: mocked_dvc_repo.experiments.save.assert_called_with( name=live._exp_name, include_untracked=[live.dir, "dvc.yaml"], force=True, message=None, ) else: mocked_dvc_repo.experiments.save.assert_not_called() def test_exp_save_skip_on_env_vars(tmp_dir, monkeypatch): monkeypatch.setenv(DVC_EXP_BASELINE_REV, "foo") monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) live = Live() live.end() assert live._dvc_repo is None assert live._baseline_rev == "foo" assert live._exp_name == "bar" assert live._inside_dvc_exp assert live._inside_dvc_pipeline def test_exp_save_with_dvc_files(tmp_dir, mocker): dvc_repo = mocker.MagicMock() dvc_file = mocker.MagicMock() dvc_file.is_data_source = True dvc_repo.index.stages = [dvc_file] dvc_repo.scm.get_rev.return_value = "current_rev" dvc_repo.scm.get_ref.return_value = None dvc_repo.scm.no_commits = False dvc_repo.root_dir = tmp_dir dvc_repo.config = {} mocker.patch("dvclive.live.get_dvc_repo", return_value=dvc_repo) live = Live() live.end() dvc_repo.experiments.save.assert_called_with( name=live._exp_name, include_untracked=[live.dir, "dvc.yaml"], force=True, message=None, ) def test_exp_save_dvcexception_is_ignored(tmp_dir, mocker): from dvc.exceptions import DvcException dvc_repo = mocker.MagicMock() dvc_repo.index.stages = [] dvc_repo.scm.get_rev.return_value = "current_rev" dvc_repo.scm.get_ref.return_value = None dvc_repo.config = {} dvc_repo.experiments.save.side_effect = DvcException("foo") mocker.patch("dvclive.live.get_dvc_repo", return_value=dvc_repo) with Live(): pass def test_untracked_dvclive_files_inside_dvc_exp_run_are_added( tmp_dir, mocked_dvc_repo, monkeypatch ): monkeypatch.setenv(DVC_EXP_BASELINE_REV, "foo") monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) plot_file = os.path.join("dvclive", "plots", "metrics", "foo.tsv") mocked_dvc_repo.scm.untracked_files.return_value = [ "dvclive/metrics.json", plot_file, ] with Live() as live: live.log_metric("foo", 1) live.next_step() live._dvc_repo.scm.add.assert_any_call(["dvclive/metrics.json", plot_file]) live._dvc_repo.scm.add.assert_any_call(live.dvc_file) def test_dvc_outs_are_not_added(tmp_dir, mocked_dvc_repo, monkeypatch): """Regression test for https://github.com/iterative/dvclive/issues/516""" monkeypatch.setenv(DVC_EXP_BASELINE_REV, "foo") monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) mocked_dvc_repo.index.outs = ["dvclive/plots"] plot_file = os.path.join("dvclive", "plots", "metrics", "foo.tsv") mocked_dvc_repo.scm.untracked_files.return_value = [ "dvclive/metrics.json", plot_file, ] with Live() as live: live.log_metric("foo", 1) live.next_step() live._dvc_repo.scm.add.assert_any_call(["dvclive/metrics.json"]) def test_errors_on_git_add_are_catched(tmp_dir, mocked_dvc_repo, monkeypatch): monkeypatch.setenv(DVC_EXP_BASELINE_REV, "foo") monkeypatch.setenv(DVC_EXP_NAME, "bar") mocked_dvc_repo.scm.untracked_files.return_value = ["dvclive/metrics.json"] mocked_dvc_repo.scm.add.side_effect = DvcException("foo") with Live() as live: live.summary["foo"] = 1 def test_exp_save_message(tmp_dir, mocked_dvc_repo): live = Live(exp_message="Custom message") live.end() mocked_dvc_repo.experiments.save.assert_called_with( name=live._exp_name, include_untracked=[live.dir, "dvc.yaml"], force=True, message="Custom message", ) def test_exp_save_name(tmp_dir, mocked_dvc_repo): live = Live(exp_name="custom-name") live.end() mocked_dvc_repo.experiments.save.assert_called_with( name="custom-name", include_untracked=[live.dir, "dvc.yaml"], force=True, message=None, ) def test_no_scm_repo(tmp_dir, mocker): dvc_repo = mocker.MagicMock() dvc_repo.scm = NoSCM() mocker.patch("dvclive.live.get_dvc_repo", return_value=dvc_repo) live = Live() assert live._dvc_repo == dvc_repo live = Live() assert live._save_dvc_exp is False def test_dvc_repro(tmp_dir, monkeypatch, mocked_dvc_repo, mocked_studio_post): monkeypatch.setenv(DVC_ROOT, "root") live = Live(save_dvc_exp=True) assert live._baseline_rev is not None assert live._exp_name is not None assert not live._studio_events_to_skip assert not live._save_dvc_exp def test_get_exp_name_valid(tmp_dir, mocked_dvc_repo): live = Live(exp_name="name") assert live._exp_name == "name" def test_get_exp_name_random(tmp_dir, mocked_dvc_repo, mocker): mocker.patch( "dvc.repo.experiments.utils.get_random_exp_name", return_value="random" ) live = Live() assert live._exp_name == "random" def test_get_exp_name_invalid(tmp_dir, mocked_dvc_repo, mocker, caplog): mocker.patch( "dvc.repo.experiments.utils.get_random_exp_name", return_value="random" ) with caplog.at_level("WARNING"): live = Live(exp_name="invalid//name") assert live._exp_name == "random" assert caplog.text def test_get_exp_name_duplicate(tmp_dir, mocked_dvc_repo, mocker, caplog): mocker.patch( "dvc.repo.experiments.utils.get_random_exp_name", return_value="random" ) mocked_dvc_repo.scm.get_ref.return_value = "duplicate" with caplog.at_level("WARNING"): live = Live(exp_name="duplicate") assert live._exp_name == "random" msg = "Experiment conflicts with existing experiment 'duplicate'." assert msg in caplog.text def test_test_mode(tmp_dir, monkeypatch, mocked_dvc_repo): monkeypatch.setenv(DVCLIVE_TEST, "true") live = Live("dir", dvcyaml="dvc.yaml") live.make_dvcyaml() assert live._dir != "dir" assert live._dvc_file != "dvc.yaml" assert live._save_dvc_exp is False assert not os.path.exists("dir") assert not os.path.exists("dvc.yaml") ================================================ FILE: tests/test_log_artifact.py ================================================ import shutil from pathlib import Path import pytest from dvc.exceptions import DvcException from dvclive import Live from dvclive.error import InvalidDataTypeError from dvclive.serialize import load_yaml dvcyaml = """ stages: train: cmd: python train.py outs: - data """ @pytest.mark.parametrize("cache", [True, False]) def test_log_artifact(tmp_dir, dvc_repo, cache): data = tmp_dir / "data" data.touch() with Live(save_dvc_exp=False) as live: live.log_artifact("data", cache=cache) assert data.with_suffix(".dvc").exists() is cache assert load_yaml(live.dvc_file) == {} def test_log_artifact_on_existing_dvc_file(tmp_dir, dvc_repo): data = tmp_dir / "data" data.write_text("foo") with Live(save_dvc_exp=False) as live: live.log_artifact("data") prev_content = data.with_suffix(".dvc").read_text() with Live(save_dvc_exp=False) as live: data.write_text("bar") live.log_artifact("data") assert data.with_suffix(".dvc").read_text() != prev_content def test_log_artifact_twice(tmp_dir, dvc_repo): data = tmp_dir / "data" with Live(save_dvc_exp=False) as live: for i in range(2): data.write_text(str(i)) live.log_artifact("data") assert data.with_suffix(".dvc").exists() def test_log_artifact_with_save_dvc_exp(tmp_dir, mocker, mocked_dvc_repo): stage = mocker.MagicMock() stage.addressing = "data" mocked_dvc_repo.add.return_value = [stage] with Live() as live: live.log_artifact("data") mocked_dvc_repo.experiments.save.assert_called_with( name=live._exp_name, include_untracked=[live.dir, "data", ".gitignore", "dvc.yaml"], force=True, message=None, ) def test_log_artifact_type_model(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() with Live() as live: live.log_artifact("model.pth", type="model") assert load_yaml(live.dvc_file) == { "artifacts": {"model": {"path": "model.pth", "type": "model"}} } def test_log_artifact_dvc_symlink(tmp_dir, dvc_repo): (tmp_dir / "model.pth").touch() with Live(save_dvc_exp=False, dvcyaml="dvc.yaml") as live: live._dvc_repo.cache.local.cache_types = ["symlink"] live.log_artifact("model.pth", type="model") assert load_yaml(live.dvc_file) == { "artifacts": {"model": {"path": "model.pth", "type": "model"}} } def test_log_artifact_copy(tmp_dir, dvc_repo): (tmp_dir / "model.pth").touch() with Live(save_dvc_exp=False, dvcyaml="dvc.yaml") as live: live.log_artifact("model.pth", type="model", copy=True) artifacts_dir = Path(live.artifacts_dir) assert (artifacts_dir / "model.pth").exists() assert (artifacts_dir / "model.pth.dvc").exists() assert load_yaml(live.dvc_file) == { "artifacts": {"model": {"path": "dvclive/artifacts/model.pth", "type": "model"}} } def test_log_artifact_copy_overwrite(tmp_dir, dvc_repo): (tmp_dir / "model.pth").touch() with Live(save_dvc_exp=False, dvcyaml="dvc.yaml") as live: artifacts_dir = Path(live.artifacts_dir) # testing with symlink cache to make sure that DVC protected mode # does not prevent the overwrite live._dvc_repo.cache.local.cache_types = ["symlink"] live.log_artifact("model.pth", type="model", copy=True) assert (artifacts_dir / "model.pth").is_symlink() live.log_artifact("model.pth", type="model", copy=True) assert (artifacts_dir / "model.pth").exists() assert (artifacts_dir / "model.pth.dvc").exists() assert load_yaml(live.dvc_file) == { "artifacts": {"model": {"path": "dvclive/artifacts/model.pth", "type": "model"}} } def test_log_artifact_copy_directory_overwrite(tmp_dir, dvc_repo): model_path = Path(tmp_dir / "weights") model_path.mkdir() (tmp_dir / "weights" / "model-epoch-1.pth").touch() with Live(save_dvc_exp=False, dvcyaml="dvc.yaml") as live: artifacts_dir = Path(live.artifacts_dir) # testing with symlink cache to make sure that DVC protected mode # does not prevent the overwrite live._dvc_repo.cache.local.cache_types = ["symlink"] live.log_artifact(model_path, type="model", copy=True) assert (artifacts_dir / "weights" / "model-epoch-1.pth").is_symlink() shutil.rmtree(model_path) model_path.mkdir() (tmp_dir / "weights" / "model-epoch-10.pth").write_text("Model weights") (tmp_dir / "weights" / "best.pth").write_text("Best model weights") live.log_artifact(model_path, type="model", copy=True) assert (artifacts_dir / "weights").exists() assert (artifacts_dir / "weights" / "best.pth").is_symlink() assert (artifacts_dir / "weights" / "best.pth").read_text() == "Best model weights" assert (artifacts_dir / "weights" / "model-epoch-10.pth").is_symlink() assert len(list((artifacts_dir / "weights").iterdir())) == 2 assert load_yaml(live.dvc_file) == { "artifacts": {"weights": {"path": "dvclive/artifacts/weights", "type": "model"}} } def test_log_artifact_type_model_provided_name(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() with Live(dvcyaml="dvc.yaml") as live: live.log_artifact("model.pth", type="model", name="custom") assert load_yaml(live.dvc_file) == { "artifacts": {"custom": {"path": "model.pth", "type": "model"}} } def test_log_artifact_type_model_on_step_and_final(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() with Live(dvcyaml="dvc.yaml") as live: for _ in range(3): live.log_artifact("model.pth", type="model") live.next_step() live.log_artifact("model.pth", type="model", labels=["final"]) assert load_yaml(live.dvc_file) == { "artifacts": { "model": {"path": "model.pth", "type": "model", "labels": ["final"]}, }, "metrics": ["dvclive/metrics.json"], } def test_log_artifact_type_model_on_step(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() with Live(dvcyaml="dvc.yaml") as live: for _ in range(3): live.log_artifact("model.pth", type="model") live.next_step() assert load_yaml(live.dvc_file) == { "artifacts": { "model": {"path": "model.pth", "type": "model"}, }, "metrics": ["dvclive/metrics.json"], } def test_log_artifact_attrs(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() attrs = { "type": "model", "name": "foo", "desc": "bar", "labels": ["foo"], "meta": {"foo": "bar"}, } with Live(dvcyaml="dvc.yaml") as live: live.log_artifact("model.pth", **attrs) attrs.pop("name") assert load_yaml(live.dvc_file) == { "artifacts": { "foo": {"path": "model.pth", **attrs}, } } def test_log_artifact_type_model_when_dvc_add_fails(tmp_dir, mocker, mocked_dvc_repo): (tmp_dir / "model.pth").touch() mocked_dvc_repo.add.side_effect = DvcException("foo") with Live(save_dvc_exp=True, dvcyaml="dvc.yaml") as live: live.log_artifact("model.pth", type="model") assert load_yaml(live.dvc_file) == { "artifacts": {"model": {"path": "model.pth", "type": "model"}} } @pytest.mark.parametrize("tracked", ["data_source", "stage", None]) def test_log_artifact_inside_pipeline(tmp_dir, mocker, dvc_repo, tracked): logger = mocker.patch("dvclive.live.logger") data = tmp_dir / "data" data.touch() if tracked == "data_source": dvc_repo.add(data) elif tracked == "stage": dvcyaml_path = tmp_dir / "dvc.yaml" with open(dvcyaml_path, "w") as f: f.write(dvcyaml) live = Live(save_dvc_exp=False) spy = mocker.spy(live._dvc_repo, "add") live._inside_dvc_pipeline = True live.log_artifact("data") if tracked == "stage": msg = ( "Skipping `dvc add data` because it is already being tracked" " automatically as an output of the DVC pipeline." ) logger.info.assert_called_with(msg) spy.assert_not_called() elif tracked == "data_source": msg = ( "To track 'data' automatically in the DVC pipeline:" "\n1. Run `dvc remove data.dvc` " "to stop tracking it outside the pipeline." "\n2. Add it as an output of the pipeline stage." ) logger.warning.assert_called_with(msg) spy.assert_called_once() else: msg = ( "To track 'data' automatically in the DVC pipeline, " "add it as an output of the pipeline stage." ) logger.warning.assert_called_with(msg) spy.assert_called_once() def test_log_artifact_inside_pipeline_subdir(tmp_dir, mocker, dvc_repo): logger = mocker.patch("dvclive.live.logger") subdir = tmp_dir / "subdir" subdir.mkdir() data = subdir / "data" data.touch() dvc_repo.add(subdir) live = Live() spy = mocker.spy(live._dvc_repo, "add") live._inside_dvc_pipeline = True live.log_artifact("subdir/data") msg = ( "To track 'subdir/data' automatically in the DVC pipeline:" "\n1. Run `dvc remove subdir.dvc` " "to stop tracking it outside the pipeline." "\n2. Add it as an output of the pipeline stage." ) logger.warning.assert_called_with(msg) spy.assert_called_once() def test_log_artifact_no_repo(tmp_dir, mocker): logger = mocker.patch("dvclive.live.logger") (tmp_dir / "data").touch() live = Live() live.log_artifact("data") logger.warning.assert_called_with( "A DVC repo is required to log artifacts. Skipping `log_artifact(data)`." ) @pytest.mark.parametrize("invalid_path", [None, 1.0, True, [], {}], ids=type) def test_log_artifact_invalid_path_type(invalid_path, tmp_dir): live = Live(save_dvc_exp=False) expected_error_msg = f"not supported type {type(invalid_path)}" with pytest.raises(InvalidDataTypeError, match=expected_error_msg): live.log_artifact(path=invalid_path) ================================================ FILE: tests/test_log_metric.py ================================================ import math import os import numpy as np import pytest from dvclive import Live from dvclive.error import InvalidDataTypeError from dvclive.plots import Metric from dvclive.serialize import load_yaml from dvclive.utils import parse_metrics, parse_tsv def test_logging_no_step(tmp_dir): dvclive = Live("logs") dvclive.log_metric("m1", 1, plot=False) dvclive.make_summary() assert not (tmp_dir / "logs" / "plots" / "metrics" / "m1.tsv").is_file() assert (tmp_dir / dvclive.metrics_file).is_file() s = load_yaml(dvclive.metrics_file) assert s["m1"] == 1 assert "step" not in s @pytest.mark.parametrize("path", ["logs", os.path.join("subdir", "logs")]) def test_logging_step(tmp_dir, path): dvclive = Live(path) dvclive.log_metric("m1", 1) dvclive.next_step() assert (tmp_dir / dvclive.dir).is_dir() assert (tmp_dir / dvclive.plots_dir / Metric.subfolder / "m1.tsv").is_file() assert (tmp_dir / dvclive.metrics_file).is_file() s = load_yaml(dvclive.metrics_file) assert s["m1"] == 1 assert s["step"] == 0 def test_nested_logging(tmp_dir): dvclive = Live("logs") out = tmp_dir / dvclive.plots_dir / Metric.subfolder dvclive.log_metric("train/m1", 1) dvclive.log_metric("val/val_1/m1", 1) dvclive.log_metric("val/val_1/m2", 1) dvclive.next_step() assert (out / "val" / "val_1").is_dir() assert (out / "train" / "m1.tsv").is_file() assert (out / "val" / "val_1" / "m1.tsv").is_file() assert (out / "val" / "val_1" / "m2.tsv").is_file() assert "m1" in parse_tsv(out / "train" / "m1.tsv")[0] assert "m1" in parse_tsv(out / "val" / "val_1" / "m1.tsv")[0] assert "m2" in parse_tsv(out / "val" / "val_1" / "m2.tsv")[0] summary = load_yaml(dvclive.metrics_file) assert summary["train"]["m1"] == 1 assert summary["val"]["val_1"]["m1"] == 1 assert summary["val"]["val_1"]["m2"] == 1 @pytest.mark.parametrize("timestamp", [True, False]) def test_log_metric_timestamp(tmp_dir, timestamp): live = Live() live.log_metric("foo", 1.0, timestamp=timestamp) live.next_step() history, _ = parse_metrics(live) logged = next(iter(history.values())) assert ("timestamp" in logged[0]) == timestamp @pytest.mark.parametrize("invalid_type", [{0: 1}, [0, 1], (0, 1)]) def test_invalid_metric_type(tmp_dir, invalid_type): dvclive = Live() with pytest.raises( InvalidDataTypeError, match=f"Data 'm' has not supported type {type(invalid_type)}", ): dvclive.log_metric("m", invalid_type) @pytest.mark.parametrize( ("val"), [math.inf, math.nan, np.nan, np.inf], ) def test_log_metric_inf_nan(tmp_dir, val): with Live() as live: live.log_metric("metric", val) assert live.summary["metric"] == str(val) def test_log_metic_str(tmp_dir): with Live() as live: live.log_metric("metric", "foo") assert live.summary["metric"] == "foo" ================================================ FILE: tests/test_log_param.py ================================================ import os import pytest from dvclive import Live from dvclive.error import InvalidParameterTypeError from dvclive.serialize import load_yaml def test_cleanup_params(tmp_dir): dvclive = Live("logs") dvclive.log_param("param", 42) assert os.path.isfile(dvclive.params_file) dvclive = Live("logs") assert not os.path.exists(dvclive.params_file) @pytest.mark.parametrize( ("param_name", "param_value"), [ ("param_string", "value"), ("param_int", 42), ("param_float", 42.0), ("param_bool_true", True), ("param_bool_false", False), ("param_list", [1, 2, 3]), ( "param_dict_simple", {"str": "value", "int": 42, "bool": True, "list": [1, 2, 3]}, ), ( "param_dict_nested", { "str": "value", "int": 42, "bool": True, "list": [1, 2, 3], "dict": {"nested-str": "value", "nested-int": 42}, }, ), ], ) def test_log_param(tmp_dir, param_name, param_value): dvclive = Live() dvclive.log_param(param_name, param_value) s = load_yaml(dvclive.params_file) assert s[param_name] == param_value def test_log_params(tmp_dir): dvclive = Live() params = { "param_string": "value", "param_int": 42, "param_float": 42.0, "param_bool_true": True, "param_bool_false": False, } dvclive.log_params(params) s = load_yaml(dvclive.params_file) assert s == params @pytest.mark.parametrize("resume", [False, True]) def test_log_params_resume(tmp_dir, resume): dvclive = Live(resume=resume) dvclive.log_param("param", 42) dvclive = Live(resume=resume) assert ("param" in dvclive._params) == resume def test_log_param_custom_obj(tmp_dir): dvclive = Live("logs") class Dummy: val = 42 param_value = Dummy() with pytest.raises(InvalidParameterTypeError) as excinfo: dvclive.log_param("param_complex", param_value) assert "Dummy" in excinfo.value.args[0] ================================================ FILE: tests/test_logging.py ================================================ import logging from dvclive import Live def test_logger(tmp_dir, mocker): logger = mocker.patch("dvclive.live.logger") live = Live() live.log_metric("foo", 0) logger.debug.assert_called_with("Logged foo: 0") live.next_step() logger.debug.assert_called_with("Step: 1") live.log_metric("foo", 1) live.next_step() live = Live(resume=True) logger.info.assert_called_with("Resuming from step 1") def test_suppress_dvc_logs(tmp_dir, mocked_dvc_repo): Live() assert logging.getLogger("dvc").level == 30 ================================================ FILE: tests/test_make_dvcyaml.py ================================================ import os from pathlib import Path import pytest from PIL import Image from dvclive import Live from dvclive.dvc import make_dvcyaml from dvclive.error import InvalidDvcyamlError from dvclive.serialize import dump_yaml, load_yaml def test_make_dvcyaml_empty(tmp_dir): live = Live(dvcyaml="dvc.yaml") make_dvcyaml(live) assert load_yaml(live.dvc_file) == {} def test_make_dvcyaml_param(tmp_dir): live = Live(dvcyaml="dvc.yaml") live.log_param("foo", 1) make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "params": ["dvclive/params.yaml"], } def test_make_dvcyaml_metrics(tmp_dir): live = Live(dvcyaml="dvc.yaml") live.log_metric("bar", 2) make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["dvclive/metrics.json"], "plots": [{"dvclive/plots/metrics": {"x": "step"}}], } def test_make_dvcyaml_metrics_no_plots(tmp_dir): live = Live(dvcyaml="dvc.yaml") live.log_metric("bar", 2, plot=False) make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["dvclive/metrics.json"], } def test_make_dvcyaml_summary(tmp_dir): live = Live(dvcyaml="dvc.yaml") live.summary["bar"] = 2 make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["dvclive/metrics.json"], } def test_make_dvcyaml_all_plots(tmp_dir): live = Live(dvcyaml="dvc.yaml") live.log_param("foo", 1) live.log_metric("bar", 2) live.log_image("img.png", Image.new("RGB", (10, 10), (250, 250, 250))) live.log_sklearn_plot("confusion_matrix", [0, 0, 1, 1], [0, 1, 1, 0]) live.log_sklearn_plot( "confusion_matrix", [0, 0, 1, 1], [0, 1, 1, 0], name="confusion_matrix_normalized", normalized=True, ) live.log_sklearn_plot("roc", [0, 0, 1, 1], [0.0, 0.5, 0.5, 0.0], "custom_name_roc") make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["dvclive/metrics.json"], "params": ["dvclive/params.yaml"], "plots": [ {"dvclive/plots/metrics": {"x": "step"}}, "dvclive/plots/images", { "dvclive/plots/sklearn/confusion_matrix.json": { "template": "confusion", "x": "actual", "y": "predicted", "title": "Confusion Matrix", "x_label": "True Label", "y_label": "Predicted Label", }, }, { "dvclive/plots/sklearn/confusion_matrix_normalized.json": { "template": "confusion_normalized", "title": "Confusion Matrix", "x": "actual", "x_label": "True Label", "y": "predicted", "y_label": "Predicted Label", } }, { "dvclive/plots/sklearn/custom_name_roc.json": { "template": "simple", "x": "fpr", "y": "tpr", "title": "Receiver operating characteristic (ROC)", "x_label": "False Positive Rate", "y_label": "True Positive Rate", } }, ], } def test_make_dvcyaml_relpath(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() live = Live(dvcyaml="dir/dvc.yaml") live.log_metric("foo", 1) live.log_artifact("model.pth", type="model") make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["../dvclive/metrics.json"], "plots": [{"../dvclive/plots/metrics": {"x": "step"}}], "artifacts": { "model": {"path": "../model.pth", "type": "model"}, }, } @pytest.mark.parametrize( ("orig_yaml", "updated_yaml"), [ pytest.param( {"stages": {"train": {"cmd": "train.py"}}}, { "stages": {"train": {"cmd": "train.py"}}, "metrics": ["dvclive/metrics.json"], "plots": [ {"dvclive/plots/metrics": {"x": "step"}}, ], }, id="stages", ), pytest.param( {"params": ["dvclive/params.yaml"]}, { "metrics": ["dvclive/metrics.json"], "plots": [{"dvclive/plots/metrics": {"x": "step"}}], }, id="drop_extra_sections", ), pytest.param( {"plots": ["dvclive/plots/images"]}, { "metrics": ["dvclive/metrics.json"], "plots": [{"dvclive/plots/metrics": {"x": "step"}}], }, id="drop_unlogged_plots", ), pytest.param( {"plots": [{"dvclive/plots/metrics": {"x": "step", "y": "foo"}}]}, { "metrics": ["dvclive/metrics.json"], "plots": [{"dvclive/plots/metrics": {"x": "step"}}], }, id="plot_props", ), pytest.param( { "plots": [ { "custom": { "x": "step", "y": {"dvclive/plots/metrics": "foo"}, "title": "custom", } }, ], }, { "metrics": ["dvclive/metrics.json"], "plots": [ { "custom": { "x": "step", "y": {"dvclive/plots/metrics": "foo"}, "title": "custom", } }, {"dvclive/plots/metrics": {"x": "step"}}, ], }, id="keep_custom_plots", ), ], ) def test_make_dvcyaml_update(tmp_dir, orig_yaml, updated_yaml): dump_yaml(orig_yaml, "dvc.yaml") live = Live(dvcyaml="dvc.yaml") live.log_metric("foo", 2) make_dvcyaml(live) assert load_yaml(live.dvc_file) == updated_yaml @pytest.mark.parametrize( ("orig_yaml", "updated_yaml"), [ pytest.param( { "artifacts": { "model": { "path": "model.pth", "type": "model", "desc": "best model", }, }, }, { "artifacts": { "model": {"path": "dvclive/artifacts/model.pth", "type": "model"}, }, }, id="props", ), pytest.param( { "artifacts": { "duplicate": {"path": "dvclive/artifacts/model.pth"}, }, }, { "artifacts": { "model": {"path": "dvclive/artifacts/model.pth", "type": "model"}, }, }, id="duplicate", ), pytest.param( { "artifacts": { "data": {"path": "data.csv", "desc": "source data"}, }, }, { "artifacts": { "model": {"path": "dvclive/artifacts/model.pth", "type": "model"}, "data": {"path": "data.csv", "desc": "source data"}, }, }, id="keep_extra", ), ], ) def test_make_dvcyaml_update_artifact( tmp_dir, mocked_dvc_repo, orig_yaml, updated_yaml ): dump_yaml(orig_yaml, "dvc.yaml") (tmp_dir / "model.pth").touch() live = Live(dvcyaml="dvc.yaml") live.log_artifact("model.pth", type="model", copy=True) make_dvcyaml(live) assert load_yaml(live.dvc_file) == updated_yaml def test_make_dvcyaml_update_all(tmp_dir, mocked_dvc_repo): orig_yaml = { "stages": {"train": {"cmd": "train.py"}}, "metrics": [ "dvclive/metrics.json", "dvclive/metrics.yaml", "other/metrics.json", ], "params": ["dvclive/params.yaml"], "plots": [ {"dvclive/plots/metrics": {"x": "step", "y": "foo"}}, "dvclive/plots/images", "other/plots", { "custom": { "x": "step", "y": {"dvclive/plots/metrics": "foo"}, "title": "custom", } }, { "dvclive/plots/sklearn/confusion_matrix.json": { "template": "confusion", "x": "actual", "y": "predicted", "title": "Confusion Matrix", "x_label": "True Label", "y_label": "Predicted Label", }, }, ], "artifacts": { "model": {"path": "dvclive/artifacts/model.pth", "type": "model"}, "duplicate": {"path": "dvclive/artifacts/model.pth"}, "data": {"path": "data.csv", "desc": "source data"}, "other": {"path": "other.pth"}, }, } updated_yaml = { "stages": {"train": {"cmd": "train.py"}}, "metrics": ["other/metrics.json", "dvclive/metrics.json"], "plots": [ "other/plots", { "custom": { "x": "step", "y": {"dvclive/plots/metrics": "foo"}, "title": "custom", } }, {"dvclive/plots/metrics": {"x": "step"}}, "dvclive/plots/images", ], "artifacts": { "model": {"path": "dvclive/artifacts/model.pth", "type": "model"}, "data": {"path": "data.csv", "desc": "source data"}, "other": {"path": "other.pth"}, }, } dump_yaml(orig_yaml, "dvc.yaml") (tmp_dir / "model.pth").touch() (tmp_dir / "data.csv").touch() live = Live(dvcyaml="dvc.yaml") live.log_metric("foo", 2) live.log_image("img.png", Image.new("RGB", (10, 10), (250, 250, 250))) live.log_artifact("model.pth", type="model", copy=True) live.log_artifact("data.csv", desc="source data") make_dvcyaml(live) assert load_yaml(live.dvc_file) == updated_yaml def test_make_dvcyaml_update_multiple(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() live = Live("train", dvcyaml="dvc.yaml") live.log_metric("foo", 2) live.log_artifact("model.pth", type="model", copy=True) make_dvcyaml(live) live = Live("eval", dvcyaml="dvc.yaml") live.log_metric("bar", 3) make_dvcyaml(live) assert load_yaml(live.dvc_file) == { "metrics": ["train/metrics.json", "eval/metrics.json"], "plots": [ {"train/plots/metrics": {"x": "step"}}, {"eval/plots/metrics": {"x": "step"}}, ], "artifacts": { "model": {"path": "train/artifacts/model.pth", "type": "model"}, }, } @pytest.mark.parametrize("dvcyaml", [True, False]) def test_dvcyaml_on_next_step(tmp_dir, dvcyaml, mocked_dvc_repo): live = Live(dvcyaml=dvcyaml) live.next_step() if dvcyaml: assert (tmp_dir / live.dvc_file).exists() else: assert not (tmp_dir / live.dvc_file).exists() @pytest.mark.parametrize("dvcyaml", [True, False]) def test_dvcyaml_on_end(tmp_dir, dvcyaml, mocked_dvc_repo): live = Live(dvcyaml=dvcyaml) live.end() if dvcyaml: assert (tmp_dir / live.dvc_file).exists() else: assert not (tmp_dir / live.dvc_file).exists() def test_make_dvcyaml_idempotent(tmp_dir, mocked_dvc_repo): (tmp_dir / "model.pth").touch() with Live() as live: live.log_artifact("model.pth", type="model") live.make_dvcyaml() assert load_yaml(live.dvc_file) == { "artifacts": { "model": {"path": "model.pth", "type": "model"}, } } @pytest.mark.parametrize("dvcyaml", [True, False, "dvclive/dvc.yaml"]) def test_warn_on_dvcyaml_output_overlap(tmp_dir, mocker, mocked_dvc_repo, dvcyaml): logger = mocker.patch("dvclive.live.logger") dvc_stage = mocker.MagicMock() dvc_stage.addressing = "train" dvc_out = mocker.MagicMock() dvc_out.fs_path = tmp_dir / "dvclive" dvc_stage.outs = [dvc_out] mocked_dvc_repo.index.stages = [dvc_stage] live = Live(dvcyaml=dvcyaml) if dvcyaml == "dvclive/dvc.yaml": msg = f"'{live.dvc_file}' is in outputs of stage 'train'.\n" msg += "Remove it from outputs to make DVCLive work as expected." logger.warning.assert_called_with(msg) else: logger.warning.assert_not_called() @pytest.mark.parametrize( "dvcyaml", [True, False, "dvc.yaml", Path("dvc.yaml")], ) def test_make_dvcyaml(tmp_dir, mocked_dvc_repo, dvcyaml): dvclive = Live("logs", dvcyaml=dvcyaml) dvclive.log_metric("m1", 1) dvclive.next_step() if dvcyaml: assert "metrics" in load_yaml(dvclive.dvc_file) else: assert not os.path.exists(dvclive.dvc_file) dvclive.make_dvcyaml() assert "metrics" in load_yaml(dvclive.dvc_file) def test_make_dvcyaml_no_repo(tmp_dir, mocker): dvclive = Live("logs") dvclive.make_dvcyaml() assert os.path.exists("dvc.yaml") def test_make_dvcyaml_invalid(tmp_dir, mocker): with pytest.raises(InvalidDvcyamlError): Live("logs", dvcyaml="invalid") def test_make_dvcyaml_on_end(tmp_dir, mocker): dvclive = Live("logs") dvclive.end() assert os.path.exists("dvc.yaml") def test_make_dvcyaml_false(tmp_dir, mocker): dvclive = Live("logs", dvcyaml=False) dvclive.end() assert not os.path.exists("dvc.yaml") def test_make_dvcyaml_none(tmp_dir, mocker): dvclive = Live("logs", dvcyaml=None) dvclive.end() assert not os.path.exists("dvc.yaml") ================================================ FILE: tests/test_make_report.py ================================================ import numpy as np import pytest from PIL import Image from dvclive import Live from dvclive.env import DVCLIVE_OPEN from dvclive.error import InvalidReportModeError from dvclive.plots import CustomPlot, Metric from dvclive.plots import Image as LiveImage from dvclive.plots.sklearn import SKLearnPlot from dvclive.report import ( get_custom_plot_renderers, get_image_renderers, get_metrics_renderers, get_params_renderers, get_scalar_renderers, get_sklearn_plot_renderers, ) @pytest.mark.parametrize("mode", ["html", "md", "notebook"]) def test_get_image_renderers(tmp_dir, mode): with Live() as live: img = Image.new("RGB", (10, 10), (255, 0, 0)) live.log_image("image.png", img) image_renderers = get_image_renderers( tmp_dir / live.plots_dir / LiveImage.subfolder ) assert len(image_renderers) == 1 img = image_renderers[0].datapoints[0] assert img["src"].startswith("data:image;base64,") assert img["rev"] == "image.png" def test_get_renderers(tmp_dir, mocker): live = Live() live.log_param("string", "goo") live.log_param("number", 2) for i in range(2): live.log_metric("foo/bar", i) live.next_step() scalar_renderers = get_scalar_renderers(tmp_dir / live.plots_dir / Metric.subfolder) assert len(scalar_renderers) == 1 assert scalar_renderers[0].datapoints == [ { "bar": "0", "rev": "workspace", "step": "0", }, { "bar": "1", "rev": "workspace", "step": "1", }, ] assert scalar_renderers[0].properties["y"] == "bar" assert scalar_renderers[0].properties["title"] == "foo/bar" assert scalar_renderers[0].name == "static/foo/bar" metrics_renderer = get_metrics_renderers(live.metrics_file)[0] assert metrics_renderer.datapoints == [{"step": 1, "foo": {"bar": 1}}] params_renderer = get_params_renderers(live.params_file)[0] assert params_renderer.datapoints == [{"string": "goo", "number": 2}] def test_report_init(monkeypatch, mocker): mocker.patch("dvclive.live.inside_notebook", return_value=False) live = Live(report="notebook") assert live._report_mode is None mocker.patch("dvclive.live.matplotlib_installed", return_value=False) live = Live(report="md") assert live._report_mode is None mocker.patch("dvclive.live.matplotlib_installed", return_value=True) live = Live(report="md") assert live._report_mode == "md" live = Live(report="html") assert live._report_mode == "html" with pytest.raises(InvalidReportModeError, match="Got foo instead\\."): Live(report="foo") @pytest.mark.parametrize("mode", ["html", "md"]) def test_make_report(tmp_dir, mode): last_report = "" live = Live(report=mode) for i in range(3): live.log_metric("foobar", i) live.log_metric("foo/bar", i) live.make_report() live.next_step() assert (tmp_dir / live.report_file).exists() current_report = (tmp_dir / live.report_file).read_text() assert last_report != current_report last_report = current_report @pytest.mark.vscode def test_make_report_open(tmp_dir, mocker, monkeypatch): mocked_open = mocker.patch("webbrowser.open") live = Live() live.log_sklearn_plot("confusion_matrix", [0, 0, 1, 1], [1, 0, 0, 1]) live.make_report() live.make_report() assert not mocked_open.called live = Live(report="html") live.log_metric("foo", 1) live.next_step() assert not mocked_open.called monkeypatch.setenv(DVCLIVE_OPEN, "true") live = Live(report="html") live.log_sklearn_plot("confusion_matrix", [0, 0, 1, 1], [1, 0, 0, 1]) live.make_report() mocked_open.assert_called_once() def test_get_plot_renderers_sklearn(tmp_dir): live = Live() for _ in range(2): live.log_sklearn_plot("confusion_matrix", [0, 0, 1, 1], [1, 0, 0, 1]) live.log_sklearn_plot( "confusion_matrix", [0, 0, 1, 1], [1, 0, 0, 1], name="train/cm" ) live.log_sklearn_plot("roc", [0, 0, 1, 1], [1, 0.1, 0, 1], name="roc_curve") live.log_sklearn_plot( "roc", [0, 0, 1, 1], [1, 0.1, 0, 1], name="other_roc.json" ) live.next_step() plot_renderers = get_sklearn_plot_renderers( tmp_dir / live.plots_dir / SKLearnPlot.subfolder, live ) assert len(plot_renderers) == 4 plot_renderers_dict = { plot_renderer.name: plot_renderer for plot_renderer in plot_renderers } for name in ("roc_curve", "other_roc"): plot_renderer = plot_renderers_dict[name] assert plot_renderer.datapoints == [ {"fpr": 0.0, "rev": "workspace", "threshold": np.inf, "tpr": 0.0}, {"fpr": 0.5, "rev": "workspace", "threshold": 1.0, "tpr": 0.5}, {"fpr": 1.0, "rev": "workspace", "threshold": 0.1, "tpr": 0.5}, {"fpr": 1.0, "rev": "workspace", "threshold": 0.0, "tpr": 1.0}, ] assert plot_renderer.properties == live._plots[name].plot_config for name in ("confusion_matrix", "train/cm"): plot_renderer = plot_renderers_dict[name] assert plot_renderer.datapoints == [ {"actual": "0", "rev": "workspace", "predicted": "1"}, {"actual": "0", "rev": "workspace", "predicted": "0"}, {"actual": "1", "rev": "workspace", "predicted": "0"}, {"actual": "1", "rev": "workspace", "predicted": "1"}, ] assert plot_renderer.properties == live._plots[name].plot_config def test_get_plot_renderers_custom(tmp_dir): live = Live() datapoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] for _ in range(2): live.log_plot("foo_default", datapoints, x="x", y="y") live.log_plot( "foo_scatter", datapoints, x="x", y="y", template="scatter", ) live.next_step() plot_renderers = get_custom_plot_renderers( tmp_dir / live.plots_dir / CustomPlot.subfolder, live ) assert len(plot_renderers) == 2 plot_renderers_dict = { plot_renderer.name: plot_renderer for plot_renderer in plot_renderers } for name in ("foo_default", "foo_scatter"): plot_renderer = plot_renderers_dict[name] assert plot_renderer.datapoints == [ {"rev": "workspace", "x": 1, "y": 2}, {"rev": "workspace", "x": 3, "y": 4}, ] assert plot_renderer.properties == live._plots[name].plot_config def test_report_notebook(tmp_dir, mocker): mocker.patch("dvclive.live.inside_notebook", return_value=True) mocked_display = mocker.MagicMock() mocker.patch("IPython.display.display", return_value=mocked_display) live = Live(report="notebook") assert live._report_mode == "notebook" live.make_report() assert mocked_display.update.called ================================================ FILE: tests/test_make_summary.py ================================================ import json from dvclive import Live from dvclive.plots import Metric def test_make_summary_without_calling_log(tmp_dir): dvclive = Live() dvclive.summary["foo"] = 1.0 dvclive.make_summary() assert json.loads((tmp_dir / dvclive.metrics_file).read_text()) == { # no `step` "foo": 1.0 } log_file = tmp_dir / dvclive.plots_dir / Metric.subfolder / "foo.tsv" assert not log_file.exists() def test_make_summary_is_called_on_end(tmp_dir): live = Live() live.summary["foo"] = 1.0 live.end() assert json.loads((tmp_dir / live.metrics_file).read_text()) == { # no `step` "foo": 1.0 } log_file = tmp_dir / live.plots_dir / Metric.subfolder / "foo.tsv" assert not log_file.exists() def test_make_summary_on_end_dont_increment_step(tmp_dir): with Live() as live: for i in range(2): live.log_metric("foo", i) live.next_step() assert json.loads((tmp_dir / live.metrics_file).read_text()) == { "foo": 1.0, "step": 1, } ================================================ FILE: tests/test_monitor_system.py ================================================ import time from pathlib import Path import dpath import pytest from pytest_voluptuous import S from dvclive import Live from dvclive.monitor_system import ( GIGABYTES_DIVIDER, METRIC_CPU_COUNT, METRIC_CPU_PARALLELIZATION_PERCENT, METRIC_CPU_USAGE_PERCENT, METRIC_DISK_TOTAL_GB, METRIC_DISK_USAGE_GB, METRIC_DISK_USAGE_PERCENT, METRIC_GPU_COUNT, METRIC_GPU_USAGE_PERCENT, METRIC_RAM_TOTAL_GB, METRIC_RAM_USAGE_GB, METRIC_RAM_USAGE_PERCENT, METRIC_VRAM_TOTAL_GB, METRIC_VRAM_USAGE_GB, METRIC_VRAM_USAGE_PERCENT, _SystemMonitor, ) from dvclive.utils import parse_metrics def mock_psutil_cpu(mocker): mocker.patch( "dvclive.monitor_system.psutil.cpu_percent", return_value=[10, 10, 10, 40, 50, 60], ) mocker.patch("dvclive.monitor_system.psutil.cpu_count", return_value=6) def mock_psutil_ram(mocker): mocked_ram = mocker.MagicMock() mocked_ram.percent = 50 mocked_ram.used = 2 * GIGABYTES_DIVIDER mocked_ram.total = 4 * GIGABYTES_DIVIDER mocker.patch( "dvclive.monitor_system.psutil.virtual_memory", return_value=mocked_ram ) def mock_psutil_disk(mocker): mocked_disk = mocker.MagicMock() mocked_disk.percent = 50 mocked_disk.used = 16 * GIGABYTES_DIVIDER mocked_disk.total = 32 * GIGABYTES_DIVIDER mocker.patch("dvclive.monitor_system.psutil.disk_usage", return_value=mocked_disk) def mock_psutil_disk_with_oserror(mocker): mocked_disk = mocker.MagicMock() mocked_disk.percent = 50 mocked_disk.used = 16 * GIGABYTES_DIVIDER mocked_disk.total = 32 * GIGABYTES_DIVIDER mocker.patch( "dvclive.monitor_system.psutil.disk_usage", side_effect=[ mocked_disk, OSError, mocked_disk, OSError, ], ) def mock_pynvml(mocker, num_gpus=2): prefix = "dvclive.monitor_system" mocker.patch(f"{prefix}.GPU_AVAILABLE", bool(num_gpus)) mocker.patch(f"{prefix}.nvmlDeviceGetCount", return_value=num_gpus) mocker.patch(f"{prefix}.nvmlInit", return_value=None) mocker.patch(f"{prefix}.nvmlShutdown", return_value=None) mocker.patch(f"{prefix}.nvmlDeviceGetHandleByIndex", return_value=None) vram_info = mocker.MagicMock() vram_info.used = 3 * 1024**3 vram_info.total = 6 * 1024**3 gpu_usage = mocker.MagicMock() gpu_usage.memory = 5 gpu_usage.gpu = 10 mocker.patch(f"{prefix}.nvmlDeviceGetMemoryInfo", return_value=vram_info) mocker.patch(f"{prefix}.nvmlDeviceGetUtilizationRates", return_value=gpu_usage) @pytest.fixture def cpu_metrics(): content = { METRIC_CPU_COUNT: 6, METRIC_CPU_USAGE_PERCENT: 30.0, METRIC_CPU_PARALLELIZATION_PERCENT: 50.0, METRIC_RAM_USAGE_PERCENT: 50.0, METRIC_RAM_USAGE_GB: 2.0, METRIC_RAM_TOTAL_GB: 4.0, f"{METRIC_DISK_USAGE_PERCENT}/main": 50.0, f"{METRIC_DISK_USAGE_GB}/main": 16.0, f"{METRIC_DISK_TOTAL_GB}/main": 32.0, } result = {} for name, value in content.items(): dpath.new(result, name, value) return result def _timeserie_schema(name, value): return [{name: str(value), "timestamp": str, "step": "0"}] @pytest.fixture def cpu_timeseries(): return { f"{METRIC_CPU_USAGE_PERCENT}.tsv": _timeserie_schema( METRIC_CPU_USAGE_PERCENT.split("/")[-1], 30.0 ), f"{METRIC_CPU_PARALLELIZATION_PERCENT}.tsv": _timeserie_schema( METRIC_CPU_PARALLELIZATION_PERCENT.split("/")[-1], 50.0 ), f"{METRIC_RAM_USAGE_PERCENT}.tsv": _timeserie_schema( METRIC_RAM_USAGE_PERCENT.split("/")[-1], 50.0 ), f"{METRIC_RAM_USAGE_GB}.tsv": _timeserie_schema( METRIC_RAM_USAGE_GB.split("/")[-1], 2.0 ), f"{METRIC_DISK_USAGE_PERCENT}/main.tsv": _timeserie_schema("main", 50.0), f"{METRIC_DISK_USAGE_GB}/main.tsv": _timeserie_schema("main", 16.0), } @pytest.fixture def gpu_timeseries(): return { f"{METRIC_GPU_USAGE_PERCENT}/0.tsv": _timeserie_schema("0", 50.0), f"{METRIC_GPU_USAGE_PERCENT}/1.tsv": _timeserie_schema("1", 50.0), f"{METRIC_VRAM_USAGE_PERCENT}/0.tsv": _timeserie_schema("0", 50.0), f"{METRIC_VRAM_USAGE_PERCENT}/1.tsv": _timeserie_schema("1", 50.0), f"{METRIC_VRAM_USAGE_GB}/0.tsv": _timeserie_schema("0", 3.0), f"{METRIC_VRAM_USAGE_GB}/1.tsv": _timeserie_schema("1", 3.0), } def test_monitor_system_is_false(tmp_dir, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=0) system_monitor_mock = mocker.patch( "dvclive.live._SystemMonitor", spec=_SystemMonitor ) Live(tmp_dir, save_dvc_exp=False, monitor_system=False) system_monitor_mock.assert_not_called() def test_monitor_system_is_true(tmp_dir, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=0) system_monitor_mock = mocker.patch( "dvclive.live._SystemMonitor", spec=_SystemMonitor ) Live(tmp_dir, save_dvc_exp=False, monitor_system=True) system_monitor_mock.assert_called_once() def test_all_threads_close(tmp_dir, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=0) with Live( tmp_dir, save_dvc_exp=False, monitor_system=True, ) as live: first_end_spy = mocker.spy(live._system_monitor, "end") first_end_spy.assert_not_called() live.monitor_system(interval=0.01) first_end_spy.assert_called_once() second_end_spy = mocker.spy(live._system_monitor, "end") # check the monitoring thread is stopped second_end_spy.assert_called_once() def test_ignore_non_existent_directories(tmp_dir, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk_with_oserror(mocker) mock_pynvml(mocker, num_gpus=0) with Live( tmp_dir, save_dvc_exp=False, monitor_system=False, ) as live: non_existent_disk = "/non-existent" system_monitor = _SystemMonitor( live=live, interval=0.1, num_samples=4, directories_to_monitor={"main": "/", "non-existent": non_existent_disk}, ) metrics = system_monitor._get_metrics() system_monitor.end() assert not Path(non_existent_disk).exists() assert f"{METRIC_DISK_USAGE_PERCENT}/non-existent" not in metrics assert f"{METRIC_DISK_USAGE_GB}/non-existent" not in metrics assert f"{METRIC_DISK_TOTAL_GB}/non-existent" not in metrics @pytest.mark.timeout(2) def test_monitor_system_metrics(tmp_dir, cpu_metrics, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=0) with Live( tmp_dir, save_dvc_exp=False, monitor_system=False, ) as live: live.monitor_system(interval=0.05, num_samples=4) # wait for the metrics to be logged. # METRIC_DISK_TOTAL_GB is the last metric to be logged. while len(dpath.search(live.summary, METRIC_DISK_TOTAL_GB)) == 0: time.sleep(0.001) live.next_step() _, latest = parse_metrics(live) schema = {"step": 0, **cpu_metrics} assert latest == S(schema) @pytest.mark.timeout(2) def test_monitor_system_timeseries(tmp_dir, cpu_timeseries, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=0) with Live( tmp_dir, save_dvc_exp=False, monitor_system=False, ) as live: live.monitor_system(interval=0.05, num_samples=4) # wait for the metrics to be logged. # METRIC_DISK_TOTAL_GB is the last metric to be logged. while len(dpath.search(live.summary, METRIC_DISK_TOTAL_GB)) == 0: time.sleep(0.001) live.next_step() timeseries, _ = parse_metrics(live) prefix = Path(tmp_dir) / "plots/metrics" schema = {str(prefix / name): value for name, value in cpu_timeseries.items()} assert timeseries == S(schema) @pytest.mark.timeout(2) def test_monitor_system_metrics_with_gpu(tmp_dir, cpu_metrics, mocker): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=2) with Live( tmp_dir, save_dvc_exp=False, monitor_system=False, ) as live: live.monitor_system(interval=0.05, num_samples=4) # wait for the metrics to be logged. # METRIC_DISK_TOTAL_GB is the last metric to be logged. while len(dpath.search(live.summary, METRIC_DISK_TOTAL_GB)) == 0: time.sleep(0.001) live.next_step() _, latest = parse_metrics(live) schema = {"step": 0, **cpu_metrics} gpu_content = { METRIC_GPU_COUNT: 2, f"{METRIC_GPU_USAGE_PERCENT}": {"0": 50.0, "1": 50.0}, f"{METRIC_VRAM_USAGE_PERCENT}": {"0": 50.0, "1": 50.0}, f"{METRIC_VRAM_USAGE_GB}": {"0": 3.0, "1": 3.0}, f"{METRIC_VRAM_TOTAL_GB}": {"0": 6.0, "1": 6.0}, } for name, value in gpu_content.items(): dpath.new(schema, name, value) assert latest == S(schema) @pytest.mark.timeout(2) def test_monitor_system_timeseries_with_gpu( tmp_dir, cpu_timeseries, gpu_timeseries, mocker ): mock_psutil_cpu(mocker) mock_psutil_ram(mocker) mock_psutil_disk(mocker) mock_pynvml(mocker, num_gpus=2) with Live( tmp_dir, save_dvc_exp=False, monitor_system=False, ) as live: live.monitor_system(interval=0.05, num_samples=4) # wait for the metrics to be logged. # METRIC_DISK_TOTAL_GB is the last metric to be logged. while len(dpath.search(live.summary, METRIC_DISK_TOTAL_GB)) == 0: time.sleep(0.001) live.next_step() timeseries, _ = parse_metrics(live) prefix = Path(tmp_dir) / "plots/metrics" schema = {str(prefix / name): value for name, value in cpu_timeseries.items()} schema.update({str(prefix / name): value for name, value in gpu_timeseries.items()}) assert timeseries == S(schema) ================================================ FILE: tests/test_post_to_studio.py ================================================ import time import unittest from collections import defaultdict from copy import deepcopy from pathlib import Path import pytest from dvc.env import DVC_EXP_GIT_REMOTE from dvc_studio_client import DEFAULT_STUDIO_URL from dvc_studio_client.env import DVC_STUDIO_REPO_URL, DVC_STUDIO_TOKEN from PIL import Image as ImagePIL from dvclive import Live from dvclive.env import DVC_EXP_BASELINE_REV, DVC_EXP_NAME, DVC_ROOT from dvclive.plots import Image, Metric from dvclive.studio import _adapt_image, get_dvc_studio_config, post_to_studio def get_studio_call(event_type, exp_name, **kwargs): data = { "type": event_type, "name": exp_name, "repo_url": "STUDIO_REPO_URL", "baseline_sha": kwargs.pop("baseline_sha", None) or "f" * 40, "client": "dvclive", } | kwargs return { "json": data, "headers": { "Authorization": "token STUDIO_TOKEN", "Content-type": "application/json", }, "timeout": (30, 5), } def test_post_to_studio(tmp_dir, mocked_dvc_repo, mocked_studio_post): live = Live() live.log_param("fooparam", 1) foo_path = (Path(live.plots_dir) / Metric.subfolder / "foo.tsv").as_posix() mocked_post, _ = mocked_studio_post mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("start", exp_name=live._exp_name) ) live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=0, plots={f"{foo_path}": {"data": [{"step": 0, "foo": 1.0}]}}, ), ) live.step += 1 live.log_metric("foo", 2) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=1, plots={f"{foo_path}": {"data": [{"step": 1, "foo": 2.0}]}}, ), ) mocked_post.reset_mock() live.save_dvc_exp() data = live._get_live_data() post_to_studio(live, "done", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "done", exp_name=live._exp_name, experiment_rev=live._experiment_rev ), ) def test_post_to_studio_subrepo(tmp_dir, mocked_dvc_subrepo, mocked_studio_post): live = Live() live.log_param("fooparam", 1) mocked_post, _ = mocked_studio_post mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("start", exp_name=live._exp_name, subdir="subdir"), ) def test_post_to_studio_repo_url(tmp_dir, dvc_repo, mocked_studio_post, monkeypatch): monkeypatch.setenv(DVC_EXP_GIT_REMOTE, "dvc_exp_git_remote") live = Live() live.log_param("fooparam", 1) mocked_post, _ = mocked_studio_post assert mocked_post.call_args.kwargs["json"]["repo_url"] == "dvc_exp_git_remote" def test_post_to_studio_failed_data_request( tmp_dir, mocker, mocked_dvc_repo, mocked_studio_post ): mocked_post, valid_response = mocked_studio_post live = Live() foo_path = (Path(live.plots_dir) / Metric.subfolder / "foo.tsv").as_posix() error_response = mocker.MagicMock() error_response.status_code = 400 mocker.patch("requests.post", return_value=error_response) live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post = mocker.patch("requests.post", return_value=valid_response) live.step += 1 live.log_metric("foo", 2) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=1, plots={ f"{foo_path}": { "data": [{"step": 0, "foo": 1.0}, {"step": 1, "foo": 2.0}] } }, ), ) def test_post_to_studio_failed_start_request( tmp_dir, mocker, mocked_dvc_repo, mocked_studio_post ): mocked_response = mocker.MagicMock() mocked_response.status_code = 400 mocked_post = mocker.patch("requests.post", return_value=mocked_response) live = Live() live.log_metric("foo", 1) live.next_step() live.log_metric("foo", 2) live.next_step() assert mocked_post.call_count == 1 assert live._studio_events_to_skip == {"start", "data", "done"} def test_post_to_studio_done_only_once(tmp_dir, mocked_dvc_repo, mocked_studio_post): mocked_post, _ = mocked_studio_post with Live() as live: live.log_metric("foo", 1) live.next_step() expected_done_calls = [ call for call in mocked_post.call_args_list if call.kwargs["json"]["type"] == "done" ] live.end() actual_done_calls = [ call for call in mocked_post.call_args_list if call.kwargs["json"]["type"] == "done" ] assert expected_done_calls == actual_done_calls def test_post_to_studio_snapshots_data_to_send( tmp_dir, mocked_dvc_repo, mocked_studio_post ): # Tests race condition between main app thread and Studio post thread # where the main thread can be faster in producing metrics than the # Studio post thread in sending them. mocked_post, _ = mocked_studio_post calls = defaultdict(dict) def _long_post(*_, **kwargs): if kwargs["json"]["type"] == "data": # Mock by default doesn't copy lists, dict, we share "body" var in # some calls, thus we can't rely on `mocked_post.call_args_list` json = deepcopy(kwargs)["json"] step = json["step"] for key in ["metrics", "params", "plots"]: if key in json: calls[step][key] = json[key] time.sleep(0.1) return unittest.mock.DEFAULT mocked_post.side_effect = lambda *args, **kwargs: _long_post(*args, **kwargs) live = Live() for i in range(10): live.log_metric("foo", i) live.log_param(f"fooparam-{i}", i) live.log_image(f"foo.{i}.png", ImagePIL.new("RGB", (i + 1, i + 1), (0, 0, 0))) live.next_step() live._wait_for_studio_updates_posted() assert len(calls) == 10 for i in range(10): call = calls[i] assert call["metrics"] == { "dvclive/metrics.json": {"data": {"foo": i, "step": i}} } assert call["params"] == { "dvclive/params.yaml": {f"fooparam-{k}": k for k in range(i + 1)} } # Check below that `plots`` has the following shape # { # 'dvclive/plots/metrics/foo.tsv': {'data': [{'step': i, 'foo': float(i)}]}, # f"dvclive/plots/images/foo.{i}.png": {'image': '...'} # } assert len(call["plots"]) == 2 foo_data = call["plots"]["dvclive/plots/metrics/foo.tsv"]["data"] assert len(foo_data) == 1 assert foo_data[0]["step"] == i assert foo_data[0]["foo"] == pytest.approx(float(i)) assert call["plots"][f"dvclive/plots/images/foo.{i}.png"]["image"] def test_studio_updates_posted_on_end(tmp_path, mocked_dvc_repo, mocked_studio_post): mocked_post, valid_response = mocked_studio_post metrics_file = tmp_path / "metrics.json" metrics_content = "metrics" def long_post(*args, **kwargs): # in case of `data` `long_post` should be called from a separate thread, # meanwhile main thread go forward without slowing down, so if there is no # some kind of wait in the Live main thread, then it will complete before # we even can have a chance to write the file below if kwargs["json"]["type"] == "data": time.sleep(1) metrics_file.write_text(metrics_content) return valid_response mocked_post.side_effect = long_post with Live() as live: live.log_metric("foo", 1) assert metrics_file.read_text() == metrics_content def test_studio_update_raises_exception(tmp_path, mocked_dvc_repo, mocked_studio_post): # Test that if a studio update raises an exception, main process doesn't hang on # queue join in the Live main thread. # https://github.com/iterative/dvclive/pull/864 mocked_post, valid_response = mocked_studio_post def post_raises_exception(*args, **kwargs): if kwargs["json"]["type"] == "data": # We'll hit this sleep only once, other calls are ignored # after the exception is raised time.sleep(1) raise Exception("test exception") # noqa: TRY002, TRY003 return valid_response mocked_post.side_effect = post_raises_exception with Live() as live: live.log_metric("foo", 1) live.log_metric("foo", 2) live.log_metric("foo", 3) # Only 1 data call is made, other calls are ignored after the exception is raised assert mocked_post.call_count == 3 assert [e.kwargs["json"]["type"] for e in mocked_post.call_args_list] == [ "start", "data", "done", ] @pytest.mark.studio def test_post_to_studio_skip_start_and_done_on_env_var( tmp_dir, mocked_dvc_repo, mocked_studio_post, monkeypatch ): mocked_post, _ = mocked_studio_post monkeypatch.setenv(DVC_EXP_BASELINE_REV, "f" * 40) monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) with Live() as live: live.log_metric("foo", 1) live.next_step() call_types = [call.kwargs["json"]["type"] for call in mocked_post.call_args_list] assert "start" not in call_types assert "done" not in call_types @pytest.mark.studio def test_post_to_studio_dvc_studio_config( tmp_dir, mocker, mocked_dvc_repo, mocked_studio_post, monkeypatch ): mocked_post, _ = mocked_studio_post monkeypatch.setenv(DVC_EXP_BASELINE_REV, "f" * 40) monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) monkeypatch.delenv(DVC_STUDIO_TOKEN) mocked_dvc_repo.config = {"studio": {"token": "token"}} with Live() as live: live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) assert mocked_post.call_args.kwargs["headers"]["Authorization"] == "token token" @pytest.mark.studio def test_post_to_studio_skip_if_no_token( tmp_dir, mocker, monkeypatch, mocked_dvc_repo, ): mocked_post = mocker.patch("dvclive.studio.post_live_metrics", return_value=None) monkeypatch.setenv(DVC_EXP_BASELINE_REV, "f" * 40) monkeypatch.setenv(DVC_EXP_NAME, "bar") mocked_dvc_repo.config = {} with Live() as live: live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) assert mocked_post.call_count == 0 def test_post_to_studio_shorten_names(tmp_dir, mocked_dvc_repo, mocked_studio_post): mocked_post, _ = mocked_studio_post live = Live() live.log_metric("eval/loss", 1) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) plots_path = Path(live.plots_dir) loss_path = (plots_path / Metric.subfolder / "eval/loss.tsv").as_posix() mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=0, plots={f"{loss_path}": {"data": [{"step": 0, "loss": 1.0}]}}, ), ) @pytest.mark.studio def test_post_to_studio_inside_dvc_exp( tmp_dir, mocker, monkeypatch, mocked_studio_post, mocked_dvc_repo ): mocked_post, _ = mocked_studio_post monkeypatch.setenv(DVC_EXP_BASELINE_REV, "f" * 40) monkeypatch.setenv(DVC_EXP_NAME, "bar") monkeypatch.setenv(DVC_ROOT, tmp_dir) with Live() as live: live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) call_types = [call.kwargs["json"]["type"] for call in mocked_post.call_args_list] assert "start" not in call_types assert "done" not in call_types @pytest.mark.studio def test_post_to_studio_inside_subdir( tmp_dir, dvc_repo, mocker, monkeypatch, mocked_studio_post, mocked_dvc_repo ): mocked_post, _ = mocked_studio_post subdir = tmp_dir / "subdir" subdir.mkdir() monkeypatch.chdir(subdir) live = Live() live.log_metric("foo", 1) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) foo_path = (Path(live.plots_dir) / Metric.subfolder / "foo.tsv").as_posix() mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", baseline_sha=live._baseline_rev, exp_name=live._exp_name, step=0, plots={f"subdir/{foo_path}": {"data": [{"step": 0, "foo": 1.0}]}}, ), ) @pytest.mark.studio def test_post_to_studio_inside_subdir_dvc_exp( tmp_dir, dvc_repo, monkeypatch, mocked_studio_post, mocked_dvc_repo ): mocked_post, _ = mocked_studio_post subdir = tmp_dir / "subdir" subdir.mkdir() monkeypatch.chdir(subdir) monkeypatch.setenv(DVC_EXP_BASELINE_REV, "f" * 40) monkeypatch.setenv(DVC_EXP_NAME, "bar") live = Live() live.log_metric("foo", 1) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) foo_path = (Path(live.plots_dir) / Metric.subfolder / "foo.tsv").as_posix() mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", baseline_sha=live._baseline_rev, exp_name=live._exp_name, step=0, plots={f"subdir/{foo_path}": {"data": [{"step": 0, "foo": 1.0}]}}, ), ) def test_post_to_studio_without_exp(tmp_dir, mocked_dvc_repo, mocked_studio_post): assert not Live(save_dvc_exp=False)._studio_events_to_skip def test_get_dvc_studio_config_none(mocker): mocker.patch("dvclive.live.get_dvc_repo", return_value=None) live = Live() assert get_dvc_studio_config(live) == {} def test_get_dvc_studio_config_env_var(monkeypatch, mocker): monkeypatch.setenv(DVC_STUDIO_TOKEN, "token") monkeypatch.setenv(DVC_STUDIO_REPO_URL, "repo_url") mocker.patch("dvclive.live.get_dvc_repo", return_value=None) live = Live() assert get_dvc_studio_config(live) == { "token": "token", "repo_url": "repo_url", "url": DEFAULT_STUDIO_URL, } def test_get_dvc_studio_config_dvc_repo(mocked_dvc_repo): mocked_dvc_repo.config = {"studio": {"token": "token", "repo_url": "repo_url"}} live = Live() assert get_dvc_studio_config(live) == { "token": "token", "repo_url": "repo_url", "url": DEFAULT_STUDIO_URL, } def test_post_to_studio_images(tmp_dir, mocked_dvc_repo, mocked_studio_post): mocked_post, _ = mocked_studio_post live = Live() live.log_image("foo.png", ImagePIL.new("RGB", (10, 10), (0, 0, 0))) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) foo_path = (Path(live.plots_dir) / Image.subfolder / "foo.png").as_posix() mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", baseline_sha=live._baseline_rev, exp_name=live._exp_name, step=0, plots={f"{foo_path}": {"image": _adapt_image(foo_path)}}, ), ) def test_post_to_studio_message(tmp_dir, mocked_dvc_repo, mocked_studio_post): live = Live(exp_message="Custom message") mocked_post, _ = mocked_studio_post mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("start", exp_name=live._exp_name, message="Custom message"), ) def test_post_to_studio_name(tmp_dir, mocked_dvc_repo, mocked_studio_post): Live(exp_name="custom-name") mocked_post, _ = mocked_studio_post mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("start", exp_name="custom-name"), ) def test_post_to_studio_if_done_skipped(tmp_dir, mocked_dvc_repo, mocked_studio_post): with Live() as live: live._studio_events_to_skip.add("start") live._studio_events_to_skip.add("done") live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post, _ = mocked_studio_post call_types = [call.kwargs["json"]["type"] for call in mocked_post.call_args_list] assert "data" in call_types @pytest.mark.studio def test_post_to_studio_no_repo(tmp_dir, monkeypatch, mocked_studio_post): monkeypatch.setenv(DVC_STUDIO_TOKEN, "STUDIO_TOKEN") monkeypatch.setenv(DVC_STUDIO_REPO_URL, "STUDIO_REPO_URL") live = Live(save_dvc_exp=True) live.log_param("fooparam", 1) foo_path = (Path(live.plots_dir) / Metric.subfolder / "foo.tsv").as_posix() mocked_post, _ = mocked_studio_post mocked_post.assert_called() mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("start", baseline_sha="0" * 40, exp_name=live._exp_name), ) live.log_metric("foo", 1) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", baseline_sha="0" * 40, exp_name=live._exp_name, step=0, plots={f"{foo_path}": {"data": [{"step": 0, "foo": 1.0}]}}, ), ) live.step += 1 live.log_metric("foo", 2) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", baseline_sha="0" * 40, exp_name=live._exp_name, step=1, plots={f"{foo_path}": {"data": [{"step": 1, "foo": 2.0}]}}, ), ) post_to_studio(live, "done") mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call("done", baseline_sha="0" * 40, exp_name=live._exp_name), ) @pytest.mark.studio def test_post_to_studio_skip_if_no_repo_url( tmp_dir, mocker, monkeypatch, ): mocked_post = mocker.patch("dvclive.studio.post_live_metrics", return_value=None) monkeypatch.setenv(DVC_STUDIO_TOKEN, "token") with Live() as live: live.log_metric("foo", 1) live.step = 0 live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) assert mocked_post.call_count == 0 def test_post_to_studio_repeat_step(tmp_dir, mocked_dvc_repo, mocked_studio_post): # for more context see the PR https://github.com/iterative/dvclive/pull/788 live = Live() prefix = Path(live.plots_dir) / Metric.subfolder foo_path = (prefix / "foo.tsv").as_posix() bar_path = (prefix / "bar.tsv").as_posix() mocked_post, _ = mocked_studio_post live.step = 0 live.log_metric("foo", 1) live.log_metric("bar", 0.1) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=0, plots={ f"{foo_path}": {"data": [{"step": 0, "foo": 1.0}]}, f"{bar_path}": {"data": [{"step": 0, "bar": 0.1}]}, }, ), ) live.log_metric("foo", 2) live.log_metric("foo", 3) live.log_metric("bar", 0.2) live.make_summary() data = live._get_live_data() post_to_studio(live, "data", data) mocked_post.assert_called_with( "https://0.0.0.0/api/live", **get_studio_call( "data", exp_name=live._exp_name, step=0, plots={ f"{foo_path}": { "data": [{"step": 0, "foo": 2.0}, {"step": 0, "foo": 3.0}] }, f"{bar_path}": {"data": [{"step": 0, "bar": 0.2}]}, }, ), ) ================================================ FILE: tests/test_resume.py ================================================ import pytest from dvclive import Live from dvclive.env import DVCLIVE_RESUME from dvclive.utils import read_history, read_latest @pytest.mark.parametrize( ("resume", "steps", "metrics"), [(True, [0, 1, 2, 3], [0.9, 0.8, 0.7, 0.6]), (False, [0, 1], [0.7, 0.6])], ) def test_resume(tmp_dir, resume, steps, metrics): dvclive = Live("logs") for metric in [0.9, 0.8]: dvclive.log_metric("metric", metric) dvclive.next_step() dvclive.log_metric("summary", 1) dvclive.end() assert read_history(dvclive, "metric") == ([0, 1], [0.9, 0.8]) assert read_latest(dvclive, "metric") == (1, 0.8) dvclive = Live("logs", resume=resume) for new_metric in [0.7, 0.6]: dvclive.log_metric("metric", new_metric) dvclive.next_step() dvclive.end() assert read_history(dvclive, "metric") == (steps, metrics) assert read_latest(dvclive, "metric") == (steps[-1], metrics[-1]) if resume: assert dvclive.read_latest()["summary"] == 1 else: assert "summary" not in dvclive.read_latest() def test_resume_on_first_init(tmp_dir): dvclive = Live(resume=True) assert dvclive._step == 0 def test_resume_env_var(tmp_dir, monkeypatch): assert not Live()._resume monkeypatch.setenv(DVCLIVE_RESUME, "true") assert Live()._resume ================================================ FILE: tests/test_step.py ================================================ import os import pytest from dvclive import Live from dvclive.utils import read_history, read_latest @pytest.mark.parametrize("metric", ["m1", os.path.join("train", "m1")]) def test_allow_step_override(tmp_dir, metric): dvclive = Live("logs") dvclive.log_metric(metric, 1.0) dvclive.log_metric(metric, 2.0) def test_custom_steps(tmp_dir): dvclive = Live("logs") steps = [0, 62, 1000] metrics = [0.9, 0.8, 0.7] for step, metric in zip(steps, metrics): dvclive.step = step dvclive.log_metric("m", metric) dvclive.make_summary() assert read_history(dvclive, "m") == (steps, metrics) assert read_latest(dvclive, "m") == (steps[-1], metrics[-1]) def test_log_reset_with_set_step(tmp_dir): dvclive = Live() for i in range(3): dvclive.step = i dvclive.log_metric("train_m", 1) dvclive.make_summary() for i in range(3): dvclive.step = i dvclive.log_metric("val_m", 1) dvclive.make_summary() assert read_history(dvclive, "train_m") == ([0, 1, 2], [1, 1, 1]) assert read_history(dvclive, "val_m") == ([0, 1, 2], [1, 1, 1]) assert read_latest(dvclive, "train_m") == (2, 1) assert read_latest(dvclive, "val_m") == (2, 1) def test_get_step_resume(tmp_dir): dvclive = Live() for metric in [0.9, 0.8]: dvclive.log_metric("metric", metric) dvclive.next_step() assert dvclive.step == 2 dvclive = Live(resume=True) assert dvclive.step == 2 dvclive = Live(resume=False) assert dvclive.step == 0 def test_get_step_custom_steps(tmp_dir): dvclive = Live() steps = [0, 62, 1000] metrics = [0.9, 0.8, 0.7] for step, metric in zip(steps, metrics): dvclive.step = step dvclive.log_metric("x", metric) assert dvclive.step == step def test_get_step_control_flow(tmp_dir): dvclive = Live() while dvclive.step < 10: dvclive.log_metric("i", dvclive.step) dvclive.next_step() steps, values = read_history(dvclive, "i") assert steps == list(range(10)) assert values == [float(x) for x in range(10)] def test_set_step_only(tmp_dir): dvclive = Live() dvclive.step = 1 dvclive.end() assert dvclive.read_latest() == {"step": 1} assert not os.path.exists(os.path.join(tmp_dir, "dvclive", "plots")) def test_step_on_end(tmp_dir): dvclive = Live() for metric in range(3): dvclive.log_metric("m", metric) dvclive.next_step() dvclive.end() assert dvclive.step == metric assert dvclive.read_latest() == {"step": metric, "m": metric} ================================================ FILE: tests/test_utils.py ================================================ import numpy as np import pandas as pd import pytest from dvclive.error import InvalidDataTypeError from dvclive.utils import convert_datapoints_to_list_of_dicts, standardize_metric_name @pytest.mark.parametrize( ("framework", "logged", "standardized"), [ ("dvclive.lightning", "epoch", "epoch"), ("dvclive.lightning", "train_loss", "train/loss"), ("dvclive.lightning", "train_loss_epoch", "train/epoch/loss"), ("dvclive.lightning", "train_model_error", "train/model_error"), ("dvclive.lightning", "grad_step", "grad_step"), ], ) def test_standardize_metric_name(framework, logged, standardized): assert standardize_metric_name(logged, framework) == standardized # Tests for convert_datapoints_to_list_of_dicts() @pytest.mark.parametrize( ("input_data", "expected_output"), [ ( pd.DataFrame({"A": [1, 2], "B": [3, 4]}), [{"A": 1, "B": 3}, {"A": 2, "B": 4}], ), (np.array([[1, 3], [2, 4]]), [{0: 1, 1: 3}, {0: 2, 1: 4}]), ( np.array([(1, 3), (2, 4)], dtype=[("A", "i4"), ("B", "i4")]), [{"A": 1, "B": 3}, {"A": 2, "B": 4}], ), ([{"A": 1, "B": 3}, {"A": 2, "B": 4}], [{"A": 1, "B": 3}, {"A": 2, "B": 4}]), ], ) def test_convert_datapoints_to_list_of_dicts(input_data, expected_output): assert convert_datapoints_to_list_of_dicts(input_data) == expected_output def test_unsupported_format(): with pytest.raises(InvalidDataTypeError) as exc_info: convert_datapoints_to_list_of_dicts("unsupported data format") assert "not supported type" in str(exc_info.value) ================================================ FILE: tests/test_vscode.py ================================================ import json import os import pytest from dvclive import Live, env @pytest.mark.vscode @pytest.mark.parametrize("dvc_root", [True, False]) def test_vscode_dvclive_step_completed_signal_file( tmp_dir, dvc_root, mocker, monkeypatch ): signal_file = os.path.join( tmp_dir, ".dvc", "tmp", "exps", "run", "DVCLIVE_STEP_COMPLETED" ) cwd = tmp_dir test_pid = 12345 if dvc_root: cwd = tmp_dir / ".dvc" / "tmp" / "exps" / "asdasasf" monkeypatch.setenv(env.DVC_ROOT, tmp_dir.as_posix()) (cwd / ".dvc").mkdir(parents=True) assert not os.path.exists(signal_file) dvc_repo = mocker.MagicMock() dvc_repo.index.stages = [] dvc_repo.config = {} dvc_repo.scm.get_rev.return_value = "current_rev" dvc_repo.scm.get_ref.return_value = None dvc_repo.scm.no_commits = False mocker.patch("dvclive.live.get_dvc_repo", return_value=dvc_repo) mocker.patch("dvclive.live.os.getpid", return_value=test_pid) dvclive = Live(save_dvc_exp=True) assert not os.path.exists(signal_file) dvclive.next_step() assert dvclive.step == 1 if dvc_root: assert os.path.exists(signal_file) with open(signal_file, encoding="utf-8") as f: assert json.load(f) == {"pid": test_pid, "step": 0} else: assert not os.path.exists(signal_file) dvclive.next_step() assert dvclive.step == 2 if dvc_root: with open(signal_file, encoding="utf-8") as f: assert json.load(f) == {"pid": test_pid, "step": 1} dvclive.end() assert not os.path.exists(signal_file) @pytest.mark.vscode @pytest.mark.parametrize("dvc_root", [True, False]) def test_vscode_dvclive_only_signal_file(tmp_dir, dvc_root, mocker): signal_file = os.path.join(tmp_dir, ".dvc", "tmp", "exps", "run", "DVCLIVE_ONLY") test_pid = 12345 if dvc_root: (tmp_dir / ".dvc").mkdir(parents=True) assert not os.path.exists(signal_file) dvc_repo = mocker.MagicMock() dvc_repo.index.stages = [] dvc_repo.config = {} dvc_repo.scm.get_rev.return_value = "current_rev" dvc_repo.scm.get_ref.return_value = None dvc_repo.scm.no_commits = False mocker.patch("dvclive.live.get_dvc_repo", return_value=dvc_repo) mocker.patch("dvclive.live.os.getpid", return_value=test_pid) dvclive = Live(save_dvc_exp=True) if dvc_root: assert os.path.exists(signal_file) with open(signal_file, encoding="utf-8") as f: assert json.load(f) == {"pid": test_pid, "exp_name": dvclive._exp_name} else: assert not os.path.exists(signal_file) dvclive.end() assert not os.path.exists(signal_file)