From f919f838c269ddf4ce3ed3874ad03a794a9bef7e Mon Sep 17 00:00:00 2001 From: Weiming Date: Sat, 9 Apr 2022 16:55:21 +0800 Subject: [PATCH] [Release] v0.1.0 --- .dockerignore | 10 + .gitignore | 11 + .readthedocs.yaml | 16 + LICENSE | 201 ++++ Makefile | 14 + README.md | 95 ++ docker/base.Dockerfile | 17 + docker/client.Dockerfile | 9 + docker/docker-compose.yml | 105 ++ docker/run.Dockerfile | 9 + docker/server.Dockerfile | 9 + docker/tracker.Dockerfile | 9 + docs/en/Makefile | 20 + docs/en/_static/css/readthedocs.css | 6 + docs/en/_static/image/architecture.png | Bin 0 -> 319813 bytes docs/en/_static/image/docker.png | Bin 0 -> 46575 bytes docs/en/_static/image/easyfl-logo.png | Bin 0 -> 25810 bytes docs/en/_static/image/registry.png | Bin 0 -> 75632 bytes docs/en/_static/image/training-flow.png | Bin 0 -> 58955 bytes docs/en/api.rst | 40 + docs/en/changelog.md | 5 + docs/en/conf.py | 125 +++ docs/en/faq.md | 8 + docs/en/get_started.md | 87 ++ docs/en/index.rst | 48 + docs/en/introduction.md | 51 + docs/en/make.bat | 35 + docs/en/projects.md | 11 + docs/en/quick_run.md | 49 + docs/en/tutorials/config.md | 318 ++++++ .../tutorials/customize_server_and_client.md | 281 ++++++ docs/en/tutorials/dataset.md | 232 +++++ docs/en/tutorials/distributed_training.md | 42 + docs/en/tutorials/high-level_apis.md | 126 +++ docs/en/tutorials/index.rst | 11 + docs/en/tutorials/model.md | 92 ++ docs/en/tutorials/remote_training.md | 257 +++++ easyfl/__init__.py | 22 + easyfl/client/__init__.py | 5 + easyfl/client/base.py | 471 +++++++++ easyfl/client/service.py | 30 + easyfl/communication/__init__.py | 3 + easyfl/communication/grpc_wrapper.py | 77 ++ easyfl/compression/__init__.py | 0 easyfl/config.yaml | 113 +++ easyfl/coordinator.py | 481 +++++++++ easyfl/datasets/__init__.py | 26 + easyfl/datasets/cifar10/__init__.py | 1 + easyfl/datasets/cifar10/cifar10.py | 88 ++ easyfl/datasets/cifar100/__init__.py | 1 + easyfl/datasets/cifar100/cifar100.py | 88 ++ easyfl/datasets/data.py | 243 +++++ easyfl/datasets/data_process/__init__.py | 0 easyfl/datasets/data_process/cifar10.py | 55 ++ easyfl/datasets/data_process/cifar100.py | 55 ++ easyfl/datasets/data_process/femnist.py | 10 + .../datasets/data_process/language_utils.py | 142 +++ easyfl/datasets/data_process/shakespeare.py | 15 + easyfl/datasets/dataset.py | 427 ++++++++ easyfl/datasets/dataset_util.py | 45 + easyfl/datasets/femnist/__init__.py | 1 + easyfl/datasets/femnist/femnist.py | 109 +++ .../datasets/femnist/preprocess/__init__.py | 0 .../femnist/preprocess/data_to_json.py | 94 ++ .../femnist/preprocess/get_file_dirs.py | 71 ++ .../datasets/femnist/preprocess/get_hashes.py | 55 ++ .../femnist/preprocess/group_by_writer.py | 25 + .../femnist/preprocess/match_hashes.py | 25 + easyfl/datasets/shakespeare/__init__.py | 1 + easyfl/datasets/shakespeare/shakespeare.py | 89 ++ easyfl/datasets/shakespeare/utils/__init__.py | 0 .../shakespeare/utils/gen_all_data.py | 17 + .../utils/preprocess_shakespeare.py | 183 ++++ .../datasets/shakespeare/utils/shake_utils.py | 69 ++ easyfl/datasets/simulation.py | 350 +++++++ easyfl/datasets/utils/__init__.py | 0 easyfl/datasets/utils/base_dataset.py | 158 +++ easyfl/datasets/utils/constants.py | 2 + easyfl/datasets/utils/download.py | 176 ++++ easyfl/datasets/utils/remove_users.py | 62 ++ easyfl/datasets/utils/sample.py | 274 ++++++ easyfl/datasets/utils/split_data.py | 235 +++++ easyfl/datasets/utils/util.py | 42 + easyfl/distributed/__init__.py | 18 + easyfl/distributed/distributed.py | 257 +++++ easyfl/distributed/slurm.py | 64 ++ easyfl/encryption/__init__.py | 0 easyfl/models/__init__.py | 1 + easyfl/models/lenet.py | 36 + easyfl/models/model.py | 24 + easyfl/models/resnet.py | 124 +++ easyfl/models/resnet18.py | 102 ++ easyfl/models/resnet50.py | 107 ++ easyfl/models/rnn.py | 36 + easyfl/models/simple_cnn.py | 40 + easyfl/models/vgg9.py | 65 ++ easyfl/pb/__init__.py | 0 easyfl/pb/client_service_pb2.py | 75 ++ easyfl/pb/client_service_pb2_grpc.py | 66 ++ easyfl/pb/common_pb2.py | 81 ++ easyfl/pb/common_pb2_grpc.py | 4 + easyfl/pb/server_service_pb2.py | 118 +++ easyfl/pb/server_service_pb2_grpc.py | 132 +++ easyfl/pb/tracking_service_pb2.py | 198 ++++ easyfl/pb/tracking_service_pb2_grpc.py | 297 ++++++ easyfl/protocol/__init__.py | 3 + easyfl/protocol/codec.py | 9 + easyfl/registor/Dockerfile | 25 + easyfl/registor/README.md | 60 ++ easyfl/registor/docker-register.service | 15 + easyfl/registor/etcd.tmpl | 37 + easyfl/registry/__init__.py | 4 + easyfl/registry/etcd_client.py | 207 ++++ easyfl/registry/k8s.py | 29 + easyfl/registry/mock_etcd.py | 155 +++ easyfl/registry/registry.py | 31 + easyfl/registry/vclient.py | 5 + easyfl/server/__init__.py | 7 + easyfl/server/base.py | 917 ++++++++++++++++++ easyfl/server/service.py | 143 +++ easyfl/server/strategies.py | 119 +++ easyfl/service.py | 71 ++ easyfl/simulation/__init__.py | 0 easyfl/simulation/mobile_ratio.py | 6 + easyfl/simulation/system_hetero.py | 71 ++ easyfl/tracking/__init__.py | 0 easyfl/tracking/client.py | 242 +++++ easyfl/tracking/client_test.py | 136 +++ easyfl/tracking/evaluation.py | 17 + easyfl/tracking/metric.py | 486 ++++++++++ easyfl/tracking/service.py | 157 +++ easyfl/tracking/storage.py | 340 +++++++ easyfl/utils/__init__.py | 0 easyfl/utils/float.py | 5 + easyfl/version.py | 17 + examples/cifar10.py | 9 + examples/dataset_registration.py | 50 + examples/distributed_slurm.py | 21 + examples/femnist.py | 12 + examples/remote_client.py | 3 + examples/remote_run.py | 58 ++ examples/remote_server.py | 11 + examples/remote_stop.py | 26 + examples/remote_tracker.py | 6 + examples/shakespeare.py | 9 + kubernetes/client.yml | 38 + kubernetes/namespace.yml | 10 + kubernetes/server.yml | 20 + kubernetes/services.yml | 43 + kubernetes/tracker.yml | 20 + protos/easyfl/pb/client_service.proto | 53 + protos/easyfl/pb/common.proto | 65 ++ protos/easyfl/pb/server_service.proto | 63 ++ protos/easyfl/pb/tracking_service.proto | 115 +++ requirements.txt | 1 + requirements/docs.txt | 7 + requirements/readthedocs.txt | 2 + requirements/runtime.txt | 9 + setup.cfg | 3 + setup.py | 131 +++ 160 files changed, 12729 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .readthedocs.yaml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker/base.Dockerfile create mode 100644 docker/client.Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 docker/run.Dockerfile create mode 100644 docker/server.Dockerfile create mode 100644 docker/tracker.Dockerfile create mode 100644 docs/en/Makefile create mode 100644 docs/en/_static/css/readthedocs.css create mode 100644 docs/en/_static/image/architecture.png create mode 100644 docs/en/_static/image/docker.png create mode 100644 docs/en/_static/image/easyfl-logo.png create mode 100644 docs/en/_static/image/registry.png create mode 100644 docs/en/_static/image/training-flow.png create mode 100644 docs/en/api.rst create mode 100644 docs/en/changelog.md create mode 100644 docs/en/conf.py create mode 100644 docs/en/faq.md create mode 100644 docs/en/get_started.md create mode 100644 docs/en/index.rst create mode 100644 docs/en/introduction.md create mode 100644 docs/en/make.bat create mode 100644 docs/en/projects.md create mode 100644 docs/en/quick_run.md create mode 100644 docs/en/tutorials/config.md create mode 100644 docs/en/tutorials/customize_server_and_client.md create mode 100644 docs/en/tutorials/dataset.md create mode 100644 docs/en/tutorials/distributed_training.md create mode 100644 docs/en/tutorials/high-level_apis.md create mode 100644 docs/en/tutorials/index.rst create mode 100644 docs/en/tutorials/model.md create mode 100644 docs/en/tutorials/remote_training.md create mode 100644 easyfl/__init__.py create mode 100644 easyfl/client/__init__.py create mode 100644 easyfl/client/base.py create mode 100644 easyfl/client/service.py create mode 100644 easyfl/communication/__init__.py create mode 100644 easyfl/communication/grpc_wrapper.py create mode 100644 easyfl/compression/__init__.py create mode 100644 easyfl/config.yaml create mode 100644 easyfl/coordinator.py create mode 100644 easyfl/datasets/__init__.py create mode 100644 easyfl/datasets/cifar10/__init__.py create mode 100644 easyfl/datasets/cifar10/cifar10.py create mode 100644 easyfl/datasets/cifar100/__init__.py create mode 100644 easyfl/datasets/cifar100/cifar100.py create mode 100644 easyfl/datasets/data.py create mode 100644 easyfl/datasets/data_process/__init__.py create mode 100644 easyfl/datasets/data_process/cifar10.py create mode 100644 easyfl/datasets/data_process/cifar100.py create mode 100644 easyfl/datasets/data_process/femnist.py create mode 100755 easyfl/datasets/data_process/language_utils.py create mode 100644 easyfl/datasets/data_process/shakespeare.py create mode 100644 easyfl/datasets/dataset.py create mode 100644 easyfl/datasets/dataset_util.py create mode 100644 easyfl/datasets/femnist/__init__.py create mode 100644 easyfl/datasets/femnist/femnist.py create mode 100644 easyfl/datasets/femnist/preprocess/__init__.py create mode 100644 easyfl/datasets/femnist/preprocess/data_to_json.py create mode 100644 easyfl/datasets/femnist/preprocess/get_file_dirs.py create mode 100644 easyfl/datasets/femnist/preprocess/get_hashes.py create mode 100644 easyfl/datasets/femnist/preprocess/group_by_writer.py create mode 100644 easyfl/datasets/femnist/preprocess/match_hashes.py create mode 100644 easyfl/datasets/shakespeare/__init__.py create mode 100644 easyfl/datasets/shakespeare/shakespeare.py create mode 100644 easyfl/datasets/shakespeare/utils/__init__.py create mode 100644 easyfl/datasets/shakespeare/utils/gen_all_data.py create mode 100644 easyfl/datasets/shakespeare/utils/preprocess_shakespeare.py create mode 100644 easyfl/datasets/shakespeare/utils/shake_utils.py create mode 100644 easyfl/datasets/simulation.py create mode 100644 easyfl/datasets/utils/__init__.py create mode 100644 easyfl/datasets/utils/base_dataset.py create mode 100644 easyfl/datasets/utils/constants.py create mode 100644 easyfl/datasets/utils/download.py create mode 100644 easyfl/datasets/utils/remove_users.py create mode 100644 easyfl/datasets/utils/sample.py create mode 100644 easyfl/datasets/utils/split_data.py create mode 100644 easyfl/datasets/utils/util.py create mode 100644 easyfl/distributed/__init__.py create mode 100644 easyfl/distributed/distributed.py create mode 100644 easyfl/distributed/slurm.py create mode 100644 easyfl/encryption/__init__.py create mode 100644 easyfl/models/__init__.py create mode 100644 easyfl/models/lenet.py create mode 100644 easyfl/models/model.py create mode 100644 easyfl/models/resnet.py create mode 100644 easyfl/models/resnet18.py create mode 100644 easyfl/models/resnet50.py create mode 100644 easyfl/models/rnn.py create mode 100644 easyfl/models/simple_cnn.py create mode 100644 easyfl/models/vgg9.py create mode 100644 easyfl/pb/__init__.py create mode 100644 easyfl/pb/client_service_pb2.py create mode 100644 easyfl/pb/client_service_pb2_grpc.py create mode 100644 easyfl/pb/common_pb2.py create mode 100644 easyfl/pb/common_pb2_grpc.py create mode 100644 easyfl/pb/server_service_pb2.py create mode 100644 easyfl/pb/server_service_pb2_grpc.py create mode 100644 easyfl/pb/tracking_service_pb2.py create mode 100644 easyfl/pb/tracking_service_pb2_grpc.py create mode 100644 easyfl/protocol/__init__.py create mode 100644 easyfl/protocol/codec.py create mode 100644 easyfl/registor/Dockerfile create mode 100644 easyfl/registor/README.md create mode 100644 easyfl/registor/docker-register.service create mode 100644 easyfl/registor/etcd.tmpl create mode 100644 easyfl/registry/__init__.py create mode 100644 easyfl/registry/etcd_client.py create mode 100644 easyfl/registry/k8s.py create mode 100644 easyfl/registry/mock_etcd.py create mode 100644 easyfl/registry/registry.py create mode 100644 easyfl/registry/vclient.py create mode 100644 easyfl/server/__init__.py create mode 100644 easyfl/server/base.py create mode 100644 easyfl/server/service.py create mode 100644 easyfl/server/strategies.py create mode 100644 easyfl/service.py create mode 100644 easyfl/simulation/__init__.py create mode 100644 easyfl/simulation/mobile_ratio.py create mode 100644 easyfl/simulation/system_hetero.py create mode 100644 easyfl/tracking/__init__.py create mode 100644 easyfl/tracking/client.py create mode 100644 easyfl/tracking/client_test.py create mode 100644 easyfl/tracking/evaluation.py create mode 100644 easyfl/tracking/metric.py create mode 100644 easyfl/tracking/service.py create mode 100644 easyfl/tracking/storage.py create mode 100644 easyfl/utils/__init__.py create mode 100644 easyfl/utils/float.py create mode 100644 easyfl/version.py create mode 100644 examples/cifar10.py create mode 100644 examples/dataset_registration.py create mode 100644 examples/distributed_slurm.py create mode 100644 examples/femnist.py create mode 100644 examples/remote_client.py create mode 100644 examples/remote_run.py create mode 100644 examples/remote_server.py create mode 100644 examples/remote_stop.py create mode 100644 examples/remote_tracker.py create mode 100644 examples/shakespeare.py create mode 100644 kubernetes/client.yml create mode 100644 kubernetes/namespace.yml create mode 100644 kubernetes/server.yml create mode 100644 kubernetes/services.yml create mode 100644 kubernetes/tracker.yml create mode 100644 protos/easyfl/pb/client_service.proto create mode 100644 protos/easyfl/pb/common.proto create mode 100644 protos/easyfl/pb/server_service.proto create mode 100644 protos/easyfl/pb/tracking_service.proto create mode 100644 requirements.txt create mode 100644 requirements/docs.txt create mode 100644 requirements/readthedocs.txt create mode 100644 requirements/runtime.txt create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b10d56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.vscode +.idea +venv/* +metrics/* +__pycache__ +*_pb2.py +*_pb2_grpc.py +*.tar.gz +*.npy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f41e6a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__ +*.pyc +*.log +*.csv +*.db +*.xls +*.xlsx +*.egg-info +docs/build + + diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..027a774 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +formats: all + +sphinx: + fail_on_warning: false + + +python: + # Install our python package before building the docs + version: 3.7 + install: + - method: pip + path: . + - requirements: requirements/docs.txt + - requirements: requirements/readthedocs.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0853510 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 [EasyFL] [Weiming Zhuang] + + 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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d5a667 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +protobuf: + python -m grpc_tools.protoc -I./protos \ + --python_out=. \ + --grpc_python_out=. \ + protos/easyfl/pb/*.proto + +base_image: + docker build -f docker/base.Dockerfile -t easyfl:base . + +image: + docker build -f docker/client.Dockerfile -t easyfl-client . + docker build -f docker/server.Dockerfile -t easyfl-server . + docker build -f docker/tracker.Dockerfile -t easyfl-tracker . + docker build -f docker/run.Dockerfile -t easyfl-run . diff --git a/README.md b/README.md new file mode 100644 index 0000000..db21000 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +
+ +

EasyFL: A Low-code Federated Learning Platform

+ +[![PyPI](https://img.shields.io/pypi/v/easyfl)](https://pypi.org/project/easyfl) +[![docs](https://img.shields.io/badge/docs-latest-blue)](https://easyfl.readthedocs.io/en/latest/) +[![license](https://img.shields.io/github/license/easyfl-ai/easyfl.svg)](https://github.com/easyfl-ai/easyfl/blob/master/LICENSE) +[![maintained](https://img.shields.io/badge/Maintained%3F-YES-yellow.svg)](https://github.com/easyfl-ai/easyfl/graphs/commit-activity) + +[📘 Documentation](https://easyfl.readthedocs.io/en/latest/) | [🛠️ Installation](https://easyfl.readthedocs.io/en/latest/get_started.html) +
+ +## Introduction + +**EasyFL** is an easy-to-use federated learning (FL) platform based on PyTorch. It aims to enable users with various levels of expertise to experiment and prototype FL applications with little/no coding. + +You can use it for: +* FL Research on algorithm and system +* Proof-of-concept (POC) of new FL applications +* Prototype of industrial applications +* Learning FL implementations + +We currently focus on horizontal FL, supporting both cross-silo and cross-device FL. You can learn more about federated learning from these [resources](https://github.com/weimingwill/awesome-federated-learning#blogs). + +## Major Features + +**Easy to Start** + +EasyFL is easy to install and easy to learn. It does not have complex dependency requirements. You can run EasyFL on your personal computer with only three lines of code ([Quick Start](docs/en/quick_run.md)). + +**Out-of-the-box Functionalities** + +EasyFL provides many out-of-the-box functionalities, including datasets, models, and FL algorithms. With simple configurations, you simulate different FL scenarios using the popular datasets. We support both statistical heterogeneity simulation and system heterogeneity simulation. + +**Flexible, Customizable, and Reproducible** + +EasyFL is flexible to be customized according to your needs. You can easily migrate existing CV or NLP applications into the federated manner by writing the PyTorch codes that you are most familiar with. + +**Multiple Training Modes** + +EasyFL supports **standalone training**, **distributed training**, and **remote training**. By developing the code once, you can easily speed up FL training with distributed training on multiple GPUs. Besides, you can even deploy it to Kubernetes with Docker using remote training. + +## Getting Started + +You can refer to [Get Started](docs/en/get_started.md) for installation and [Quick Run](docs/en/quick_run.md) for the simplest way of using EasyFL. + +For more advanced usage, we provide a list of tutorials on: +* [High-level APIs](docs/en/tutorials/high-level_apis.md) +* [Configurations](docs/en/tutorials/config.md) +* [Datasets](docs/en/tutorials/dataset.md) +* [Models](docs/en/tutorials/model.md) +* [Customize Server and Client](docs/en/tutorials/customize_server_and_client.md) +* [Distributed Training](docs/en/tutorials/distributed_training.md) +* [Remote Training](docs/en/tutorials/remote_training.md) + + +## Projects & Papers + +The following publications are developed using EasyFL. + +- Divergence-aware Federated Self-Supervised Learning, _ICLR'2022_. [[paper]](https://openreview.net/forum?id=oVE1z8NlNe) +- Collaborative Unsupervised Visual Representation Learning From Decentralized Data, _ICCV'2021_. [[paper]](https://openaccess.thecvf.com/content/ICCV2021/html/Zhuang_Collaborative_Unsupervised_Visual_Representation_Learning_From_Decentralized_Data_ICCV_2021_paper.html) +- Joint Optimization in Edge-Cloud Continuum for Federated Unsupervised Person Re-identification, _ACMMM'2021_. [[paper]](https://arxiv.org/abs/2108.06493) + +:bulb: We will release the source codes of these projects in this repository. Please stay tuned. + +We have been doing research on federated learning for several years, the following are our additional publications. + +- EasyFL: A Low-code Federated Learning Platform For Dummies, _IEEE Internet-of-Things Journal_. [[paper]](https://arxiv.org/abs/2105.07603) +- Performance Optimization for Federated Person Re-identification via Benchmark Analysis, _ACMMM'2020_. [[paper]](https://weiming.me/publication/fedreid/) +- Federated Unsupervised Domain Adaptation for Face Recognition, _ICME'22_. [[paper]](https://weiming.me/publication/fedfr/) + +## Join Our Community + +Please join our community on Slack: [easyfl.slack.com](https://easyfl.slack.com) + +We will post updated features and answer questions on Slack. + +## License + +This project is released under the [Apache 2.0 license](LICENSE). + +## Citation + +If you use this platform or related projects in your research, please cite this project. + +``` +@article{zhuang2022easyfl, + title={Easyfl: A low-code federated learning platform for dummies}, + author={Zhuang, Weiming and Gan, Xin and Wen, Yonggang and Zhang, Shuai}, + journal={IEEE Internet of Things Journal}, + year={2022}, + publisher={IEEE} +} +``` diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile new file mode 100644 index 0000000..fecd9ae --- /dev/null +++ b/docker/base.Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.7.7-slim-buster + +WORKDIR /app + +COPY requirements.txt requirements.txt +COPY Makefile Makefile +COPY protos protos + +RUN apt-get update \ + && apt-get install make \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --upgrade pip \ + && pip install -r requirements.txt \ + && rm -rf ~/.cache/pip + +RUN make protobuf \ No newline at end of file diff --git a/docker/client.Dockerfile b/docker/client.Dockerfile new file mode 100644 index 0000000..539c538 --- /dev/null +++ b/docker/client.Dockerfile @@ -0,0 +1,9 @@ +FROM easyfl:base + +WORKDIR /app + +COPY . . + +ENV PYTHONPATH=/app:$PYTHONPATH + +ENTRYPOINT ["python", "examples/remote_client.py"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..a7d5895 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,105 @@ +version: "3" +services: + etcd0: + image: quay.io/coreos/etcd:v3.4.0 + container_name: etcd + ports: + - 23790:2379 + - 23800:2380 + volumes: + - etcd0:/etcd-data + environment: + - ETCD0=localhost + command: + - /usr/local/bin/etcd + - -name + - etcd0 + - --data-dir + - /etcd_data + - -advertise-client-urls + - http://etcd0:2379 + - -listen-client-urls + - http://0.0.0.0:2379 + - -initial-advertise-peer-urls + - http://etcd0:2380 + - -listen-peer-urls + - http://0.0.0.0:2380 + - -initial-cluster + - etcd0=http://etcd0:2380 + networks: + - easyfl + + docker-register: + image: wingalong/docker-register + container_name: docker-regiser + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - HOST_IP=172.25.0.1 + - ETCD_HOST=etcd0:2379 + networks: + - easyfl + depends_on: + - etcd0 + + tracker: + image: easyfl-tracker + container_name: easyfl-tracker + ports: + - "12666:12666" + volumes: + - /home/zwm/easyfl/tracker:/app/tracker + networks: + - easyfl + environment: + - PYTHONUNBUFFERED=1 + + client: + image: easyfl-client + ports: + - "23400-23500:23400" + volumes: + - /home/zwm/easyfl/easyfl/datasets/femnist/data:/app/easyfl/datasets/femnist/data + command: ["--is-remote", "True", "--local-port", "23400", "--server-addr", "easyfl-server:23501", "--tracker-addr", "easyfl-tracker:12666"] + networks: + - easyfl + environment: + - PYTHONUNBUFFERED=1 + depends_on: + - tracker +# - etcd0 +# - docker-register + + server: + image: easyfl-server + container_name: easyfl-server + ports: + - "23501:23501" + command: ["--is-remote", "True", "--local-port", "23501", "--tracker-addr", "easyfl-tracker:12666"] + networks: + - easyfl + environment: + - PYTHONUNBUFFERED=1 + depends_on: + - tracker +# - etcd0 +# - docker-register + +# trigger_run: +# image: easyfl-run +# command: +# - --server-addr +# - 172.21.0.1:23501 +# - --etcd-addr +# - 172.21.0.1:2379 +# networks: +# - easyfl +# depends_on: +# - client +# - server + +volumes: + etcd0: + +networks: + easyfl: \ No newline at end of file diff --git a/docker/run.Dockerfile b/docker/run.Dockerfile new file mode 100644 index 0000000..b4c3d5c --- /dev/null +++ b/docker/run.Dockerfile @@ -0,0 +1,9 @@ +FROM easyfl:base + +WORKDIR /app + +COPY . . + +ENV PYTHONPATH=/app:$PYTHONPATH + +ENTRYPOINT ["python", "examples/remote_run.py"] diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 0000000..fd5fde2 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,9 @@ +FROM easyfl:base + +WORKDIR /app + +COPY . . + +ENV PYTHONPATH=/app:$PYTHONPATH + +ENTRYPOINT ["python", "examples/remote_server.py"] diff --git a/docker/tracker.Dockerfile b/docker/tracker.Dockerfile new file mode 100644 index 0000000..84fa32e --- /dev/null +++ b/docker/tracker.Dockerfile @@ -0,0 +1,9 @@ +FROM easyfl:base + +WORKDIR /app + +COPY . . + +ENV PYTHONPATH=/app:$PYTHONPATH + +ENTRYPOINT ["python", "examples/remote_tracker.py"] \ No newline at end of file diff --git a/docs/en/Makefile b/docs/en/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/en/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/en/_static/css/readthedocs.css b/docs/en/_static/css/readthedocs.css new file mode 100644 index 0000000..82a13e5 --- /dev/null +++ b/docs/en/_static/css/readthedocs.css @@ -0,0 +1,6 @@ +.header-logo { + background-image: url("../image/easyfl-logo.png"); + background-size: 156px 40px; + width: 156px; + height: 40px; +} diff --git a/docs/en/_static/image/architecture.png b/docs/en/_static/image/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..c36583863d6dfa0041fcf3e0dfc532ddb0d81477 GIT binary patch literal 319813 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%0DMa@YZU1yHi>1sss`AKespQe`1 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 Fe*gwRRf+%r literal 0 HcmV?d00001 diff --git a/docs/en/_static/image/docker.png b/docs/en/_static/image/docker.png new file mode 100644 index 0000000000000000000000000000000000000000..03f0f4593c125e698f9c0ab9861df5f55df86f7c GIT binary patch literal 46575 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 literal 0 HcmV?d00001 diff --git a/docs/en/_static/image/registry.png b/docs/en/_static/image/registry.png new file mode 100644 index 0000000000000000000000000000000000000000..93557bebab595f19e71e0ff8bae28d84917cad76 GIT binary patch literal 75632 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

LQ!yh-r$EQ3F@DzxYBvC7WDxTCOF28U9n4lh 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 SbomJQCn_u{RQ&Gqm;VP&3aD5B literal 0 HcmV?d00001 diff --git a/docs/en/_static/image/training-flow.png b/docs/en/_static/image/training-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..92d10d005c7651f4b25733032a8db317db0ddc7b GIT binary patch literal 58955 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 when copying code +copybutton_prompt_text = r'>>> |\.\.\. ' +copybutton_prompt_is_regexp = True + + +# def builder_inited_handler(app): +# subprocess.run(['./stat.py']) + + +# def setup(app): +# app.connect('builder-inited', builder_inited_handler) diff --git a/docs/en/faq.md b/docs/en/faq.md new file mode 100644 index 0000000..13f9fb8 --- /dev/null +++ b/docs/en/faq.md @@ -0,0 +1,8 @@ +# Frequently Asked Questions + +We list some common troubles faced by many users and their corresponding solutions here. Feel free to enrich the list if you find any frequent issues and have ways to help others to solve them. If the contents here do not cover your issue, please create an issue using the [provided templates](https://github.com/EasyFL-AI/easyfl/blob/master/.github/ISSUE_TEMPLATE/error-report.md/) and make sure you fill in all required information in the template. + +## EasyFL Installation + +Waiting for your input :) + diff --git a/docs/en/get_started.md b/docs/en/get_started.md new file mode 100644 index 0000000..9f165a1 --- /dev/null +++ b/docs/en/get_started.md @@ -0,0 +1,87 @@ +## Prerequisites + +- Linux or macOS (Windows is in experimental support) +- Python 3.6+ +- PyTorch 1.3+ +- CUDA 9.2+ (If you run using GPU) + +## Installation + +### Prepare environment + +1. Create a conda virtual environment and activate it. + + ```shell + conda create -n easyfl python=3.7 -y + conda activate easyfl + ``` + +2. Install PyTorch and torchvision following the [official instructions](https://pytorch.org/), e.g., + + ```shell + conda install pytorch torchvision -c pytorch + ``` + or + ```shell + pip install torch==1.10.1 torchvision==0.11.2 + ``` + +4. _You can skip the following CUDA-related content if you plan to run it on CPU._ Make sure that your compilation CUDA version and runtime CUDA version match. + + Note: Make sure that your compilation CUDA version and runtime CUDA version match. + You can check the supported CUDA version for precompiled packages on the [PyTorch website](https://pytorch.org/). + + `E.g.,` 1. If you have CUDA 10.1 installed under `/usr/local/cuda` and would like to install + PyTorch 1.5, you need to install the prebuilt PyTorch with CUDA 10.1. + + ```shell + conda install pytorch cudatoolkit=10.1 torchvision -c pytorch + ``` + + `E.g.,` 2. If you have CUDA 9.2 installed under `/usr/local/cuda` and would like to install + PyTorch 1.3.1., you need to install the prebuilt PyTorch with CUDA 9.2. + + ```shell + conda install pytorch=1.3.1 cudatoolkit=9.2 torchvision=0.4.2 -c pytorch + ``` + + If you build PyTorch from source instead of installing the prebuilt package, + you can use more CUDA versions such as 9.0. + +### Install EasyFL + +```shell +pip install easyfl +``` + +### A from-scratch setup script + +Assuming that you already have CUDA 10.1 installed, here is a full script for setting up MMDetection with conda. + +```shell +conda create -n easyfl python=3.7 -y +conda activate easyfl + +# Without GPU +conda install pytorch==1.6.0 torchvision==0.7.0 -c pytorch -y + +# With GPU +conda install pytorch==1.6.0 torchvision==0.7.0 cudatoolkit=10.1 -c pytorch -y + +# install easyfl +git clone https://github.com/EasyFL-AI/easyfl.git +cd easyfl +pip install -v -e . +``` + +## Verification + +To verify whether EasyFL is installed correctly, we can run the following sample code to test. + +```python +import easyfl + +easyfl.init() +``` + +The above code is supposed to run successfully after you finish the installation. diff --git a/docs/en/index.rst b/docs/en/index.rst new file mode 100644 index 0000000..4327e65 --- /dev/null +++ b/docs/en/index.rst @@ -0,0 +1,48 @@ +Welcome to MMDetection's documentation! +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Introduction + + introduction.md + +.. toctree:: + :maxdepth: 2 + :caption: Get Started + + get_started.md + +.. toctree:: + :maxdepth: 2 + :caption: Quick Run + + quick_run.md + +.. toctree:: + :maxdepth: 2 + :caption: Tutorials + + tutorials/index.rst + + +.. toctree:: + :maxdepth: 2 + :caption: Notes + + projects.md + changelog.md + faq.md + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api.rst + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` diff --git a/docs/en/introduction.md b/docs/en/introduction.md new file mode 100644 index 0000000..3b96875 --- /dev/null +++ b/docs/en/introduction.md @@ -0,0 +1,51 @@ +## Why EasyFL? + +**EasyFL** is an easy-to-use federated learning platform that aims to enable users with various levels of expertise to experiment and prototype FL applications with little/no coding. + +You can use it for: +* FL Research on algorithm and system +* Proof-of-concept (POC) of new FL applications +* Prototype of industrial applications +* Learning FL implementations + +We currently focus on horizontal FL, supporting both cross-silo and cross-device FL. You can learn more about federated learning from these [resources](https://github.com/weimingwill/awesome-federated-learning#blogs). + +### Major Features + +**Easy to Start** + +EasyFL is easy to install and easy to learn. It does not have complex dependency requirements. You can run EasyFL on your personal computer with only three lines of code ([Quick Start](quick_run.md)). + +**Out-of-the-box Functionalities** + +EasyFL provides many out-of-the-box functionalities, including datasets, models, and FL algorithms. With simple configurations, you simulate different FL scenarios using the popular datasets. We support both statistical heterogeneity simulation and system heterogeneity simulation. + +**Flexible, Customizable, and Reproducible** + +EasyFL is flexible to be customized according to your needs. You can easily migrate existing CV or NLP applications into the federated manner by writing the PyTorch codes that you are most familiar with. + +**Multiple Training Modes** + +EasyFL supports **standalone training**, **distributed training**, and **remote training**. By developing the code once, you can easily speed up FL training with distributed training on multiple GPUs. Besides, you can even deploy it to Kubernetes with Docker using remote training. + +We have developed many applications and published several [papers](projects.md) in top-tier conferences and journals using EasyFL. We believe that EasyFL will empower you with FL research and development. + +## Architecture Overview + +Here we introduce the architecture of EasyFL. You can jump directly to [Get Started](get_started.md) without knowing these details. + +EasyFL's architecture comprises of an **interface layer** and a modularized **system layer**. The interface layer provides simple APIs for high-level applications and the system layer has complex implementations to accelerate training and shorten deployment time with out-of-the-box functionalities. + +![architecture](_static/image/architecture.png) + +**Interface Layer**: The interface layer provides a common interface across FL applications. It contains APIs that are designed to encapsulate complex system implementations from users. These APIs decouple application-specific models, datasets, and algorithms such that EasyFL is generic to support a wide range of applications like computer vision and healthcare. + +**System Layer**: The system layer supports and manages the FL life cycle. It consists of eight modules to support FL training pipeline and life cycle: +1. The simulation manager initializes the experimental environment with heterogeneous simulations. +2. The data manager loads training and testing datasets, and the model manager loads the model. +3. A server and the clients start training and testing with FL algorithms such as FedAvg. +4. The distribution manager optimizes the training speed of distributed training. +5. The tracking manager collects the evaluation metrics and provides methods to query training results. +6. The deployment manager seamlessly deploys FL and scales FL applications in production. + +To learn more about EasyFL, you can check out our [paper](https://arxiv.org/abs/2105.07603). diff --git a/docs/en/make.bat b/docs/en/make.bat new file mode 100644 index 0000000..922152e --- /dev/null +++ b/docs/en/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/en/projects.md b/docs/en/projects.md new file mode 100644 index 0000000..11c29b2 --- /dev/null +++ b/docs/en/projects.md @@ -0,0 +1,11 @@ +# Projects based on EasyFL + +We have built several projects based on EasyFL and published four papers in top-tier conferences and journals. +We list them as examples of how to extend EasyFL for your projects. + +- EasyFL: A Low-code Federated Learning Platform For Dummies, _IEEE Internet-of-Things Journal_. [[paper]](https://arxiv.org/abs/2105.07603) +- Divergence-aware Federated Self-Supervised Learning, _ICLR'2022_. [[paper]](https://openreview.net/forum?id=oVE1z8NlNe) +- Collaborative Unsupervised Visual Representation Learning From Decentralized Data, _ICCV'2021_. [[paper]](https://openaccess.thecvf.com/content/ICCV2021/html/Zhuang_Collaborative_Unsupervised_Visual_Representation_Learning_From_Decentralized_Data_ICCV_2021_paper.html) +- Joint Optimization in Edge-Cloud Continuum for Federated Unsupervised Person Re-identification, _ACMMM'2021_. [[paper]](https://arxiv.org/abs/2108.06493) + +If you have built new projects using EasyFL, please feel free to create PR to update this page. diff --git a/docs/en/quick_run.md b/docs/en/quick_run.md new file mode 100644 index 0000000..3f0232e --- /dev/null +++ b/docs/en/quick_run.md @@ -0,0 +1,49 @@ +## High-level Introduction + +EasyFL provides numerous existing models and datasets. Models include LeNet, RNN, VGG9, and ResNet. Datasets include Femnist, Shakespeare, CIFAR-10, and CIFAR-100. +This note will present how to start training with these existing models and standard datasets. + +EasyFL provides three types of high-level APIs: **registration**, **initialization**, and **execution**. +Registration is for registering customized components, which we will introduce in the following notes. +In this note, we focus on **initialization** and **execution**. + +## Simplest Run + +We can run federated learning with only two lines of code (not counting the import statement). +It executes training with default configurations: simulating 100 clients with the FEMNIST dataset and randomly selecting 5 clients for training in each training round. +We explain more about the configurations in [another note](tutorials/config.md). + +Note: we package default partitioning of Femnist data to avoid downloading the whole dataset. + +```python +import easyfl + +# Initialize federated learning with default configurations. +easyfl.init() +# Execute federated learning training. +easyfl.run() +``` + +## Run with Configurations + +You can specify configurations to overwrite the default configurations. + +```python +import easyfl + +# Customized configuration. +config = { + "data": {"dataset": "cifar10", "split_type": "class", "num_of_clients": 100}, + "server": {"rounds": 5, "clients_per_round": 2}, + "client": {"local_epoch": 5}, + "model": "resnet18", + "test_mode": "test_in_server", +} +# Initialize federated learning with default configurations. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + +In the example above, we run training with model ResNet-18 and CIFAR-10 dataset that is partitioned into 100 clients by label `class`. +It runs training with 2 clients per round for 5 rounds. In each round, each client trains 5 epochs. diff --git a/docs/en/tutorials/config.md b/docs/en/tutorials/config.md new file mode 100644 index 0000000..6d6cd04 --- /dev/null +++ b/docs/en/tutorials/config.md @@ -0,0 +1,318 @@ +# Tutorial 2: Configurations + +Configurations in EasyFL are to control and config the federated learning (FL) training behavior. It instructs data simulation, the model for training, training hyperparameters, distributed training, etc. + +We provide [default configs](#default-configurations) in EasyFL, while there are two ways you can modify the configs of EasyFL: using Python and using a yaml file. + +## Modify Config + +EasyFL provides two ways to modify the configurations: using Python dictionary and using a yaml file. Either way, if the new configs exist in the default configuration, they overwrite those specific fields. If the new configs do not exist, it adds them to the EasyFL configuration. Thus, you can either modify the default configurations or add new configurations based on your application needs. + +### 1. Modify Using Python Dictionary + +You can create a new Python dictionary to specify configurations. These configs take effect when you initialize EasyFL with them by calling `easyfl.init(config)`. + +The examples provided in the previous [tutorial](high-level_apis.md) demonstrate how to modify config via a Python dictionary. +```python +import easyfl + +# Define customized configurations. +config = { + "data": { + "dataset": "cifar10", + "num_of_clients": 1000 + }, + "server": { + "rounds": 5, + "clients_per_round": 2 + }, + "client": {"local_epoch": 5}, + "model": "resnet18", + "test_mode": "test_in_server", +} +# Initialize EasyFL with the new config. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + +### 2. Modify Using A Yaml File + +You can create a new yaml file named `config.yaml` for configuration and load them into EasyFL. + +```python +import easyfl +# Define customized configurations in a yaml file. +config_file = "config.yaml" +# Load the yaml file as config. +config = easyfl.load_config(config_file) +# Initialize EasyFL with the new config. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + +You can also combine these two methods of modifying configs. + +```python +import easyfl + +# Define part of customized configs. +config = { + "data": { + "dataset": "cifar10", + "num_of_clients": 1000 + }, + "server": { + "rounds": 5, + "clients_per_round": 2 + }, + "client": {"local_epoch": 5}, + "model": "resnet18", + "test_mode": "test_in_server", +} + +# Define part of configs in a yaml file. +config_file = "config.yaml" +# Load and combine these two configs. +config = easyfl.load_config(config_file, config) +# Initialize EasyFL with the new config. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + +## A Common Practice to Modify Configuration + +Since some configurations are directly related to training, we may need to set them dynamically with different values. + +For example, we may want to experiment with the effect of batch size and local epoch on federated learning. Instead of changing the value manually each time in configuration, you can pass the value in as command-line arguments and set the value with different commands. + +```python +import easyfl +import argparse + +# Define command line arguments. +parser = argparse.ArgumentParser(description='Example') +parser.add_argument("--batch_size", type=int, default=32) +parser.add_argument("--local_epoch", type=int, default=5) +args = parser.parse_args() +print("args", args) + +# Define customized configurations using the arguments. +config = { + "client": { + "batch_size": args.batch_size, + "local_epoch": args.local_epoch, + } +} +# Initialize EasyFL with the new config. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + + +## Default Configurations + +The followings are the default configurations in EasyFL. +They are copied from `easyfl/config.yaml` on April, 2022. + +We provide more details on how to simulate different FL scenarios with the out-of-the-box datasets in [another note](dataset.md). + +```yaml +# The unique identifier for each federated learning task +task_id: "" + +# Provide dataset and federated learning simulation related configuration. +data: + # The root directory where datasets are stored. + root: "./data/" + # The name of the dataset, support: femnist, shakespeare, cifar10, and cifar100. + dataset: femnist + # The data distribution of each client, support: iid, niid (for femnist and shakespeare), and dir and class (for cifar datasets). + # `iid` means independent and identically distributed data. + # `niid` means non-independent and identically distributed data for Femnist and Shakespeare. + # `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + # `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + split_type: "iid" + + # The minimal number of samples in each client. It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + min_size: 10 + # The fraction of data sampled for LEAF datasets. e.g., 10% means that only 10% of total dataset size are used. + data_amount: 0.05 + # The fraction of the number of clients used when the split_type is 'iid'. + iid_fraction: 0.1 + # Whether partition users of the dataset into train-test groups. Only applicable to femnist and shakespeare datasets. + # True means partitioning users of the dataset into train-test groups. + # False means partitioning each users' samples into train-test groups. + user: False + # The fraction of data for training; the rest are for testing. + train_test_split: 0.9 + + # The number of classes in each client. Only applicable when the split_type is 'class'. + class_per_client: 1 + # The targeted number of clients to construct.used in non-leaf dataset, number of clients split into. for leaf dataset, only used when split type class. + num_of_clients: 100 + + # The parameter for Dirichlet distribution simulation, applicable only when split_type is `dir` for CIFAR datasets. + alpha: 0.5 + + # The targeted distribution of quantities to simulate data quantity heterogeneity. + # The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + # The `num_of_clients` should be divisible by `len(weights)`. + # None means clients are simulated with the same data quantity. + weights: NULL + +# The name of the model for training, support: lenet, rnn, resnet, resnet18, resnet50, vgg9. +model: lenet +# How to conduct testing, options: test_in_client or test_in_server. + # `test_in_client` means that each client has a test set to run testing. + # `test_in_server` means that server has a test set to run testing for the global model. Use this mode for cifar datasets. +test_mode: "test_in_client" +# The way to measure testing performance (accuracy) when test mode is `test_in_client`, support: average or weighted (means weighted average). +test_method: "average" + +server: + track: False # Whether track server metrics using the tracking service. + rounds: 10 # Total training round. + clients_per_round: 5 # The number of clients to train in each round. + test_every: 1 # The frequency of testing: conduct testing every N round. + save_model_every: 10 # The frequency of saving model: save model every N round. + save_model_path: "" # The path to save model. Default path is root directory of the library. + batch_size: 32 # The batch size of test_in_server. + test_all: True # Whether test all clients or only selected clients. + random_selection: True # Whether select clients to train randomly. + # The strategy to aggregate client uploaded models, options: FedAvg, equal. + # FedAvg aggregates models using weighted average, where the weights are data size of clients. + # equal aggregates model by simple averaging. + aggregation_stragtegy: "FedAvg" + # The content of aggregation, options: all, parameters. + # all means aggregating models using state_dict, including both model parameters and persistent buffers like BatchNorm stats. + # parameters means aggregating only model parameters. + aggregation_content: "all" + +client: + track: False # Whether track server metrics using the tracking service. + batch_size: 32 # The batch size of training in client. + test_batch_size: 5 # The batch size of testing in client. + local_epoch: 10 # The number of epochs to train in each round. + optimizer: + type: "Adam" # The name of the optimizer, options: Adam, SGD. + lr: 0.001 + momentum: 0.9 + weight_decay: 0 + seed: 0 + local_test: False # Whether test the trained models in clients before uploading them to the server. + +gpu: 0 # The total number of GPUs used in training. 0 means CPU. +distributed: # The distributed training configurations. It is only applicable when gpu > 1. + backend: "nccl" # The distributed backend. + init_method: "" + world_size: 0 + rank: 0 + local_rank: 0 + +tracking: # The configurations for logging and tracking. + database: "" # The path of local dataset, sqlite3. + log_file: "" + log_level: "INFO" # The level of logging. + metric_file: "" + save_every: 1 + +# The configuration for system heterogeneity simulation. +resource_heterogeneous: + simulate: False # Whether simulate system heterogeneity in federated learning. + # The type of heterogeneity to simulate, support iso, dir, real. + # iso means that + hetero_type: "real" + level: 3 # The level of heterogeneous (0-5), 0 means no heterogeneous among clients. + sleep_group_num: 1000 # The number of groups with different sleep time. 1 means all clients are the same. + total_time: 1000 # The total sleep time of all clients, unit: second. + fraction: 1 # The fraction of clients attending heterogeneous simulation. + grouping_strategy: "greedy" # The grouping strategy to handle system heterogeneity, support: random, greedy, slowest. + initial_default_time: 5 # The estimated default training time for each training round, unit: second. + default_time_momentum: 0.2 # The default momentum for default time update. + +seed: 0 # The random seed. +``` + +### Default Config without Comments + +```yaml +task_id: "" +data: + root: "./data/" + dataset: femnist + split_type: "iid" + + min_size: 10 + data_amount: 0.05 + iid_fraction: 0.1 + user: False + + class_per_client: 1 + num_of_clients: 100 + train_test_split: 0.9 + alpha: 0.5 + + weights: NULL + +model: lenet +test_mode: "test_in_client" +test_method: "average" + +server: + track: False + rounds: 10 + clients_per_round: 5 + test_every: 1 + save_model_every: 10 + save_model_path: "" + batch_size: 32 + test_all: True + random_selection: True + aggregation_stragtegy: "FedAvg" + aggregation_content: "all" + +client: + track: False + batch_size: 32 + test_batch_size: 5 + local_epoch: 10 + optimizer: + type: "Adam" + lr: 0.001 + momentum: 0.9 + weight_decay: 0 + seed: 0 + local_test: False + +gpu: 0 +distributed: + backend: "nccl" + init_method: "" + world_size: 0 + rank: 0 + local_rank: 0 + +tracking: + database: "" + log_file: "" + log_level: "INFO" + metric_file: "" + save_every: 1 + +resource_heterogeneous: + simulate: False + hetero_type: "real" + level: 3 + sleep_group_num: 1000 + total_time: 1000 + fraction: 1 + grouping_strategy: "greedy" + initial_default_time: 5 + default_time_momentum: 0.2 + +seed: 0 +``` diff --git a/docs/en/tutorials/customize_server_and_client.md b/docs/en/tutorials/customize_server_and_client.md new file mode 100644 index 0000000..b349157 --- /dev/null +++ b/docs/en/tutorials/customize_server_and_client.md @@ -0,0 +1,281 @@ +# Tutorial 5: Customize Server and Client + +EasyFL abstracts the federated learning (FL) training flow in the server and the client into granular stages, as shown in the image below. + +![Training Flow](../_static/image/training-flow.png) + +You have the flexibility to customize any stage of the training flow while reusing the rest by implementing a customized server/client. + +## Customize Server + +EasyFL implements random client selection and [Federated Averaging](https://arxiv.org/abs/1602.05629) as the aggregation strategy. +You can customize the server implementation by inheriting [BaseServer](../api.html#easyfl.server.BaseServer) and override specific functions. + +Below is an example of a customized server. +```python +import easyfl +from easyfl.server import BaseServer +from easyfl.server.base import MODEL + +class CustomizedServer(BaseServer): + def __init__(self, conf, **kwargs): + super(CustomizedServer, self).__init__(conf, **kwargs) + pass # more initialization of attributes. + + def aggregation(self): + uploaded_content = self.get_client_uploads() + models = list(uploaded_content[MODEL].values()) + # Original implementation of aggregation weights + # weights = list(uploaded_content[DATA_SIZE].values()) + # We can assign the manipulated customized weights in aggregation. + customized_weights = list(range(len(models))) + model = self.aggregate(models, customized_weights) + self.set_model(model, load_dict=True) + +# Register customized server. +easyfl.register_server(CustomizedServer) +# Initialize federated learning with default configurations. +easyfl.init() +# Execute federated learning training. +easyfl.run() +``` + +Here we list down more useful functions to override to implement a customized server. + +```python +import easyfl +from easyfl.server import BaseServer + +class CustomizedServer(BaseServer): + def __init__(self, conf, **kwargs): + super(CustomizedServer, self).__init__(conf, **kwargs) + pass # more initialization of attributes. + + def selection(self, clients, clients_per_round): + pass # implement customized client selection algorithm. + + def compression(self): + pass # implement customized compression algorithm. + + def pre_train(self): + pass # inject operations before distribution to train. + + def post_train(self): + pass # inject operations after aggregation. + + def pre_test(self): + pass # inject operations before distribution to test. + + def post_test(self): + pass # inject operations after aggregating testing results. + + def decompression(self, model): + pass # implement customized decompression algorithm. + + def aggregation(self): + pass # implement customized aggregation algorithm. +``` + +Below are some attributes that you may need in implementing the customized server. + +`self.conf`: Configurations of EasyFL. + +`self._model`: The global model in server, updated after aggregation. + +`self._current_round`: The current training round. + +`self._clients`: All available clients. + +`self.selected_clients`: The selected clients. + +You may refer to the [BaseServer](../api.html#easyfl.server.BaseServer) for more functions and class attributes. + +## Customize Client + +Each client of EasyFL conducts training and testing. +The implementation of training and testing is similar to normal PyTorch implementation. +We implement training with Adam/SGD optimizer using CrossEntropy loss. +You can customize client implementation of training and testing by inheriting [BaseClient](../api.html#easyfl.client.BaseClient) and overriding specific functions. + +Below is an example of a customized client. + +```python +import time +import easyfl +from torch import nn +import torch.optim as optim +from easyfl.client.base import BaseClient + +# Inherit BaseClient to implement customized client operations. +class CustomizedClient(BaseClient): + def __init__(self, cid, conf, train_data, test_data, device, **kwargs): + super(CustomizedClient, self).__init__(cid, conf, train_data, test_data, device, **kwargs) + # Initialize a classifier for each client. + self.classifier = nn.Sequential(*[nn.Linear(512, 100)]) + + def train(self, conf, device): + start_time = time.time() + self.model.classifier.classifier = self.classifier.to(device) + loss_fn, optimizer = self.pretrain_setup(conf, device) + self.train_loss = [] + for i in range(conf.local_epoch): + batch_loss = [] + for batched_x, batched_y in self.train_loader: + x, y = batched_x.to(device), batched_y.to(device) + optimizer.zero_grad() + out = self.model(x) + loss = loss_fn(out, y) + loss.backward() + optimizer.step() + batch_loss.append(loss.item()) + current_epoch_loss = sum(batch_loss) / len(batch_loss) + self.train_loss.append(float(current_epoch_loss)) + self.train_time = time.time() - start_time + # Keep the classifier in clients and upload only the backbone of model. + self.classifier = self.model.classifier.classifier + self.model.classifier.classifier = nn.Sequential() + + # A customized optimizer that sets different learning rates for different model parts. + def load_optimizer(self, conf): + ignored_params = list(map(id, self.model.classifier.parameters())) + base_params = filter(lambda p: id(p) not in ignored_params, self.model.parameters()) + optimizer = optim.SGD([ + {'params': base_params, 'lr': 0.1 * conf.optimizer.lr}, + {'params': self.model.classifier.parameters(), 'lr': conf.optimizer.lr} + ], weight_decay=5e-4, momentum=conf.optimizer.momentum, nesterov=True) + return optimizer + +# Register customized client. +easyfl.register_client(CustomizedClient) +# Initialize federated learning with default configurations. +easyfl.init() +# Execute federated learning training. +easyfl.run() +``` + +Here we list down more useful functions to override to implement a customized client. + +```python +import easyfl +from easyfl.client import BaseClient + +# Inherit BaseClient to implement customized client operations. +class CustomizedClient(BaseClient): + def __init__(self, cid, conf, train_data, test_data, device, **kwargs): + super(CustomizedClient, self).__init__(cid, conf, train_data, test_data, device, **kwargs) + pass # more initialization of attributes. + + def decompression(self): + pass # implement decompression method. + + def pre_train(self): + pass # inject operations before training. + + def train(self, conf, device): + pass # implement customized training method. + + def post_train(self): + pass # inject operations after training. + + def load_loss_fn(self, conf): + pass # load a customized loss function. + return loss + + def load_optimizer(self, conf): + pass # load a customized optimizer + return optimizer + + def load_loader(self, conf): + pass # load a customized data loader. + return train_loader + + def test_local(self): + pass # implement testing of the trained model before uploading to the server. + + def pre_test(self): + pass # inject operations before testing. + + def test(self, conf, device): + pass # implement customized testing. + + def post_test(self): + pass # inject operations after testing. + + def encryption(self): + pass # implement customized encryption method. + + def compression(self): + pass # implement customized compression method. + + def upload(self): + pass # implement customized upload method. + + def post_upload(self): + pass # implement customized post upload method. +``` + +Below are some attributes that you may need in implementing the customized client. + +`self.conf`: Configurations of client, under key "client" of config dictionary. + +`self.compressed_model`: The model downloaded from the server. + +`self.model`: The model used for training. + +`self.cid`: The client id. + +`self.device`: The device for training. + +`self.train_data`: The training data of the client. + +`self.test_data`: The testing data of the client. + +You may refer to the [BaseClient](../api.html#easyfl.client.BaseClient) for more functions and class attributes. + +## Existing Works + +We surveyed 33 papers from recent publications of FL from both the machine learning and system community. +The following table shows that 10 out of 33 (~30%) publications propose new algorithms with changes in only one stage of the training flow, and the majority (~57%) change only two stages. +Training flow abstraction you to focus on the problems, without re-implementing the whole FL process. + +Annotation of the table: + +_Server stages_: **Sel** -- Selection, **Com** -- Compression, **Agg** -- Aggregation + +_Client stages_: **Train**, **Com** -- Compression, **Enc** -- Encryption + +| Revenue | Title | Sel | Com | Agg | Train | Com | Enc | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | ---: | +| INFOCOM'20 | Optimizing Federated Learning on Non-IID Data with Reinforcement Learning | ✓ | | | | | | | +| OSDI'21 | Oort: Informed Participant Selection for Scalable Federated Learning | ✓ | | | | | | | +| HPDC'20 | TiFL: A Tier-based Federated Learning System | ✓ | | | | | | | +| IoT'21 | FedMCCS: Multicriteria Client Selection Model for Optimal IoT Federated Learning | ✓ | | | | | | | +| KDD'20 | FedFast: Going Beyond Average for Faster Training of Federated Recommender Systems | ✓ | | ✓ | | | | | +| TNNLS 2019 | Robust and Communication-Efficient Federated Learning From Non-i.i.d. Data | | ✓ | | | | ✓ | | +| NIPS'20 | Ensemble Distillation for Robust Model Fusion in Federated Learning | | | ✓ | | | | | +| ICDCS 2019 | CMFL: Mitigating Communication Overhead for Federated Learning | | | | | | ✓ | | +| ICML'20 | FetchSGD: Communication-Efficient Federated Learning with Sketching | | | ✓ | | | ✓ | | +| ICML'20 | SCAFFOLD: Stochastic Controlled Averaging for Federated Learning | | | ✓ | | | ✓ | | +| TPDS'20 | FedSCR: Structure-Based Communication Reduction for Federated Learning | | | ✓ | | | ✓ | | +| HotEdge 2018 | eSGD: Communication Efficient Distributed Deep Learning on the Edge | | | | | | ✓ | | +| ICML'20 | Adaptive Federated Optimization | | | | | ✓ | | | +| CVPR'21 | Privacy-preserving Collaborative Learning with Automatic Transformation Search | | | | | ✓ | | | +| MLSys'20 | Federated Optimization in Heterogeneous Networks | | | | | ✓ | | | +| ICLR'20 | Federated Learning with Matched Averaging | | | ✓ | | ✓ | | | +| ACMMM'20 | Performance Optimization for Federated Person Re-identification via Benchmark Analysis | | | ✓ | | ✓ | | | +| NIPS'20 | Distributionally Robust Federated Averaging | | | ✓ | | ✓ | | | +| NIPS'20 | Group Knowledge Transfer: Federated Learning of Large CNNs at the Edge | | | ✓ | | ✓ | | | +| NIPS'20 | Personalized Federated Learning with Moreau Envelopes | | | ✓ | | ✓ | | | +| ICLR'20 | Fair Resource Allocation in Federated Learning | | | ✓ | | ✓ | | | +| ICML'20 | Federated Learning with Only Positive Labels | | | ✓ | | ✓ | | | +| AAAI'21 | Addressing Class Imbalance in Federated Learning | | | ✓ | | ✓ | | | +| AAAI'21 | Federated Block Coordinate Descent Scheme for Learning Global and Personalized Models | | | ✓ | | ✓ | | | +| IoT'20 | Toward Communication-Efficient Federated Learning in the Internet of Things With Edge Computing | | | ✓ | | ✓ | ✓ | | +| ICML'20 | Acceleration for Compressed Gradient Descent in Distributed and Federated Optimization | | | ✓ | | ✓ | ✓ | | +| INFOCOMM 2018 | When Edge Meets Learning: Adaptive Control for Resource-Constrained Distributed Machine Learning | | | ✓ | | ✓ | | | +| ATC'20 | BatchCrypt: Efficient Homomorphic Encryption for Cross-Silo Federated Learning | | | ✓ | | | | ✓ | +| AAAI'21 | FLAME: Differentially Private Federated Learning in the Shuffle Model | | | ✓ | | | | ✓ | +| TIFS'20 | Federated Learning with Differential Privacy: Algorithms and Performance Analysis | | | ✓ | | | | ✓ | +| GLOBECOM'20 | Towards Efficient Secure Aggregation for Model Update in Federated Learning | | | ✓ | | | | ✓ | +| MobiCom'20 | Billion-Scale Federated Learning on Mobile Clients: A Submodel Design with Tunable Privacy | | | ✓ | | ✓ | | ✓ | +| IoT'20 | Privacy-Preserving Federated Learning in Fog Computing | | | ✓ | | ✓ | | ✓ | diff --git a/docs/en/tutorials/dataset.md b/docs/en/tutorials/dataset.md new file mode 100644 index 0000000..e55ee2e --- /dev/null +++ b/docs/en/tutorials/dataset.md @@ -0,0 +1,232 @@ +# Tutorial 3: Datasets + +In this note, we present how to use the out-of-the-box datasets to simulate different federated learning (FL) scenarios. +Besides, we introduce how to use the customized dataset in EasyFL. + +We currently provide four out-of-the-box datasets: FEMNIST, Shakespeare, CIFAR-10, and CIFAR-100. FEMNIST and +Shakespeare are adopted from [LEAF benchmark](https://leaf.cmu.edu/). We plan to integrate and provide more +out-of-the-box datasets in the future. + +## Out-of-the-box Datasets + +The simulation of different FL scenarios is configured in the configurations. You can refer to the +other [tutorial](config.md) to learn more about how to modify configs. In this note, we focus on how to config the +datasets with different simulations. + +The following are dataset configurations. + +```yaml +data: + # The root directory where datasets are stored. + root: "./data/" + # The name of the dataset, support: femnist, shakespeare, cifar10, and cifar100. + dataset: femnist + # The data distribution of each client, support: iid, niid (for femnist and shakespeare), and dir and class (for cifar datasets). + # `iid` means independent and identically distributed data. + # `niid` means non-independent and identically distributed data for FEMNIST and Shakespeare. + # `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + # `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + split_type: "iid" + + # The minimal number of samples in each client. It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + min_size: 10 + # The fraction of data sampled for LEAF datasets. e.g., 10% means that only 10% of the total dataset size is used. + data_amount: 0.05 + # The fraction of the number of clients used when the split_type is 'iid'. + iid_fraction: 0.1 + # Whether partition users of the dataset into train-test groups. Only applicable to femnist and shakespeare datasets. + # True means partitioning users of the dataset into train-test groups. + # False means partitioning each users' samples into train-test groups. + user: False + # The fraction of data for training; the rest are for testing. + train_test_split: 0.9 + + # The number of classes in each client. Only applicable when the split_type is 'class'. + class_per_client: 1 + # The targeted number of clients to construct.used in non-leaf dataset, number of clients split into. for leaf dataset, only used when split type class. + num_of_clients: 100 + # The parameter for Dirichlet distribution simulation, applicable only when split_type is `dir` for CIFAR datasets. + alpha: 0.5 + + # The targeted distribution of quantities to simulate data quantity heterogeneity. + # The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + # The `num_of_clients` should be divisible by `len(weights)`. + # None means clients are simulated with the same data quantity. + weights: NULL +``` + +Among them, `root` is applicable to all datasets. It specifies the directory to store datasets. + +EasyFL automatically downloads a dataset if it is not exist in the root directory. + +Next, we introduce the simulation and configuration for specific datasets. + +### FEMNIST and Shakespeare Datasets + +The following are basic stats of these two datasets. + +FEMNIST + +* Overview: Image Dataset +* Details: 3500 users, 62 different classes (10 digits, 26 lowercase, 26 uppercase), images are 28 by 28 pixels (with + option to make them all 128 by 128 pixels) +* Task: Image Classification + +Shakespeare + +* Overview: Text Dataset of Shakespeare Dialogues +* Details: 1129 users (reduced to 660 with our choice of sequence length.) +* Task: Next-Character Prediction + +The datasets are non-IID (independent and identically distributed) in nature. + +`split_type`: There are two options for these two datasets: `iid` and `niid`, representing IID data simulation and +non-IID data simulation. + +Five hyper-parameters determine the simulated dataset: `min_size`, `data_amount`, `iid_fraction`, `tran_test_split`, +and `user`. + +`user` is a boolean that determines whether to partition the dataset to train test group by user or samples. +`user: True` means partitioning users of the dataset into train-test groups, i.e. some users are for training, some +users are for testing. +`user: False` means partitioning each users' samples into train-test groups, i.e. data in each client is partitioned +into training set and testing set. + +Note: we normally use `test_mode: test_in_clients` for these two datasets. + +#### IID Simulation + +In IID simulation, data are randomly partitioned into multiple clients. + +The number of clients is determined by `data_amount` and `iid_fraction`. + +#### Non-IID Simulation + +Since FEMNIST and Shakespeare are non-IID in nature, each user of the dataset is regarded as a client. + +`data_amount` determine the number of clients participate in training. + +### CIFAR-10 and CIFAR-100 Datasets + +> The **CIFAR-10** dataset consists of 60000 32x32 colour images in 10 classes, with 6000 images per class. There are 50000 training images and 10000 test images. + +> The **CIFAR-100** dataset consists of 60000 32x32 colour images in 100 classes, with 600 images per class. There are 50000 training images and 10000 test images. + +`split_type`: There are three options for CIFAR datasets: `iid`, `dir`, and `class`. + +Three hyper-parameters determine the simulated dataset: `num_of_clients`, `class_per_client`, and `alpha`. + +#### IID Simulation + +In IID simulation, the training images of the datasets are randomly partitioned into `num_of_clients` clients. + +#### Non-IID Simulation + +We can simulate non-IID CIFAR datasets by Dirichlet process (`dir`) or by label class (`class`). + +`alpha` controls the level of heterogeneity for `dir` simulation. + +`class_per_client` determines the number of classes in each client. + +## Customize Datasets + +EasyFL also supports integrating with customized dataset to simulate federated learning. + +You can use the following classes to integrate customized dataset: [FederatedImageDataset](../api.html#easyfl.datasets.FederatedImageDataset), [FederatedTensorDataset](../api.html#easyfl.datasets.FederatedTensorDataset), and [FederatedTorchDataset](../api.html#easyfl.datasets.FederatedTorchDataset). + +The following is an example that integrates [nine person re-identification datasets](https://arxiv.org/abs/2008.11560), where each client contains one dataset. + +```python +import easyfl +import os +from torchvision import transforms +from easyfl.datasets import FederatedImageDataset + +TRANSFORM_TRAIN_LIST = transforms.Compose([ + transforms.Resize((256, 128), interpolation=3), + transforms.Pad(10), + transforms.RandomCrop((256, 128)), + transforms.RandomHorizontalFlip(), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) +]) +TRANSFORM_VAL_LIST = transforms.Compose([ + transforms.Resize(size=(256, 128), interpolation=3), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) +]) + +DATASETS = ["MSMT17", "Duke", "Market", "cuhk03", "prid", "cuhk01", "viper", "3dpes", "ilids"] + +# Prepare customized training data +def prepare_train_data(data_dir): + client_ids = [] + roots = [] + for db in DATASETS: + client_ids.append(db) + data_path = os.path.join(data_dir, db, "pytorch") + roots.append(os.path.join(data_path, "train_all")) + data = FederatedImageDataset(root=roots, + simulated=True, + do_simulate=False, + transform=TRANSFORM_TRAIN_LIST, + client_ids=client_ids) + return data + + +# Prepare customized testing data +def prepare_test_data(data_dir): + roots = [] + client_ids = [] + for db in DATASETS: + test_gallery = os.path.join(data_dir, db, 'pytorch', 'gallery') + test_query = os.path.join(data_dir, db, 'pytorch', 'query') + roots.extend([test_gallery, test_query]) + client_ids.extend([f"{db}_gallery", f"{db}_query"]) + data = FederatedImageDataset(root=roots, + simulated=True, + do_simulate=False, + transform=TRANSFORM_VAL_LIST, + client_ids=client_ids) + return data + + +if __name__ == '__main__': + config = {...} + data_dir = "datasets/" + train_data, test_data = prepare_train_data(data_dir), prepare_test_data(data_dir) + easyfl.register_dataset(train_data, test_data) + easyfl.init(config) + easyfl.run() +``` + +The folder structure of these datasets are as followed: +``` +|-- MSMT17 +| |-- pytorch +| | |-- gallery +| | |-- query +| | |-- train +| | |-- train_all +| | `-- val +|-- cuhk01 +| |-- pytorch +| | |-- gallery +| | |-- query +| | |-- train +| | |-- train_all +| ... +``` + +Please [email us](mailto:weiming001@e.ntu.edu.sg) if you want to access these datasets with: +1. A short self-introduction. +2. The purposes of using these datasets. + +*⚠️ Further distribution of the datasets are prohibited.* + +### Create Your Own Federated Dataset + +In case that the provided federated dataset class is not enough, +you can implement your own federated dataset by inherit and implement [FederatedDataset](../api.html#easyfl.datasets.FederatedDataset). + +You can refer to [FederatedImageDataset](../api.html#easyfl.datasets.FederatedImageDataset), [FederatedTensorDataset](../api.html#easyfl.datasets.FederatedTensorDataset), and [FederatedTorchDataset](../api.html#easyfl.datasets.FederatedTorchDataset) on how to implement. diff --git a/docs/en/tutorials/distributed_training.md b/docs/en/tutorials/distributed_training.md new file mode 100644 index 0000000..38ed847 --- /dev/null +++ b/docs/en/tutorials/distributed_training.md @@ -0,0 +1,42 @@ +# Tutorial 6: Distributed Training + +EasyFL enables federated learning (FL) training over multiple GPUs. We define the following variables to further illustrate the idea: +* K: the number of clients who participated in training each round +* N: the number of available GPUs + +When _K == N_, each selected client is allocated to a GPU to train. + +When _K > N_, multiple clients are allocated to a GPU, then they execute training sequentially in the GPU. + +When _K < N_, you can adjust to use fewer GPUs in training. + +We make it easy to use distributed training. You just need to modify the configs, without changing the core implementations. +In particular, you need to set the number of GPUs in `gpu` and specific distributed settings in the `distributed` configs. + +The following is an example of distributed training on a GPU cluster managed by _slurm_. + +```python +import easyfl +from easyfl.distributed import slurm + +# Get the distributed settings. +rank, local_rank, world_size, host_addr = slurm.setup() +# Set the distributed training settings. +config = { + "gpu": world_size, + "distributed": { + "rank": rank, + "local_rank": local_rank, + "world_size": world_size, + "init_method": host_addr + }, +} +# Initialize EasyFL. +easyfl.init(config) +# Execute training with distributed training. +easyfl.run() +``` + +We will further provide scripts to set up distributed training using `multiprocess`. +Pull requests are also welcomed. + diff --git a/docs/en/tutorials/high-level_apis.md b/docs/en/tutorials/high-level_apis.md new file mode 100644 index 0000000..8773826 --- /dev/null +++ b/docs/en/tutorials/high-level_apis.md @@ -0,0 +1,126 @@ +# Tutorial 1: High-level APIs + +EasyFL provides three types of high-level APIs: **initialization**, **registration**, and **execution**. +The initialization API initializes EasyFL with configurations. +Registration APIs register customized components into the platform. +Execution APIs start federated learning process. +These APIs are listed in the table below. + +| API Name | Description | Category +| :--- | :----: | :--- | +| init(config) | Initialize EasyFL with configurations | Initialization | +| register_dataset(train, test, val) | Register a customized dataset | Registration | +| register_model(model) | Register a customized model | Registration | +| register_server(server) | Register a customized server | Registration | +| register_client(client) | Register a customized client | Registration | +| run() | Start federated learning for standalone and distributed training | Execution | +| start_server() | Start server service for remote training | Execution | +| start_client() | Start client service for remote training | Execution | + + +`init(config):` Initialize EasyFL with provided configurations (`config`) or default configurations if not specified. +These configurations determine the training hardware and hyperparameters. + +`register_:` Register customized modules to the system. +EasyFL supports the registration of customized datasets, models, server, and client, replacing the default modules in FL training. In the experimental phase, users can register newly developed algorithms to understand their performance. + +`run, start_:` The APIs are commands to trigger execution. +`run()` starts FL using standalone training or distributed training. + `start_server` and `start_client` start the server and client services to communicate remotely with `args` variables for configurations specific to remote training, such as the endpoint addresses. + +Next, we introduce how to use these APIs with examples. + +## Standalone Training Example + +_**Standalone training**_ means that federated learning (FL) training is run on a single hardware device, such as your personal computer and a single GPU. +_**Distributed training**_ means conducting FL with multiple GPUs to speed up training. +Running distributed training is similar to standalone training, except that we need to configure the number of GPUs and the distributed settings. +We explain more on distributed training in [another note](distributed_training.md) and focus on standalone training example here. + +To run any federated learning process, we need to first call the initialization API and then use the execution API. Registration is optional. + +The simplest way is to run with the default setup. +```python +import easyfl +# Initialize federated learning with default configurations. +easyfl.init() +# Execute federated learning training. +easyfl.run() +``` + +You can run it with specified configurations. +```python +import easyfl + +# Customized configuration. +config = { + "data": {"dataset": "cifar10", "num_of_clients": 1000}, + "server": {"rounds": 5, "clients_per_round": 2, "test_all": False}, + "client": {"local_epoch": 5}, + "model": "resnet18", + "test_mode": "test_in_server", +} +# Initialize federated learning with default configurations. +easyfl.init(config) +# Execute federated learning training. +easyfl.run() +``` + +You can also run federated learning with customized datasets, model, server and client implementations. + +Note: `registration` must be done before `initialization`. + +```python +import easyfl +from easyfl.client import BaseClient + +# Inherit BaseClient to implement customized client operations. +class CustomizedClient(BaseClient): + def __init__(self, cid, conf, train_data, test_data, device, **kwargs): + super(CustomizedClient, self).__init__(cid, conf, train_data, test_data, device, **kwargs) + pass # more initialization of attributes. + + def train(self, conf, device): + pass # Implement customized training method, overwriting the default one. + +# Register customized client. +easyfl.register_client(CustomizedClient) +# Initialize federated learning with default configurations. +easyfl.init() +# Execute federated learning training. +easyfl.run() +``` + +## Remote Training Example + +_**Remote training**_ is the scenario where the server and the clients are running on different devices. +We explain more on remote training in [another note](remote_training.md). +Here we provide examples on how to start client and server services using the APIs. + +Start remote server. +```python +import easyfl +# Configurations for the remote server. +conf = {"is_remote": True, "local_port": 22999} +# Initialize only the configuration. +easyfl.init(conf, init_all=False) +# Start remote server service. +# The remote server waits to be connected with the remote client. +easyfl.start_server() +``` + +Start remote client. +```python +import easyfl +# Configurations for the remote client. +conf = {"is_remote": True, "local_port": 23000} +# Initialize only the configuration. +easyfl.init(conf, init_all=False) +# Start remote client service. +# The remote client waits to be connected with the remote server. +easyfl.start_client() +``` + +We expose two additional APIs that wrap starting remote services with customized components. +They are `start_remote_server` and `start_remote_client`. +You can explore more in the API documentation. diff --git a/docs/en/tutorials/index.rst b/docs/en/tutorials/index.rst new file mode 100644 index 0000000..e5c1887 --- /dev/null +++ b/docs/en/tutorials/index.rst @@ -0,0 +1,11 @@ +.. toctree:: + :maxdepth: 2 + :caption: Tutorial + + high-level_apis.md + config.md + dataset.md + model.md + customize_server_and_client.md + distributed_training.md + remote_training.md diff --git a/docs/en/tutorials/model.md b/docs/en/tutorials/model.md new file mode 100644 index 0000000..58915b9 --- /dev/null +++ b/docs/en/tutorials/model.md @@ -0,0 +1,92 @@ +# Tutorial 4: Models + +EasyFL supports numerous models and allows you to customize the model. + +## Out-of-the-box Models + +To use these models, you can set configurations `model: `. + +We currently provide `lenet`, `resnet`, `resnet18`, `resnet50`,`vgg9`, and `rnn`. + +## Customized Models + +EasyFL allows training with a wide range of models by providing the flexibility to customize models. +You can customize and register models in two ways: register as a class and register as an instance. +Either way, the basic is to **inherit and implement the `easyfl.models.BaseModel`**. + +### Register as a Class + +In the example below, we implement and conduct FL training with a `CustomizedModel`. + +It is applicable when the model does not require extra arguments to initialize. + +```python +from torch import nn +import torch.nn.functional as F +import easyfl +from easyfl.models import BaseModel + +# Define a customized model class. +class CustomizedModel(BaseModel): + def __init__(self): + super(CustomizedModel, self).__init__() + self.conv1 = nn.Conv2d(3, 32, 224, padding=(2, 2)) + self.conv2 = nn.Conv2d(32, 64, 5, padding=(2, 2)) + self.fc1 = nn.Linear(64, 128) + self.fc2 = nn.Linear(128, 42) + + def forward(self, x): + x = F.relu(self.conv1(x)) + x = F.max_pool2d(x, 2, 2) + x = F.relu(self.conv2(x)) + x = F.max_pool2d(x, 2, 2) + x = x.view(-1, 64) + x = F.relu(self.fc1(x)) + x = self.fc2(x) + + return x + +# Register the customized model class. +easyfl.register_model(CustomizedModel) +# Initialize EasyFL. +easyfl.init() +# Execute FL training. +easyfl.run() +``` + +### Register as an Instance + +When the model requires arguments for initialization, we can implement and register a model instance. + +```python +from torch import nn +import torch.nn.functional as F +import easyfl +from easyfl.models import BaseModel + +# Define a customized model class. +class CustomizedModel(BaseModel): + def __init__(self, num_class): + super(CustomizedModel, self).__init__() + self.conv1 = nn.Conv2d(3, 32, 224, padding=(2, 2)) + self.conv2 = nn.Conv2d(32, 64, 5, padding=(2, 2)) + self.fc1 = nn.Linear(64, 128) + self.fc2 = nn.Linear(128, num_class) + + def forward(self, x): + x = F.relu(self.conv1(x)) + x = F.max_pool2d(x, 2, 2) + x = F.relu(self.conv2(x)) + x = F.max_pool2d(x, 2, 2) + x = x.view(-1, 64) + x = F.relu(self.fc1(x)) + x = self.fc2(x) + return x + +# Register the customized model instance. +easyfl.register_model(CustomizedModel(num_class=10)) +# Initialize EasyFL. +easyfl.init() +# Execute FL training. +easyfl.run() +``` \ No newline at end of file diff --git a/docs/en/tutorials/remote_training.md b/docs/en/tutorials/remote_training.md new file mode 100644 index 0000000..88b00c6 --- /dev/null +++ b/docs/en/tutorials/remote_training.md @@ -0,0 +1,257 @@ +# Tutorial 7: Remote Training + +_**Remote training**_ is the scenario where the server and the clients are running on different devices. Standalone and +distributed training are mainly for federated learning (FL) simulation experiments. Remote training brings FL from +experimentation to production. + +## Remote Training Example + +In remote training, both server and clients are started as gRPC services. Here we provide examples on how to start +server and client services. + +Start remote server. + +```python +import easyfl + +# Configurations for the remote server. +conf = {"is_remote": True, "local_port": 22999} +# Initialize only the configuration. +easyfl.init(conf, init_all=False) +# Start remote server service. +# The remote server waits to be connected with the remote client. +easyfl.start_server() +``` + +Start remote client 1 with port 23000. + +```python +import easyfl + +# Configurations for the remote client. +conf = { + "is_remote": True, + "local_port": 23000, + "server_addr": "localhost:22999", + "index": 0, +} +# Initialize only the configuration. +easyfl.init(conf, init_all=False) +# Start remote client service. +# The remote client waits to be connected with the remote server. +easyfl.start_client() +``` + +Start remote client 2 with port 23001. + +```python +import easyfl + +# Configurations for the remote client. +conf = { + "is_remote": True, + "local_port": 23001, + "server_addr": "localhost:22999", + "index": 1, +} +# Initialize only the configuration. +easyfl.init(conf, init_all=False) +# Start remote client service. +# The remote client waits to be connected with the remote server. +easyfl.start_client() +``` + +The client service connects to the remote service via specified `server_address`. +The client service users `index` to decide the data (user) of the configured dataset. + +To trigger remote training, we can send gRPC requests to trigger the training operation. + +```python +import easyfl +from easyfl.pb import common_pb2 as common_pb +from easyfl.pb import server_service_pb2 as server_pb +from easyfl.protocol import codec +from easyfl.communication import grpc_wrapper +from easyfl.registry.vclient import VirtualClient + +server_addr = "localhost:22999" +config = { + "data": {"dataset": "femnist"}, + "model": "lenet", + "test_mode": "test_in_client" +} +# Initialize configurations. +easyfl.init(config, init_all=False) +# Initialize the model, using the configured 'lenet' +model = easyfl.init_model() + +# Construct gRPC request +stub = grpc_wrapper.init_stub(grpc_wrapper.TYPE_SERVER, server_addr) +request = server_pb.RunRequest(model=codec.marshal(model)) +# The request contains clients' addresses for the server to communicate with the clients. +clients = [VirtualClient("1", "localhost:23000", 0), VirtualClient("2", "localhost:23001", 1)] +for c in clients: + request.clients.append(server_pb.Client(client_id=c.id, index=c.index, address=c.address)) +# Send request to trigger training. +response = stub.Run(request) +result = "Success" if response.status.code == common_pb.SC_OK else response +print(result) +``` + +Similarly, we can also stop remote training by sending gRPC requests to the server. + +```python +from easyfl.communication import grpc_wrapper +from easyfl.pb import common_pb2 as common_pb +from easyfl.pb import server_service_pb2 as server_pb + +server_addr = "localhost:22999" +stub = grpc_wrapper.init_stub(grpc_wrapper.TYPE_SERVER, server_addr) +# Send request to stop training. +response = stub.Stop(server_pb.StopRequest()) +result = "Success" if response.status.code == common_pb.SC_OK else response +print(result) +``` + +## Remote Training on Docker and Kubernetes + +EasyFL supports deployment of FL training using Docker and Kubernetes. + +Since we cannot easily obtain the server and client addresses in Docker or Kubernetes, especially when scaling up the number of clients, +EasyFL provides a service discovery mechanism, as shown in the image below. +![service_discovery](../_static/image/registry.png) + +It contains registors to dynamically register the clients and the registry to store the client addresses for the server to query. +The registor gets the addresses of clients and registers them to the registry. +Since the clients are unaware of the container environment they are running, +they must rely on a third-party service (the registor) to fetch their container addresses to complete registration. +The registry stores the registered client addresses for the server to query. +EasyFL supports two service discovery methods targeting different deployment scenarios: using Docker and using Kubernetes + +The following are the deployment manual and the steps to conduct training in Kubernetes. + +⚠️ Note: these commands were tested before refactoring. They may not work as expected now. **Need further testing**. + +### Deployment using Docker + +Important: Adjust the `Memeory` constrain of docker to be > 11 GB (To be optimized) + +1. Build docker images and start services with either docker compose or individual docker containers +2. Start training with a grpc message + +#### Build images + +``` +make base_image +make image +``` + +Or + +``` +docker build -t easyfl:base -f docker/base.Dockerfile . +docker build -t easyfl-client -f docker/client.Dockerfile . +docker build -t easyfl-server -f docker/server.Dockerfile . +docker build -t easyfl-run -f docker/run.Dockerfile . +``` + +#### Start with Docker Compose + +Use docker compose to start all services. +``` +docker-compose up --scale client=2 && docker-compose rm -fsv +``` + +Mac users with Docker Desktop > 2.0 may have port conflict occurs because `bind: address already in use`. +The workaround is to run with +``` +docker-compose up && docker-compose rm -fsv +``` +and start another terminal to scale with +``` +docker-compose up --scale client=2 && docker-compose rm -fsv +``` + +#### Etcd Setup + +``` +export NODE1=localhost +export DATA_DIR="etcd-data" +REGISTRY=quay.io/coreos/etcd + +docker run --rm \ + -p 23790:2379 \ + -p 23800:2380 \ + --volume=${DATA_DIR}:/etcd-data \ + --name etcd ${REGISTRY}:v3.4.0 \ + /usr/local/bin/etcd \ + --data-dir=/etcd-data --name node1 \ + --initial-advertise-peer-urls http://${NODE1}:2380 --listen-peer-urls http://0.0.0.0:2380 \ + --advertise-client-urls http://${NODE1}:2379 --listen-client-urls http://0.0.0.0:2379 \ + --initial-cluster node1=http://${NODE1}:2380 +``` + +#### Docker Register + +``` +docker run --name docker-register --rm -d -e HOST_IP=<172.18.0.1> -e ETCD_HOST=<172.17.0.1>:2379 -v /var/run/docker.sock:/var/run/docker.sock -t wingalong/docker-register +``` +* HOST_IP: the ip address of network client runs on: gateway in `docker inspect easyfl-client` +* ETCD_HOST: the ip address of etcd: gateway in `docker inspect etcd` + +#### Start containers + +```shell +# 1. Start clients +docker run --rm -p 23400:23400 --name client0 --network host -v /femnist/data:/app//femnist/data easyfl-client --index=0 --is-remote=True --local-port=23400 --server-addr="localhost:23501" +docker run --rm -p 23401:23401 --name client1 --network host -v /femnist/data:/app//femnist/data easyfl-client --index=1 --is-remote=True --local-port=23401 --server-addr="localhost:23501" + +# 2. Start server +docker run --rm -p 23501:23501 --name easyfl-server --network host easyfl-server --local-port=23501 --is-remote=True +``` + +Note: you need to replace the `dataset_path` with your actual dataset directory. + +#### Start Training Remotely +``` +docker run --rm --name easyfl-run --network host easyfl-run --server-addr 127.0.0.1:23501 --etcd-addr:127.0.0.1:23790 +``` +It sends a gRPC message to server to start training. + +### Deployment using Kubernetes + + +```shell +# 1. Deploy tracker +kubectl apply -f kubernetes/tracker.yml + +# 2. Deploy server +kubectl apply -f kubernetes/server.yml + +# 3. Deploy client +kubectl apply -f kubernetes/client.yml + +# 4. Scale client +kubectl scale -n easyfl deployment easyfl-client --replicas=6 + +# 5. Check pods +kubectl get pods -n easyfl -o wide + +# 6. Run + +python examples/remote_run.py --server-addr localhost:32501 --source kubernetes + +# 7. Check logs +kubectl logs -f -n easyfl easyfl-server + +# 8. Get results +python examples/test_services.py --task-id task_ijhwqg + +# 9. Save log +kubectl logs -n easyfl easyfl-server > server-log.log + +# 10. Stop client/server/tracker +kubectl delete -f kubernetes/client.yml +kubectl delete -f kubernetes/server.yml +kubectl delete -f kubernetes/tracker.yml +``` diff --git a/easyfl/__init__.py b/easyfl/__init__.py new file mode 100644 index 0000000..3777da2 --- /dev/null +++ b/easyfl/__init__.py @@ -0,0 +1,22 @@ +from easyfl.coordinator import ( + init, + init_dataset, + init_model, + start_server, + start_client, + run, + register_dataset, + register_model, + register_server, + register_client, + load_config, +) + +from easyfl.service import ( + start_remote_server, + start_remote_client, +) + +__all__ = ["init", "init_dataset", "init_model", "start_server", "start_client", "run", + "register_dataset", "register_model", "register_server", "register_client", + "load_config", "start_remote_server", "start_remote_client"] diff --git a/easyfl/client/__init__.py b/easyfl/client/__init__.py new file mode 100644 index 0000000..a7e0fa9 --- /dev/null +++ b/easyfl/client/__init__.py @@ -0,0 +1,5 @@ +from easyfl.client.base import BaseClient +from easyfl.client.service import ClientService + +__all__ = ['BaseClient', 'ClientService'] + diff --git a/easyfl/client/base.py b/easyfl/client/base.py new file mode 100644 index 0000000..3c66f76 --- /dev/null +++ b/easyfl/client/base.py @@ -0,0 +1,471 @@ +import argparse +import copy +import logging +import time + +import torch + +from easyfl.client.service import ClientService +from easyfl.communication import grpc_wrapper +from easyfl.distributed.distributed import CPU +from easyfl.pb import common_pb2 as common_pb +from easyfl.pb import server_service_pb2 as server_pb +from easyfl.protocol import codec +from easyfl.tracking import metric +from easyfl.tracking.client import init_tracking +from easyfl.tracking.evaluation import model_size + +logger = logging.getLogger(__name__) + + +def create_argument_parser(): + """Create argument parser with arguments/configurations for starting remote client service. + + Returns: + argparse.ArgumentParser: Parser with client service arguments. + """ + parser = argparse.ArgumentParser(description='Federated Client') + parser.add_argument('--local-port', + type=int, + default=23000, + help='Listen port of the client') + parser.add_argument('--server-addr', + type=str, + default="localhost:22999", + help='Address of server in [IP]:[PORT] format') + parser.add_argument('--tracker-addr', + type=str, + default="localhost:12666", + help='Address of tracking service in [IP]:[PORT] format') + parser.add_argument('--is-remote', + type=bool, + default=False, + help='Whether start as a remote client.') + return parser + + +class BaseClient(object): + """Default implementation of federated learning client. + + Args: + cid (str): Client id. + conf (omegaconf.dictconfig.DictConfig): Client configurations. + train_data (:obj:`FederatedDataset`): Training dataset. + test_data (:obj:`FederatedDataset`): Test dataset. + device (str): Hardware device for training, cpu or cuda devices. + sleep_time (float): Duration of on hold after training to simulate stragglers. + is_remote (bool): Whether start remote training. + local_port (int): Port of remote client service. + server_addr (str): Remote server service grpc address. + tracker_addr (str): Remote tracking service grpc address. + + + Override the class and functions to implement customized client. + + Example: + >>> from easyfl.client import BaseClient + >>> class CustomizedClient(BaseClient): + >>> def __init__(self, cid, conf, train_data, test_data, device, **kwargs): + >>> super(CustomizedClient, self).__init__(cid, conf, train_data, test_data, device, **kwargs) + >>> pass # more initialization of attributes. + >>> + >>> def train(self, conf, device=CPU): + >>> # Implement customized client training method, which overwrites the default training method. + >>> pass + """ + def __init__(self, + cid, + conf, + train_data, + test_data, + device, + sleep_time=0, + is_remote=False, + local_port=23000, + server_addr="localhost:22999", + tracker_addr="localhost:12666"): + self.cid = cid + self.conf = conf + self.train_data = train_data + self.train_loader = None + self.test_data = test_data + self.test_loader = None + self.device = device + + self.round_time = 0 + self.train_time = 0 + self.test_time = 0 + + self.train_accuracy = [] + self.train_loss = [] + self.test_accuracy = 0 + self.test_loss = 0 + + self.profiled = False + self._sleep_time = sleep_time + + self.compressed_model = None + self.model = None + self._upload_holder = server_pb.UploadContent() + + self.is_remote = is_remote + self.local_port = local_port + self._server_addr = server_addr + self._tracker_addr = tracker_addr + self._server_stub = None + self._tracker = None + self._is_train = True + + if conf.track: + self._tracker = init_tracking(init_store=False) + + def run_train(self, model, conf): + """Conduct training on clients. + + Args: + model (nn.Module): Model to train. + conf (omegaconf.dictconfig.DictConfig): Client configurations. + Returns: + :obj:`UploadRequest`: Training contents. Unify the interface for both local and remote operations. + """ + self.conf = conf + if conf.track: + self._tracker.set_client_context(conf.task_id, conf.round_id, self.cid) + + self._is_train = True + + self.download(model) + self.track(metric.TRAIN_DOWNLOAD_SIZE, model_size(model)) + + self.decompression() + + self.pre_train() + self.train(conf, self.device) + self.post_train() + + self.track(metric.TRAIN_ACCURACY, self.train_accuracy) + self.track(metric.TRAIN_LOSS, self.train_loss) + self.track(metric.TRAIN_TIME, self.train_time) + + if conf.local_test: + self.test_local() + + self.compression() + + self.track(metric.TRAIN_UPLOAD_SIZE, model_size(self.compressed_model)) + + self.encryption() + + return self.upload() + + def run_test(self, model, conf): + """Conduct testing on clients. + + Args: + model (nn.Module): Model to test. + conf (omegaconf.dictconfig.DictConfig): Client configurations. + Returns: + :obj:`UploadRequest`: Testing contents. Unify the interface for both local and remote operations. + """ + self.conf = conf + if conf.track: + reset = not self._is_train + self._tracker.set_client_context(conf.task_id, conf.round_id, self.cid, reset_client=reset) + + self._is_train = False + + self.download(model) + self.track(metric.TEST_DOWNLOAD_SIZE, model_size(model)) + + self.decompression() + + self.pre_test() + self.test(conf, self.device) + self.post_test() + + self.track(metric.TEST_ACCURACY, float(self.test_accuracy)) + self.track(metric.TEST_LOSS, float(self.test_loss)) + self.track(metric.TEST_TIME, self.test_time) + + return self.upload() + + def download(self, model): + """Download model from the server. + + Args: + model (nn.Module): Global model distributed from the server. + """ + if self.compressed_model: + self.compressed_model.load_state_dict(model.state_dict()) + else: + self.compressed_model = copy.deepcopy(model) + + def decompression(self): + """Decompressed model. It can be further implemented when the model is compressed in the server.""" + self.model = self.compressed_model + + def pre_train(self): + """Preprocessing before training.""" + pass + + def train(self, conf, device=CPU): + """Execute client training. + + Args: + conf (omegaconf.dictconfig.DictConfig): Client configurations. + device (str): Hardware device for training, cpu or cuda devices. + """ + start_time = time.time() + loss_fn, optimizer = self.pretrain_setup(conf, device) + self.train_loss = [] + for i in range(conf.local_epoch): + batch_loss = [] + for batched_x, batched_y in self.train_loader: + x, y = batched_x.to(device), batched_y.to(device) + optimizer.zero_grad() + out = self.model(x) + loss = loss_fn(out, y) + loss.backward() + optimizer.step() + batch_loss.append(loss.item()) + current_epoch_loss = sum(batch_loss) / len(batch_loss) + self.train_loss.append(float(current_epoch_loss)) + logger.debug("Client {}, local epoch: {}, loss: {}".format(self.cid, i, current_epoch_loss)) + self.train_time = time.time() - start_time + logger.debug("Client {}, Train Time: {}".format(self.cid, self.train_time)) + + def post_train(self): + """Postprocessing after training.""" + pass + + def pretrain_setup(self, conf, device): + """Setup loss function and optimizer before training.""" + self.simulate_straggler() + self.model.train() + self.model.to(device) + loss_fn = self.load_loss_fn(conf) + optimizer = self.load_optimizer(conf) + if self.train_loader is None: + self.train_loader = self.load_loader(conf) + return loss_fn, optimizer + + def load_loss_fn(self, conf): + return torch.nn.CrossEntropyLoss() + + def load_optimizer(self, conf): + """Load training optimizer. Implemented Adam and SGD.""" + if conf.optimizer.type == "Adam": + optimizer = torch.optim.Adam(self.model.parameters(), lr=conf.optimizer.lr) + else: + # default using optimizer SGD + optimizer = torch.optim.SGD(self.model.parameters(), + lr=conf.optimizer.lr, + momentum=conf.optimizer.momentum, + weight_decay=conf.optimizer.weight_decay) + return optimizer + + def load_loader(self, conf): + """Load the training data loader. + + Args: + conf (omegaconf.dictconfig.DictConfig): Client configurations. + Returns: + torch.utils.data.DataLoader: Data loader. + """ + return self.train_data.loader(conf.batch_size, self.cid, shuffle=True, seed=conf.seed) + + def test_local(self): + """Test client local model after training.""" + pass + + def pre_test(self): + """Preprocessing before testing.""" + pass + + def test(self, conf, device=CPU): + """Execute client testing. + + Args: + conf (omegaconf.dictconfig.DictConfig): Client configurations. + device (str): Hardware device for training, cpu or cuda devices. + """ + begin_test_time = time.time() + self.model.eval() + self.model.to(device) + loss_fn = self.load_loss_fn(conf) + if self.test_loader is None: + self.test_loader = self.test_data.loader(conf.test_batch_size, self.cid, shuffle=False, seed=conf.seed) + # TODO: make evaluation metrics a separate package and apply it here. + self.test_loss = 0 + correct = 0 + with torch.no_grad(): + for batched_x, batched_y in self.test_loader: + x = batched_x.to(device) + y = batched_y.to(device) + log_probs = self.model(x) + loss = loss_fn(log_probs, y) + _, y_pred = torch.max(log_probs, -1) + correct += y_pred.eq(y.data.view_as(y_pred)).long().cpu().sum() + self.test_loss += loss.item() + test_size = self.test_data.size(self.cid) + self.test_loss /= test_size + self.test_accuracy = 100.0 * float(correct) / test_size + + logger.debug('Client {}, testing -- Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)'.format( + self.cid, self.test_loss, correct, test_size, self.test_accuracy)) + + self.test_time = time.time() - begin_test_time + self.model = self.model.cpu() + + def post_test(self): + """Postprocessing after testing.""" + pass + + def encryption(self): + """Encrypt the client local model.""" + # TODO: encryption of model, remember to track encrypted model instead of compressed one after implementation. + pass + + def compression(self): + """Compress the client local model after training and before uploading to the server.""" + self.compressed_model = self.model + + def upload(self): + """Upload the messages from client to the server. + + Returns: + :obj:`UploadRequest`: The upload request defined in protobuf to unify local and remote operations. + Only applicable for local training as remote training upload through a gRPC request. + """ + request = self.construct_upload_request() + if not self.is_remote: + self.post_upload() + return request + + self.upload_remotely(request) + self.post_upload() + + def post_upload(self): + """Postprocessing after uploading training/testing results.""" + pass + + def construct_upload_request(self): + """Construct client upload request for training updates and testing results. + + Returns: + :obj:`UploadRequest`: The upload request defined in protobuf to unify local and remote operations. + """ + data = codec.marshal(server_pb.Performance(accuracy=self.test_accuracy, loss=self.test_loss)) + typ = common_pb.DATA_TYPE_PERFORMANCE + try: + if self._is_train: + data = codec.marshal(copy.deepcopy(self.compressed_model)) + typ = common_pb.DATA_TYPE_PARAMS + data_size = self.train_data.size(self.cid) + else: + data_size = 1 if not self.test_data else self.test_data.size(self.cid) + except KeyError: + # When the datasize cannot be get from dataset, default to use equal aggregate + data_size = 1 + + m = self._tracker.get_client_metric().to_proto() if self._tracker else common_pb.ClientMetric() + return server_pb.UploadRequest( + task_id=self.conf.task_id, + round_id=self.conf.round_id, + client_id=self.cid, + content=server_pb.UploadContent( + data=data, + type=typ, + data_size=data_size, + metric=m, + ), + ) + + def upload_remotely(self, request): + """Send upload request to remote server via gRPC. + + Args: + request (:obj:`UploadRequest`): Upload request. + """ + start_time = time.time() + + self.connect_to_server() + resp = self._server_stub.Upload(request) + + upload_time = time.time() - start_time + m = metric.TRAIN_UPLOAD_TIME if self._is_train else metric.TEST_UPLOAD_TIME + self.track(m, upload_time) + + logger.info("client upload time: {}s".format(upload_time)) + if resp.status.code == common_pb.SC_OK: + logger.info("Uploaded remotely to the server successfully\n") + else: + logger.error("Failed to upload, code: {}, message: {}\n".format(resp.status.code, resp.status.message)) + + # Functions for remote services. + + def start_service(self): + """Start client service.""" + if self.is_remote: + grpc_wrapper.start_service(grpc_wrapper.TYPE_CLIENT, ClientService(self), self.local_port) + + def connect_to_server(self): + """Establish connection between the client and the server.""" + if self.is_remote and self._server_stub is None: + self._server_stub = grpc_wrapper.init_stub(grpc_wrapper.TYPE_SERVER, self._server_addr) + logger.info("Successfully connected to gRPC server {}".format(self._server_addr)) + + def operate(self, model, conf, index, is_train=True): + """A wrapper over operations (training/testing) on clients. + + Args: + model (nn.Module): Model for operations. + conf (omegaconf.dictconfig.DictConfig): Client configurations. + index (int): Client index in the client list, for retrieving data. TODO: improvement. + is_train (bool): The flag to indicate whether the operation is training, otherwise testing. + """ + try: + # Load the data index depending on server request + self.cid = self.train_data.users[index] + except IndexError: + logger.error("Data index exceed the available data, abort training") + return + + if self.conf.track and self._tracker is None: + self._tracker = init_tracking(init_store=False) + + if is_train: + logger.info("Train on data index {}, client: {}".format(index, self.cid)) + self.run_train(model, conf) + else: + logger.info("Test on data index {}, client: {}".format(index, self.cid)) + self.run_test(model, conf) + + # Functions for tracking. + + def track(self, metric_name, value): + """Track a metric. + + Args: + metric_name (str): The name of the metric. + value (str|int|float|bool|dict|list): The value of the metric. + """ + if not self.conf.track or self._tracker is None: + logger.debug("Tracker not available, Tracking not supported") + return + self._tracker.track_client(metric_name, value) + + def save_metrics(self): + """Save client metrics to database.""" + # TODO: not tested + if self._tracker is None: + logger.debug("Tracker not available, no saving") + return + self._tracker.save_client() + + # Functions for simulation. + + def simulate_straggler(self): + """Simulate straggler effect of system heterogeneity.""" + if self._sleep_time > 0: + time.sleep(self._sleep_time) diff --git a/easyfl/client/service.py b/easyfl/client/service.py new file mode 100644 index 0000000..85514fd --- /dev/null +++ b/easyfl/client/service.py @@ -0,0 +1,30 @@ +import logging +import threading + +from easyfl.pb import client_service_pb2_grpc as client_grpc, client_service_pb2 as client_pb, common_pb2 as common_pb +from easyfl.protocol import codec + +logger = logging.getLogger(__name__) + + +class ClientService(client_grpc.ClientServiceServicer): + """"Remote gRPC client service. + + Args: + client (:obj:`BaseClient`): Federated learning client instance. + """ + def __init__(self, client): + self._base = client + + def Operate(self, request, context): + """Perform training/testing operations.""" + # TODO: add request validation. + model = codec.unmarshal(request.model) + is_train = request.type == client_pb.OP_TYPE_TRAIN + # Threading is necessary to respond to server quickly + t = threading.Thread(target=self._base.operate, args=[model, request.config, request.data_index, is_train]) + t.start() + response = client_pb.OperateResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + return response diff --git a/easyfl/communication/__init__.py b/easyfl/communication/__init__.py new file mode 100644 index 0000000..a8b9389 --- /dev/null +++ b/easyfl/communication/__init__.py @@ -0,0 +1,3 @@ +from easyfl.communication.grpc_wrapper import * + +__all__ = ['init_stub', 'start_service'] diff --git a/easyfl/communication/grpc_wrapper.py b/easyfl/communication/grpc_wrapper.py new file mode 100644 index 0000000..614cc84 --- /dev/null +++ b/easyfl/communication/grpc_wrapper.py @@ -0,0 +1,77 @@ +from concurrent import futures + +import grpc + +from easyfl.pb import client_service_pb2_grpc as client_grpc +from easyfl.pb import server_service_pb2_grpc as server_grpc +from easyfl.pb import tracking_service_pb2_grpc as tracking_grpc + +MAX_MESSAGE_LENGTH = 524288000 # 500MB + +TYPE_CLIENT = "client" +TYPE_SERVER = "server" +TYPE_TRACKING = "tracking" + + +def init_stub(typ, address): + """Initialize gRPC stub. + + Args: + typ (str): Type of service, option: client, server, tracking + address (str): Address of the gRPC service. + Returns: + (:obj:`ClientServiceStub`|:obj:`ServerServiceStub`|:obj:`TrackingServiceStub`): stub of the gRPC service. + """ + + channel = grpc.insecure_channel( + address, + options=[ + ('grpc.max_send_message_length', MAX_MESSAGE_LENGTH), + ('grpc.max_receive_message_length', MAX_MESSAGE_LENGTH), + ], + ) + if typ == TYPE_CLIENT: + stub = client_grpc.ClientServiceStub(channel) + elif typ == TYPE_TRACKING: + stub = tracking_grpc.TrackingServiceStub(channel) + else: + stub = server_grpc.ServerServiceStub(channel) + + return stub + + +def start_service(typ, service, port): + """Start gRPC service. + Args: + typ (str): Type of service, option: client, server, tracking. + service (:obj:`ClientService`|:obj:`ServerService`|:obj:`TrackingService`): gRPC service to start. + port (int): The port of the service. + """ + server = grpc.server( + futures.ThreadPoolExecutor(max_workers=10), + options=[ + ('grpc.max_send_message_length', MAX_MESSAGE_LENGTH), + ('grpc.max_receive_message_length', MAX_MESSAGE_LENGTH), + ], + ) + if typ == TYPE_CLIENT: + client_grpc.add_ClientServiceServicer_to_server(service, server) + elif typ == TYPE_TRACKING: + tracking_grpc.add_TrackingServiceServicer_to_server(service, server) + else: + server_grpc.add_ServerServiceServicer_to_server(service, server) + server.add_insecure_port('[::]:{}'.format(port)) + server.start() + server.wait_for_termination() + + +def endpoint(host, port): + """Format endpoint. + + Args: + host (str): Host address. + port (int): Port number. + Returns: + str: Address in `host:port` format. + """ + return "{}:{}".format(host, port) diff --git a/easyfl/compression/__init__.py b/easyfl/compression/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/config.yaml b/easyfl/config.yaml new file mode 100644 index 0000000..5d0f941 --- /dev/null +++ b/easyfl/config.yaml @@ -0,0 +1,113 @@ +# The unique identifier for each federated learning task +task_id: "" + +# Provide dataset and federated learning simulation related configuration. +data: + # The root directory where datasets are stored. + root: "./data/" + # The name of the dataset, support: femnist, shakespeare, cifar10, and cifar100. + dataset: femnist + # The data distribution of each client, support: iid, niid (for femnist and shakespeare), and dir and class (for cifar datasets). + # `iid` means independent and identically distributed data. + # `niid` means non-independent and identically distributed data for FEMNIST and Shakespeare. + # `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + # `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + split_type: "iid" + + # The minimal number of samples in each client. It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + min_size: 10 + # The fraction of data sampled for LEAF datasets. e.g., 10% means that only 10% of total dataset size are used. + data_amount: 0.05 + # The fraction of the number of clients used when the split_type is 'iid'. + iid_fraction: 0.1 + # Whether partition users of the dataset into train-test groups. Only applicable to femnist and shakespeare datasets. + # True means partitioning users of the dataset into train-test groups. + # False means partitioning each users' samples into train-test groups. + user: False + # The fraction of data for training; the rest are for testing. + train_test_split: 0.9 + + # The number of classes in each client. Only applicable when the split_type is 'class'. + class_per_client: 1 + # The targeted number of clients to construct.used in non-leaf dataset, number of clients split into. for leaf dataset, only used when split type class. + num_of_clients: 100 + # The parameter for Dirichlet distribution simulation, applicable only when split_type is `dir` for CIFAR datasets. + alpha: 0.5 + + # The targeted distribution of quantities to simulate data quantity heterogeneity. + # The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + # The `num_of_clients` should be divisible by `len(weights)`. + # None means clients are simulated with the same data quantity. + weights: NULL + +# The name of the model for training, support: lenet, rnn, resnet, resnet18, resnet50, vgg9. +model: lenet +# How to conduct testing, options: test_in_client or test_in_server. + # `test_in_client` means that each client has a test set to run testing. + # `test_in_server` means that server has a test set to run testing for the global model. Use this mode for cifar datasets. +test_mode: "test_in_client" +# The way to measure testing performance (accuracy) when test mode is `test_in_client`, support: average or weighted (means weighted average). +test_method: "average" + +server: + track: False # Whether track server metrics using the tracking service. + rounds: 10 # Total training round. + clients_per_round: 5 # The number of clients to train in each round. + test_every: 1 # The frequency of testing: conduct testing every N round. + save_model_every: 10 # The frequency of saving model: save model every N round. + save_model_path: "" # The path to save model. Default path is root directory of the library. + batch_size: 32 # The batch size of test_in_server. + test_all: False # Whether test all clients or only selected clients. + random_selection: True # Whether select clients to train randomly. + # The strategy to aggregate client uploaded models, options: FedAvg, equal. + # FedAvg aggregates models using weighted average, where the weights are data size of clients. + # equal aggregates model by simple averaging. + aggregation_stragtegy: "FedAvg" + # The content of aggregation, options: all, parameters. + # all means aggregating models using state_dict, including both model parameters and persistent buffers like BatchNorm stats. + # parameters means aggregating only model parameters. + aggregation_content: "all" + +client: + track: False # Whether track server metrics using the tracking service. + batch_size: 32 # The batch size of training in client. + test_batch_size: 5 # The batch size of testing in client. + local_epoch: 10 # The number of epochs to train in each round. + optimizer: + type: "Adam" # The name of the optimizer, options: Adam, SGD. + lr: 0.001 + momentum: 0.9 + weight_decay: 0 + seed: 0 + local_test: False # Whether test the trained models in clients before uploading them to the server. + +gpu: 0 # The total number of GPUs used in training. 0 means CPU. +distributed: # The distributed training configurations. It is only applicable when gpu > 1. + backend: "nccl" # The distributed backend. + init_method: "" + world_size: 0 + rank: 0 + local_rank: 0 + +tracking: # The configurations for logging and tracking. + database: "" # The path of local dataset, sqlite3. + log_file: "" + log_level: "INFO" # The level of logging. + metric_file: "" + save_every: 1 + +# The configuration for system heterogeneity simulation. +resource_heterogeneous: + simulate: False # Whether simulate system heterogeneity in federated learning. + # The type of heterogeneity to simulate, support iso, dir, real. + # iso means that + hetero_type: "real" + level: 3 # The level of heterogeneous (0-5), 0 means no heterogeneous among clients. + sleep_group_num: 1000 # The number of groups with different sleep time. 1 means all clients are the same. + total_time: 1000 # The total sleep time of all clients, unit: second. + fraction: 1 # The fraction of clients attending heterogeneous simulation. + grouping_strategy: "greedy" # The grouping strategy to handle system heterogeneity, support: random, greedy, slowest. + initial_default_time: 5 # The estimated default training time for each training round, unit: second. + default_time_momentum: 0.2 # The default momentum for default time update. + +seed: 0 # The random seed. \ No newline at end of file diff --git a/easyfl/coordinator.py b/easyfl/coordinator.py new file mode 100644 index 0000000..0b95748 --- /dev/null +++ b/easyfl/coordinator.py @@ -0,0 +1,481 @@ +import logging +import os +import random +import sys +import time +from os import path + +import numpy as np +import torch +from omegaconf import OmegaConf + +from easyfl.client.base import BaseClient +from easyfl.datasets import TEST_IN_SERVER +from easyfl.datasets.data import construct_datasets +from easyfl.distributed import dist_init, get_device +from easyfl.models.model import load_model +from easyfl.server.base import BaseServer +from easyfl.simulation.system_hetero import resource_hetero_simulation + +logger = logging.getLogger(__name__) + + +class Coordinator(object): + """Coordinator manages federated learning server and client. + A single instance of coordinator is initialized for each federated learning task + when the package is imported. + """ + + def __init__(self): + self.registered_model = False + self.registered_dataset = False + self.registered_server = False + self.registered_client = False + self.train_data = None + self.test_data = None + self.val_data = None + self.conf = None + self.model = None + self._model_class = None + self.server = None + self._server_class = None + self.clients = None + self._client_class = None + self.tracker = None + + def init(self, conf, init_all=False): + """Initialize coordinator + + Args: + conf (omegaconf.dictconfig.DictConfig): Internal configurations for federated learning. + init_all (bool): Whether initialize dataset, model, server, and client other than configuration. + """ + self.init_conf(conf) + + _set_random_seed(conf.seed) + + if init_all: + self.init_dataset() + + self.init_model() + + self.init_server() + + self.init_clients() + + def run(self): + """Run the coordinator and the federated learning process. + Initialize `torch.distributed` if distributed training is configured. + """ + start_time = time.time() + + if self.conf.is_distributed: + dist_init( + self.conf.distributed.backend, + self.conf.distributed.init_method, + self.conf.distributed.world_size, + self.conf.distributed.rank, + self.conf.distributed.local_rank, + ) + self.server.start(self.model, self.clients) + self.print_("Total training time {:.1f}s".format(time.time() - start_time)) + + def init_conf(self, conf): + """Initialize coordinator configuration. + + Args: + conf (omegaconf.dictconfig.DictConfig): Configurations. + """ + self.conf = conf + self.conf.is_distributed = (self.conf.gpu > 1) + if self.conf.gpu == 0: + self.conf.device = "cpu" + elif self.conf.gpu == 1: + self.conf.device = 0 + else: + self.conf.device = get_device(self.conf.gpu, self.conf.distributed.world_size, + self.conf.distributed.local_rank) + self.print_("Configurations: {}".format(self.conf)) + + def init_dataset(self): + """Initialize datasets. Use provided datasets if not registered.""" + if self.registered_dataset: + return + self.train_data, self.test_data = construct_datasets(self.conf.data.root, + self.conf.data.dataset, + self.conf.data.num_of_clients, + self.conf.data.split_type, + self.conf.data.min_size, + self.conf.data.class_per_client, + self.conf.data.data_amount, + self.conf.data.iid_fraction, + self.conf.data.user, + self.conf.data.train_test_split, + self.conf.data.weights, + self.conf.data.alpha) + + self.print_(f"Total training data amount: {self.train_data.total_size()}") + self.print_(f"Total testing data amount: {self.test_data.total_size()}") + + def init_model(self): + """Initialize model instance.""" + if not self.registered_model: + self._model_class = load_model(self.conf.model) + + # model_class is None means model is registered as instance, no need initialization + if self._model_class: + self.model = self._model_class() + + def init_server(self): + """Initialize a server instance.""" + if not self.registered_server: + self._server_class = BaseServer + + kwargs = { + "is_remote": self.conf.is_remote, + "local_port": self.conf.local_port + } + + if self.conf.test_mode == TEST_IN_SERVER: + kwargs["test_data"] = self.test_data + if self.val_data: + kwargs["val_data"] = self.val_data + + self.server = self._server_class(self.conf, **kwargs) + + def init_clients(self): + """Initialize client instances, each represent a federated learning client.""" + if not self.registered_client: + self._client_class = BaseClient + + # Enforce system heterogeneity of clients. + sleep_time = [0 for _ in self.train_data.users] + if self.conf.resource_heterogeneous.simulate: + sleep_time = resource_hetero_simulation(self.conf.resource_heterogeneous.fraction, + self.conf.resource_heterogeneous.hetero_type, + self.conf.resource_heterogeneous.sleep_group_num, + self.conf.resource_heterogeneous.level, + self.conf.resource_heterogeneous.total_time, + len(self.train_data.users)) + + client_test_data = self.test_data + if self.conf.test_mode == TEST_IN_SERVER: + client_test_data = None + + self.clients = [self._client_class(u, + self.conf.client, + self.train_data, + client_test_data, + self.conf.device, + **{"sleep_time": sleep_time[i]}) + for i, u in enumerate(self.train_data.users)] + + self.print_("Clients in total: {}".format(len(self.clients))) + + def init_client(self): + """Initialize client instance. + + Returns: + :obj:`BaseClient`: The initialized client instance. + """ + if not self.registered_client: + self._client_class = BaseClient + + # Get a random client if not specified + if self.conf.index: + user = self.train_data.users[self.conf.index] + else: + user = random.choice(self.train_data.users) + + return self._client_class(user, + self.conf.client, + self.train_data, + self.test_data, + self.conf.device, + is_remote=self.conf.is_remote, + local_port=self.conf.local_port, + server_addr=self.conf.server_addr, + tracker_addr=self.conf.tracker_addr) + + def start_server(self, args): + """Start a server service for remote training. + + Server controls the model and testing dataset if configured to test in server. + + Args: + args (argparse.Namespace): Configurations passed in as arguments, it is merged with configurations. + """ + if args: + self.conf = OmegaConf.merge(self.conf, args.__dict__) + + if self.conf.test_mode == TEST_IN_SERVER: + self.init_dataset() + + self.init_model() + + self.init_server() + + self.server.start_service() + + def start_client(self, args): + """Start a client service for remote training. + + Client controls training datasets. + + Args: + args (argparse.Namespace): Configurations passed in as arguments, it is merged with configurations. + """ + + if args: + self.conf = OmegaConf.merge(self.conf, args.__dict__) + + self.init_dataset() + + client = self.init_client() + + client.start_service() + + def register_dataset(self, train_data, test_data, val_data=None): + """Register datasets. + + Datasets should inherit from :obj:`FederatedDataset`, e.g., :obj:`FederatedTensorDataset`. + + Args: + train_data (:obj:`FederatedDataset`): Training dataset. + test_data (:obj:`FederatedDataset`): Testing dataset. + val_data (:obj:`FederatedDataset`): Validation dataset. + """ + self.registered_dataset = True + self.train_data = train_data + self.test_data = test_data + self.val_data = val_data + + def register_model(self, model): + """Register customized model for federated learning. + + Args: + model (nn.Module): PyTorch model, both class and instance are acceptable. + Use model class when there is no specific arguments to initialize model. + """ + self.registered_model = True + if not isinstance(model, type): + self.model = model + else: + self._model_class = model + + def register_server(self, server): + """Register a customized federated learning server. + + Args: + server (:obj:`BaseServer`): Customized federated learning server. + """ + self.registered_server = True + self._server_class = server + + def register_client(self, client): + """Register a customized federated learning client. + + Args: + client (:obj:`BaseClient`): Customized federated learning client. + """ + self.registered_client = True + self._client_class = client + + def print_(self, content): + """Log the content only when the server is primary server. + + Args: + content (str): The content to log. + """ + if self._is_primary_server(): + logger.info(content) + + def _is_primary_server(self): + """Check whether current running server is the primary server. + + In standalone or remote training, the server is primary. + In distributed training, the server on `rank0` is primary. + """ + return not self.conf.is_distributed or self.conf.distributed.rank == 0 + + +def _set_random_seed(seed): + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.cuda.manual_seed(seed) + + +# Initialize the global coordinator object +_global_coord = Coordinator() + + +def init_conf(conf=None): + """Initialize configuration for EasyFL. It overrides and supplements default configuration loaded from config.yaml + with the provided configurations. + + Args: + conf (dict): Configurations. + + Returns: + omegaconf.dictconfig.DictConfig: Internal configurations managed by OmegaConf. + """ + here = path.abspath(path.dirname(__file__)) + config_file = path.join(here, 'config.yaml') + return load_config(config_file, conf) + + +def load_config(file, conf=None): + """Load and merge configuration from file and input + + Args: + file (str): filename of the configuration. + conf (dict): Configurations. + + Returns: + omegaconf.dictconfig.DictConfig: Internal configurations managed by OmegaConf. + """ + config = OmegaConf.load(file) + if conf is not None: + config = OmegaConf.merge(config, conf) + return config + + +def init_logger(log_level): + """Initialize internal logger of EasyFL. + + Args: + log_level (int): Logger level, e.g., logging.INFO, logging.DEBUG + """ + log_formatter = logging.Formatter("%(asctime)s [%(threadName)s] [%(levelname)-5.5s] %(message)s") + root_logger = logging.getLogger() + + log_level = logging.INFO if not log_level else log_level + root_logger.setLevel(log_level) + + file_path = os.path.join(os.getcwd(), "logs") + if not os.path.exists(file_path): + os.makedirs(file_path) + file_path = path.join(file_path, "train" + time.strftime(".%m_%d_%H_%M_%S") + ".log") + file_handler = logging.FileHandler(file_path) + file_handler.setFormatter(log_formatter) + root_logger.addHandler(file_handler) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(log_formatter) + root_logger.addHandler(console_handler) + + +def init(conf=None, init_all=True): + """Initialize EasyFL. + + Args: + conf (dict, optional): Configurations. + init_all (bool, optional): Whether initialize dataset, model, server, and client other than configuration. + """ + global _global_coord + + config = init_conf(conf) + + init_logger(config.tracking.log_level) + + _set_random_seed(config.seed) + + _global_coord.init(config, init_all) + + +def run(): + """Run federated learning process.""" + global _global_coord + _global_coord.run() + + +def init_dataset(): + """Initialize dataset, either using registered dataset or out-of-the-box datasets set in config.""" + global _global_coord + _global_coord.init_dataset() + + +def init_model(): + """Initialize model, either using registered model or out-of–the-box model set in config. + + Returns: + nn.Module: Model used in federated learning. + """ + global _global_coord + _global_coord.init_model() + + return _global_coord.model + + +def start_server(args=None): + """Start federated learning server service for remote training. + + Args: + args (argparse.Namespace): Configurations passed in as arguments. + """ + global _global_coord + + _global_coord.start_server(args) + + +def start_client(args=None): + """Start federated learning client service for remote training. + + Args: + args (argparse.Namespace): Configurations passed in as arguments. + """ + global _global_coord + + _global_coord.start_client(args) + + +def get_coordinator(): + """Get the global coordinator instance. + + Returns: + :obj:`Coordinator`: global coordinator instance. + """ + return _global_coord + + +def register_dataset(train_data, test_data, val_data=None): + """Register datasets for federated learning training. + + Args: + train_data (:obj:`FederatedDataset`): Training dataset. + test_data (:obj:`FederatedDataset`): Testing dataset. + val_data (:obj:`FederatedDataset`): Validation dataset. + """ + global _global_coord + _global_coord.register_dataset(train_data, test_data, val_data) + + +def register_model(model): + """Register model for federated learning training. + + Args: + model (nn.Module): PyTorch model, both class and instance are acceptable. + """ + global _global_coord + _global_coord.register_model(model) + + +def register_server(server): + """Register federated learning server. + + Args: + server (:obj:`BaseServer`): Customized federated learning server. + """ + global _global_coord + _global_coord.register_server(server) + + +def register_client(client): + """Register federated learning client. + + Args: + client (:obj:`BaseClient`): Customized federated learning client. + """ + global _global_coord + _global_coord.register_client(client) diff --git a/easyfl/datasets/__init__.py b/easyfl/datasets/__init__.py new file mode 100644 index 0000000..2f56952 --- /dev/null +++ b/easyfl/datasets/__init__.py @@ -0,0 +1,26 @@ +from easyfl.datasets.data import construct_datasets +from easyfl.datasets.dataset import ( + FederatedDataset, + FederatedImageDataset, + FederatedTensorDataset, + FederatedTorchDataset, + TEST_IN_SERVER, + TEST_IN_CLIENT, +) +from easyfl.datasets.simulation import ( + data_simulation, + iid, + non_iid_dirichlet, + non_iid_class, + equal_division, + quantity_hetero, +) +from easyfl.datasets.utils.base_dataset import BaseDataset +from easyfl.datasets.femnist import Femnist +from easyfl.datasets.shakespeare import Shakespeare +from easyfl.datasets.cifar10 import Cifar10 +from easyfl.datasets.cifar100 import Cifar100 + +__all__ = ['FederatedDataset', 'FederatedImageDataset', 'FederatedTensorDataset', 'FederatedTorchDataset', + 'construct_datasets', 'data_simulation', 'iid', 'non_iid_dirichlet', 'non_iid_class', + 'equal_division', 'quantity_hetero', 'BaseDataset', 'Femnist', 'Shakespeare', 'Cifar10', 'Cifar100'] diff --git a/easyfl/datasets/cifar10/__init__.py b/easyfl/datasets/cifar10/__init__.py new file mode 100644 index 0000000..2539044 --- /dev/null +++ b/easyfl/datasets/cifar10/__init__.py @@ -0,0 +1 @@ +from easyfl.datasets.cifar10.cifar10 import Cifar10 diff --git a/easyfl/datasets/cifar10/cifar10.py b/easyfl/datasets/cifar10/cifar10.py new file mode 100644 index 0000000..2b887c3 --- /dev/null +++ b/easyfl/datasets/cifar10/cifar10.py @@ -0,0 +1,88 @@ +import logging +import os + +import torchvision + +from easyfl.datasets.simulation import data_simulation +from easyfl.datasets.utils.base_dataset import BaseDataset, CIFAR10 +from easyfl.datasets.utils.util import save_dict + +logger = logging.getLogger(__name__) + + +class Cifar10(BaseDataset): + def __init__(self, + root, + fraction, + split_type, + user, + iid_user_fraction=0.1, + train_test_split=0.9, + minsample=10, + num_class=80, + num_of_client=100, + class_per_client=2, + setting_folder=None, + seed=-1, + weights=None, + alpha=0.5): + super(Cifar10, self).__init__(root, + CIFAR10, + fraction, + split_type, + user, + iid_user_fraction, + train_test_split, + minsample, + num_class, + num_of_client, + class_per_client, + setting_folder, + seed) + self.train_data, self.test_data = {}, {} + self.split_type = split_type + self.num_of_client = num_of_client + self.weights = weights + self.alpha = alpha + self.min_size = minsample + self.class_per_client = class_per_client + + def download_packaged_dataset_and_extract(self, filename): + pass + + def download_raw_file_and_extract(self): + train_set = torchvision.datasets.CIFAR10(root=self.base_folder, train=True, download=True) + test_set = torchvision.datasets.CIFAR10(root=self.base_folder, train=False, download=True) + + self.train_data = { + 'x': train_set.data, + 'y': train_set.targets + } + + self.test_data = { + 'x': test_set.data, + 'y': test_set.targets + } + + def preprocess(self): + train_data_path = os.path.join(self.data_folder, "train") + test_data_path = os.path.join(self.data_folder, "test") + if not os.path.exists(self.data_folder): + os.makedirs(self.data_folder) + if self.weights is None and os.path.exists(train_data_path): + return + logger.info("Start CIFAR10 data simulation") + _, train_data = data_simulation(self.train_data['x'], + self.train_data['y'], + self.num_of_client, + self.split_type, + self.weights, + self.alpha, + self.min_size, + self.class_per_client) + logger.info("Complete CIFAR10 data simulation") + save_dict(train_data, train_data_path) + save_dict(self.test_data, test_data_path) + + def convert_data_to_json(self): + pass diff --git a/easyfl/datasets/cifar100/__init__.py b/easyfl/datasets/cifar100/__init__.py new file mode 100644 index 0000000..97144d9 --- /dev/null +++ b/easyfl/datasets/cifar100/__init__.py @@ -0,0 +1 @@ +from easyfl.datasets.cifar100.cifar100 import Cifar100 diff --git a/easyfl/datasets/cifar100/cifar100.py b/easyfl/datasets/cifar100/cifar100.py new file mode 100644 index 0000000..a73567f --- /dev/null +++ b/easyfl/datasets/cifar100/cifar100.py @@ -0,0 +1,88 @@ +import logging +import os + +import torchvision + +from easyfl.datasets.simulation import data_simulation +from easyfl.datasets.utils.base_dataset import BaseDataset, CIFAR100 +from easyfl.datasets.utils.util import save_dict + +logger = logging.getLogger(__name__) + + +class Cifar100(BaseDataset): + def __init__(self, + root, + fraction, + split_type, + user, + iid_user_fraction=0.1, + train_test_split=0.9, + minsample=10, + num_class=80, + num_of_client=100, + class_per_client=2, + setting_folder=None, + seed=-1, + weights=None, + alpha=0.5): + super(Cifar100, self).__init__(root, + CIFAR100, + fraction, + split_type, + user, + iid_user_fraction, + train_test_split, + minsample, + num_class, + num_of_client, + class_per_client, + setting_folder, + seed) + self.train_data, self.test_data = {}, {} + self.split_type = split_type + self.num_of_client = num_of_client + self.weights = weights + self.alpha = alpha + self.min_size = minsample + self.class_per_client = class_per_client + + def download_packaged_dataset_and_extract(self, filename): + pass + + def download_raw_file_and_extract(self): + train_set = torchvision.datasets.CIFAR100(root=self.base_folder, train=True, download=True) + test_set = torchvision.datasets.CIFAR100(root=self.base_folder, train=False, download=True) + + self.train_data = { + 'x': train_set.data, + 'y': train_set.targets + } + + self.test_data = { + 'x': test_set.data, + 'y': test_set.targets + } + + def preprocess(self): + train_data_path = os.path.join(self.data_folder, "train") + test_data_path = os.path.join(self.data_folder, "test") + if not os.path.exists(self.data_folder): + os.makedirs(self.data_folder) + if self.weights is None and os.path.exists(train_data_path): + return + logger.info("Start CIFAR10 data simulation") + _, train_data = data_simulation(self.train_data['x'], + self.train_data['y'], + self.num_of_client, + self.split_type, + self.weights, + self.alpha, + self.min_size, + self.class_per_client) + logger.info("Complete CIFAR10 data simulation") + save_dict(train_data, train_data_path) + save_dict(self.test_data, test_data_path) + + def convert_data_to_json(self): + pass diff --git a/easyfl/datasets/data.py b/easyfl/datasets/data.py new file mode 100644 index 0000000..3a393f7 --- /dev/null +++ b/easyfl/datasets/data.py @@ -0,0 +1,243 @@ +import importlib +import json +import logging +import os + +from easyfl.datasets.dataset import FederatedTensorDataset +from easyfl.datasets.utils.base_dataset import BaseDataset, CIFAR10, CIFAR100 +from easyfl.datasets.utils.util import load_dict + +logger = logging.getLogger(__name__) + + +def read_dir(data_dir): + clients = [] + groups = [] + data = {} + + files = os.listdir(data_dir) + files = [f for f in files if f.endswith('.json')] + for f in files: + file_path = os.path.join(data_dir, f) + with open(file_path, 'r') as inf: + cdata = json.load(inf) + clients.extend(cdata['users']) + if 'hierarchies' in cdata: + groups.extend(cdata['hierarchies']) + data.update(cdata['user_data']) + + clients = list(sorted(data.keys())) + return clients, groups, data + + +def read_data(dataset_name, train_data_dir, test_data_dir): + """Load datasets from data directories. + + Args: + dataset_name (str): The name of the dataset. + train_data_dir (str): The directory of training data. + test_data_dir (str): The directory of testing data. + + Returns: + list[str]: A list of client ids. + list[str]: A list of group ids for dataset with hierarchies. + dict: A dictionary of training data, e.g., {"id1": {"x": data, "y": label}, "id2": {"x": data, "y": label}}. + dict: A dictionary of testing data. The format is same as training data for FEMNIST and Shakespeare datasets. + For CIFAR datasets, the format is {"x": data, "y": label}, for centralized testing in the server. + """ + if dataset_name == CIFAR10 or dataset_name == CIFAR100: + train_data = load_dict(train_data_dir) + test_data = load_dict(test_data_dir) + return [], [], train_data, test_data + + # Data in the directories are `json` files with keys `users` and `user_data`. + train_clients, train_groups, train_data = read_dir(train_data_dir) + test_clients, test_groups, test_data = read_dir(test_data_dir) + + assert train_clients == test_clients + assert train_groups == test_groups + + return train_clients, train_groups, train_data, test_data + + +def load_data(root, + dataset_name, + num_of_clients, + split_type, + min_size, + class_per_client, + data_amount, + iid_fraction, + user, + train_test_split, + quantity_weights, + alpha): + """Simulate and load federated datasets. + + Args: + root (str): The root directory where datasets stored. + dataset_name (str): The name of the dataset. It currently supports: femnist, shakespeare, cifar10, and cifar100. + Among them, femnist and shakespeare are adopted from LEAF benchmark. + num_of_clients (int): The targeted number of clients to construct. + split_type (str): The type of statistical simulation, options: iid, dir, and class. + `iid` means independent and identically distributed data. + `niid` means non-independent and identically distributed data for Femnist and Shakespeare. + `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + min_size (int): The minimal number of samples in each client. + It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + class_per_client (int): The number of classes in each client. Only applicable when the split_type is 'class'. + data_amount (float): The fraction of data sampled for LEAF datasets. + e.g., 10% means that only 10% of total dataset size are used. + iid_fraction (float): The fraction of the number of clients used when the split_type is 'iid'. + user (bool): A flag to indicate whether partition users of the dataset into train-test groups. + Only applicable to LEAF datasets. + True means partitioning users of the dataset into train-test groups. + False means partitioning each users' samples into train-test groups. + train_test_split (float): The fraction of data for training; the rest are for testing. + e.g., 0.9 means 90% of data are used for training and 10% are used for testing. + quantity_weights (list[float]): The targeted distribution of quantities to simulate data quantity heterogeneity. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + The `num_of_clients` should be divisible by `len(weights)`. + None means clients are simulated with the same data quantity. + alpha (float): The parameter for Dirichlet distribution simulation, applicable only when split_type is `dir`. + + Returns: + dict: A dictionary of training data, e.g., {"id1": {"x": data, "y": label}, "id2": {"x": data, "y": label}}. + dict: A dictionary of testing data. + function: A function to preprocess training data. + function: A function to preprocess testing data. + torchvision.transforms.transforms.Compose: Training data transformation. + torchvision.transforms.transforms.Compose: Testing data transformation. + """ + user_str = "user" if user else "sample" + setting = BaseDataset.get_setting_folder(dataset_name, split_type, num_of_clients, min_size, class_per_client, + data_amount, iid_fraction, user_str, train_test_split, alpha, + quantity_weights) + dir_path = os.path.dirname(os.path.realpath(__file__)) + dataset_file = os.path.join(dir_path, "data_process", "{}.py".format(dataset_name)) + if not os.path.exists(dataset_file): + logger.error("Please specify a valid process file path for process_x and process_y functions.") + dataset_path = "easyfl.datasets.data_process.{}".format(dataset_name) + dataset_lib = importlib.import_module(dataset_path) + process_x = getattr(dataset_lib, "process_x", None) + process_y = getattr(dataset_lib, "process_y", None) + transform_train = getattr(dataset_lib, "transform_train", None) + transform_test = getattr(dataset_lib, "transform_test", None) + + data_dir = os.path.join(root, dataset_name) + if not data_dir: + os.makedirs(data_dir) + train_data_dir = os.path.join(data_dir, setting, "train") + test_data_dir = os.path.join(data_dir, setting, "test") + + if not os.path.exists(train_data_dir) or not os.path.exists(test_data_dir): + dataset_class_path = "easyfl.datasets.{}.{}".format(dataset_name, dataset_name) + dataset_class_lib = importlib.import_module(dataset_class_path) + class_name = dataset_name.capitalize() + dataset = getattr(dataset_class_lib, class_name)(root=data_dir, + fraction=data_amount, + split_type=split_type, + user=user, + iid_user_fraction=iid_fraction, + train_test_split=train_test_split, + minsample=min_size, + num_of_client=num_of_clients, + class_per_client=class_per_client, + setting_folder=setting, + alpha=alpha, + weights=quantity_weights) + try: + filename = f"{setting}.zip" + dataset.download_packaged_dataset_and_extract(filename) + logger.info(f"Downloaded packaged dataset {dataset_name}: {filename}") + except Exception as e: + logger.info(f"Failed to download packaged dataset: {e.args}") + + # CIFAR10 generate data in setup() stage, LEAF related datasets generate data in sampling() + if not os.path.exists(train_data_dir): + dataset.setup() + if not os.path.exists(train_data_dir): + dataset.sampling() + + users, train_groups, train_data, test_data = read_data(dataset_name, train_data_dir, test_data_dir) + return train_data, test_data, process_x, process_y, transform_train, transform_test + + +def construct_datasets(root, + dataset_name, + num_of_clients, + split_type, + min_size, + class_per_client, + data_amount, + iid_fraction, + user, + train_test_split, + quantity_weights, + alpha): + """Construct and load provided federated learning datasets. + + Args: + root (str): The root directory where datasets stored. + dataset_name (str): The name of the dataset. It currently supports: femnist, shakespeare, cifar10, and cifar100. + Among them, femnist and shakespeare are adopted from LEAF benchmark. + num_of_clients (int): The targeted number of clients to construct. + split_type (str): The type of statistical simulation, options: iid, dir, and class. + `iid` means independent and identically distributed data. + `niid` means non-independent and identically distributed data for Femnist and Shakespeare. + `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + min_size (int): The minimal number of samples in each client. + It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + class_per_client (int): The number of classes in each client. Only applicable when the split_type is 'class'. + data_amount (float): The fraction of data sampled for LEAF datasets. + e.g., 10% means that only 10% of total dataset size are used. + iid_fraction (float): The fraction of the number of clients used when the split_type is 'iid'. + user (bool): A flag to indicate whether partition users of the dataset into train-test groups. + Only applicable to LEAF datasets. + True means partitioning users of the dataset into train-test groups. + False means partitioning each users' samples into train-test groups. + train_test_split (float): The fraction of data for training; the rest are for testing. + e.g., 0.9 means 90% of data are used for training and 10% are used for testing. + quantity_weights (list[float]): The targeted distribution of quantities to simulate data quantity heterogeneity. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + The `num_of_clients` should be divisible by `len(weights)`. + None means clients are simulated with the same data quantity. + alpha (float): The parameter for Dirichlet distribution simulation, applicable only when split_type is `dir`. + + Returns: + :obj:`FederatedDataset`: Training dataset. + :obj:`FederatedDataset`: Testing dataset. + """ + train_data, test_data, process_x, process_y, transform_train, transform_test = load_data(root, + dataset_name, + num_of_clients, + split_type, + min_size, + class_per_client, + data_amount, + iid_fraction, + user, + train_test_split, + quantity_weights, + alpha) + + # CIFAR datasets are simulated. + test_simulated = True + if dataset_name == CIFAR10 or dataset_name == CIFAR100: + test_simulated = False + + train_data = FederatedTensorDataset(train_data, + simulated=True, + do_simulate=False, + process_x=process_x, + process_y=process_y, + transform=transform_train) + test_data = FederatedTensorDataset(test_data, + simulated=test_simulated, + do_simulate=False, + process_x=process_x, + process_y=process_y, + transform=transform_test) + return train_data, test_data diff --git a/easyfl/datasets/data_process/__init__.py b/easyfl/datasets/data_process/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/datasets/data_process/cifar10.py b/easyfl/datasets/data_process/cifar10.py new file mode 100644 index 0000000..9c2ffbe --- /dev/null +++ b/easyfl/datasets/data_process/cifar10.py @@ -0,0 +1,55 @@ +import numpy as np +import torch +import torchvision +from torchvision import transforms + + +class Cutout(object): + """Cutout data augmentation is adopted from https://github.com/uoguelph-mlrg/Cutout""" + + def __init__(self, length=16): + self.length = length + + def __call__(self, img): + """ + Args: + img (Tensor): Tensor image of size (C, H, W). + + Returns: + Tensor: Image with n_holes of dimension length x length cut out of it. + """ + h = img.size(1) + w = img.size(2) + + mask = np.ones((h, w), np.float32) + + y = np.random.randint(h) + x = np.random.randint(w) + + y1 = np.clip(y - self.length // 2, 0, h) + y2 = np.clip(y + self.length // 2, 0, h) + x1 = np.clip(x - self.length // 2, 0, w) + x2 = np.clip(x + self.length // 2, 0, w) + + mask[y1: y2, x1: x2] = 0. + + mask = torch.from_numpy(mask) + mask = mask.expand_as(img) + img *= mask + return img + + +transform_train = transforms.Compose([ + torchvision.transforms.ToPILImage(mode='RGB'), + transforms.RandomCrop(32, padding=4), + transforms.RandomHorizontalFlip(), + transforms.ToTensor(), + transforms.Normalize((0.49139968, 0.48215827, 0.44653124), (0.24703233, 0.24348505, 0.26158768)), +]) + +transform_train.transforms.append(Cutout()) + +transform_test = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize((0.49139968, 0.48215827, 0.44653124), (0.24703233, 0.24348505, 0.26158768)), +]) diff --git a/easyfl/datasets/data_process/cifar100.py b/easyfl/datasets/data_process/cifar100.py new file mode 100644 index 0000000..2ae004b --- /dev/null +++ b/easyfl/datasets/data_process/cifar100.py @@ -0,0 +1,55 @@ +import numpy as np +import torch +import torchvision +from torchvision import transforms + + +class Cutout(object): + """Cutout data augmentation is adopted from https://github.com/uoguelph-mlrg/Cutout""" + + def __init__(self, length=16): + self.length = length + + def __call__(self, img): + """ + Args: + img (Tensor): Tensor image of size (C, H, W). + + Returns: + Tensor: Image with n_holes of dimension length x length cut out of it. + """ + h = img.size(1) + w = img.size(2) + + mask = np.ones((h, w), np.float32) + + y = np.random.randint(h) + x = np.random.randint(w) + + y1 = np.clip(y - self.length // 2, 0, h) + y2 = np.clip(y + self.length // 2, 0, h) + x1 = np.clip(x - self.length // 2, 0, w) + x2 = np.clip(x + self.length // 2, 0, w) + + mask[y1: y2, x1: x2] = 0. + + mask = torch.from_numpy(mask) + mask = mask.expand_as(img) + img *= mask + return img + + +transform_train = transforms.Compose([ + torchvision.transforms.ToPILImage(mode='RGB'), + transforms.RandomCrop(32, padding=4), + transforms.RandomHorizontalFlip(), + transforms.ToTensor(), + transforms.Normalize((0.49139968, 0.48215827, 0.44653124), (0.24703233, 0.24348505, 0.26158768)), +]) + +transform_train.transforms.append(Cutout()) + +transform_test = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize((0.49139968, 0.48215827, 0.44653124), (0.24703233, 0.24348505, 0.26158768)), +]) diff --git a/easyfl/datasets/data_process/femnist.py b/easyfl/datasets/data_process/femnist.py new file mode 100644 index 0000000..fc0da5a --- /dev/null +++ b/easyfl/datasets/data_process/femnist.py @@ -0,0 +1,10 @@ +import torch + + +def process_x(raw_x_batch): + raw_x_batch = torch.FloatTensor(raw_x_batch) + return raw_x_batch.view(-1, 1, 28, 28) + + +def process_y(raw_y_batch): + return torch.LongTensor(raw_y_batch) diff --git a/easyfl/datasets/data_process/language_utils.py b/easyfl/datasets/data_process/language_utils.py new file mode 100755 index 0000000..865585d --- /dev/null +++ b/easyfl/datasets/data_process/language_utils.py @@ -0,0 +1,142 @@ +""" +These codes are adopted from LEAF. +""" + +import json +import re + +import numpy as np + +# ------------------------ +# utils for shakespeare dataset + +ALL_LETTERS = "\n !\"&'(),-.0123456789:;>?ABCDEFGHIJKLMNOPQRSTUVWXYZ[]abcdefghijklmnopqrstuvwxyz}" +NUM_LETTERS = len(ALL_LETTERS) + + +def _one_hot(index, size): + """returns one-hot vector with given size and value 1 at given index""" + vec = [0 for _ in range(size)] + vec[int(index)] = 1 + return vec + + +def letter_to_vec(letter): + """returns one-hot representation of given letter""" + index = ALL_LETTERS.find(letter) + return _one_hot(index, NUM_LETTERS) + + +def word_to_indices(word): + """returns a list of character indices + + Args: + word: string + + Return: + indices: int list with length len(word) + """ + indices = [] + for c in word: + indices.append(ALL_LETTERS.find(c)) + return indices + + +# ------------------------ +# utils for sent140 dataset + + +def split_line(line): + """split given line/phrase into list of words + + Args: + line: string representing phrase to be split + + Return: + list of strings, with each string representing a word + """ + return re.findall(r"[\w']+|[.,!?;]", line) + + +def _word_to_index(word, indd): + """returns index of given word based on given lookup dictionary + + returns the length of the lookup dictionary if word not found + + Args: + word: string + indd: dictionary with string words as keys and int indices as values + """ + if word in indd: + return indd[word] + else: + return len(indd) + + +def line_to_indices(line, word2id, max_words=25): + """converts given phrase into list of word indices + + if the phrase has more than max_words words, returns a list containing + indices of the first max_words words + if the phrase has less than max_words words, repeatedly appends integer + representing unknown index to returned list until the list's length is + max_words + + Args: + line: string representing phrase/sequence of words + word2id: dictionary with string words as keys and int indices as values + max_words: maximum number of word indices in returned list + + Return: + indl: list of word indices, one index for each word in phrase + """ + unk_id = len(word2id) + line_list = split_line(line) # split phrase in words + indl = [word2id[w] if w in word2id else unk_id for w in line_list[:max_words]] + indl += [unk_id] * (max_words - len(indl)) + return indl + + +def bag_of_words(line, vocab): + """returns bag of words representation of given phrase using given vocab + + Args: + line: string representing phrase to be parsed + vocab: dictionary with words as keys and indices as values + + Return: + integer list + """ + bag = [0] * len(vocab) + words = split_line(line) + for w in words: + if w in vocab: + bag[vocab[w]] += 1 + return bag + + +def get_word_emb_arr(path): + with open(path, 'r') as inf: + embs = json.load(inf) + vocab = embs['vocab'] + word_emb_arr = np.array(embs['emba']) + indd = {} + for i in range(len(vocab)): + indd[vocab[i]] = i + vocab = {w: i for i, w in enumerate(embs['vocab'])} + return word_emb_arr, indd, vocab + + +def val_to_vec(size, val): + """Converts target into one-hot. + + Args: + size: Size of vector. + val: Integer in range [0, size]. + Returns: + vec: one-hot vector with a 1 in the val element. + """ + assert 0 <= val < size + vec = [0 for _ in range(size)] + vec[int(val)] = 1 + return vec diff --git a/easyfl/datasets/data_process/shakespeare.py b/easyfl/datasets/data_process/shakespeare.py new file mode 100644 index 0000000..5f34b19 --- /dev/null +++ b/easyfl/datasets/data_process/shakespeare.py @@ -0,0 +1,15 @@ +import numpy as np +import torch + +from easyfl.datasets.data_process.language_utils import word_to_indices, letter_to_vec + + +def process_x(raw_x_batch): + x_batch = [word_to_indices(word) for word in raw_x_batch] + x_batch = np.array(x_batch) + return torch.LongTensor(x_batch) + + +def process_y(raw_y_batch): + y_batch = [np.argmax(letter_to_vec(c)) for c in raw_y_batch] + return torch.LongTensor(y_batch) diff --git a/easyfl/datasets/dataset.py b/easyfl/datasets/dataset.py new file mode 100644 index 0000000..1d7bfc0 --- /dev/null +++ b/easyfl/datasets/dataset.py @@ -0,0 +1,427 @@ +import logging +import os +from abc import ABC, abstractmethod + +import numpy as np +import torch +from torch.utils.data import TensorDataset, DataLoader +from torchvision.datasets.folder import default_loader, make_dataset + +from easyfl.datasets.dataset_util import TransformDataset, ImageDataset +from easyfl.datasets.simulation import data_simulation, SIMULATE_IID + +logger = logging.getLogger(__name__) + +TEST_IN_SERVER = "test_in_server" +TEST_IN_CLIENT = "test_in_client" + +IMG_EXTENSIONS = ('.jpg', '.jpeg', '.png', '.ppm', '.bmp', '.pgm', '.tif', '.tiff', '.webp') + +DEFAULT_MERGED_ID = "Merged" + + +def default_process_x(raw_x_batch): + return torch.tensor(raw_x_batch) + + +def default_process_y(raw_y_batch): + return torch.tensor(raw_y_batch) + + +class FederatedDataset(ABC): + """The abstract class of federated dataset for EasyFL.""" + + def __init__(self): + pass + + @abstractmethod + def loader(self, batch_size, shuffle=True): + """Get data loader. + + Args: + batch_size (int): The batch size of the data loader. + shuffle (bool): Whether shuffle the data in the loader. + """ + raise NotImplementedError("Data loader not implemented") + + @abstractmethod + def size(self, cid): + """Get dataset size. + + Args: + cid (str): client id. + """ + raise NotImplementedError("Size not implemented") + + @property + def users(self): + """Get client ids of the federated dataset.""" + raise NotImplementedError("Users not implemented") + + +class FederatedTensorDataset(FederatedDataset): + """Federated tensor dataset, data of clients are in format of tensor or list. + + Args: + data (dict): A dictionary of data, e.g., {"id1": {"x": [[], [], ...], "y": [...]]}}. + If simulation is not done previously, it is in format of {'x':[[],[], ...], 'y': [...]}. + transform (torchvision.transforms.transforms.Compose, optional): Transformation for data. + target_transform (torchvision.transforms.transforms.Compose, optional): Transformation for data labels. + process_x (function, optional): A function to preprocess training data. + process_y (function, optional): A function to preprocess testing data. + simulated (bool, optional): Whether the dataset is simulated to federated learning settings. + do_simulate (bool, optional): Whether conduct simulation. It is only effective if it is not simulated. + num_of_clients (int, optional): number of clients for simulation. Only need if doing simulation. + simulation_method(optional): split method. Only need if doing simulation. + weights (list[float], optional): The targeted distribution of quantities to simulate quantity heterogeneity. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + The `num_of_clients` should be divisible by `len(weights)`. + None means clients are simulated with the same data quantity. + alpha (float, optional): The parameter for Dirichlet distribution simulation, only for dir simulation. + min_size (int, optional): The minimal number of samples in each client, only for dir simulation. + class_per_client (int, optional): The number of classes in each client, only for non-iid by class simulation. + """ + + def __init__(self, + data, + transform=None, + target_transform=None, + process_x=default_process_x, + process_y=default_process_x, + simulated=False, + do_simulate=True, + num_of_clients=10, + simulation_method=SIMULATE_IID, + weights=None, + alpha=0.5, + min_size=10, + class_per_client=1): + super(FederatedTensorDataset, self).__init__() + self.simulated = simulated + self.data = data + self._validate_data(self.data) + self.process_x = process_x + self.process_y = process_y + self.transform = transform + self.target_transform = target_transform + if simulated: + self._users = sorted(list(self.data.keys())) + + elif do_simulate: + # For simulation method provided, we support testing in server for now + # TODO: support simulation for test data => test in clients + self.simulation(num_of_clients, simulation_method, weights, alpha, min_size, class_per_client) + + def simulation(self, num_of_clients, niid=SIMULATE_IID, weights=None, alpha=0.5, min_size=10, class_per_client=1): + if self.simulated: + logger.warning("The dataset is already simulated, the simulation would not proceed.") + return + self._users, self.data = data_simulation( + self.data['x'], + self.data['y'], + num_of_clients, + niid, + weights, + alpha, + min_size, + class_per_client) + self.simulated = True + + def loader(self, batch_size, client_id=None, shuffle=True, seed=0, transform=None, drop_last=False): + """Get dataset loader. + + Args: + batch_size (int): The batch size. + client_id (str, optional): The id of client. + shuffle (bool, optional): Whether to shuffle before batching. + seed (int, optional): The shuffle seed. + transform (torchvision.transforms.transforms.Compose, optional): Data transformation. + drop_last (bool, optional): Whether to drop the last batch if its size is smaller than batch size. + + Returns: + torch.utils.data.DataLoader: The data loader to load data. + """ + # Simulation need to be done before creating a data loader + if client_id is None: + data = self.data + else: + data = self.data[client_id] + + data_x = data['x'] + data_y = data['y'] + + data_x = np.array(data_x) + data_y = np.array(data_y) + + data_x = self._input_process(data_x) + data_y = self._label_process(data_y) + if shuffle: + np.random.seed(seed) + rng_state = np.random.get_state() + np.random.shuffle(data_x) + np.random.set_state(rng_state) + np.random.shuffle(data_y) + + transform = self.transform if transform is None else transform + if transform is not None: + dataset = TransformDataset(data_x, + data_y, + transform_x=transform, + transform_y=self.target_transform) + else: + dataset = TensorDataset(data_x, data_y) + loader = DataLoader(dataset=dataset, + batch_size=batch_size, + shuffle=shuffle, + drop_last=drop_last) + return loader + + @property + def users(self): + return self._users + + @users.setter + def users(self, value): + self._users = value + + def size(self, cid=None): + if cid is not None: + return len(self.data[cid]['y']) + else: + return len(self.data['y']) + + def total_size(self): + if 'y' in self.data: + return len(self.data['y']) + else: + return sum([len(self.data[i]['y']) for i in self.data]) + + def _input_process(self, sample): + if self.process_x is not None: + sample = self.process_x(sample) + return sample + + def _label_process(self, label): + if self.process_y is not None: + label = self.process_y(label) + return label + + def _validate_data(self, data): + if self.simulated: + for i in data: + assert len(data[i]['x']) == len(data[i]['y']) + else: + assert len(data['x']) == len(data['y']) + + +class FederatedImageDataset(FederatedDataset): + """ + Federated image dataset, data of clients are in format of image folder. + + Args: + root (str|list[str]): The root directory or directories of image data folder. + If the dataset is simulated to multiple clients, the root is a list of directories. + Otherwise, it is the directory of an image data folder. + simulated (bool): Whether the dataset is simulated to federated learning settings. + do_simulate (bool, optional): Whether conduct simulation. It is only effective if it is not simulated. + extensions (list[str], optional): A list of allowed image extensions. + Only one of `extensions` and `is_valid_file` can be specified. + is_valid_file (function, optional): A function that takes path of an Image file and check if it is valid. + Only one of `extensions` and `is_valid_file` can be specified. + transform (torchvision.transforms.transforms.Compose, optional): Transformation for data. + target_transform (torchvision.transforms.transforms.Compose, optional): Transformation for data labels. + num_of_clients (int, optional): number of clients for simulation. Only need if doing simulation. + simulation_method(optional): split method. Only need if doing simulation. + weights (list[float], optional): The targeted distribution of quantities to simulate quantity heterogeneity. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + The `num_of_clients` should be divisible by `len(weights)`. + None means clients are simulated with the same data quantity. + alpha (float, optional): The parameter for Dirichlet distribution simulation, only for dir simulation. + min_size (int, optional): The minimal number of samples in each client, only for dir simulation. + class_per_client (int, optional): The number of classes in each client, only for non-iid by class simulation. + client_ids (list[str], optional): A list of client ids. + Each client id matches with an element in roots. + The client ids are ["f0000001", "f00000002", ...] if not specified. + """ + + def __init__(self, + root, + simulated, + do_simulate=True, + extensions=IMG_EXTENSIONS, + is_valid_file=None, + transform=None, + target_transform=None, + client_ids="default", + num_of_clients=10, + simulation_method=SIMULATE_IID, + weights=None, + alpha=0.5, + min_size=10, + class_per_client=1): + super(FederatedImageDataset, self).__init__() + self.simulated = simulated + self.transform = transform + self.target_transform = target_transform + + if self.simulated: + self.data = {} + self.classes = {} + self.class_to_idx = {} + self.roots = root + self.num_of_clients = len(self.roots) + if client_ids == "default": + self.users = ["f%07.0f" % (i) for i in range(len(self.roots))] + else: + self.users = client_ids + for i in range(self.num_of_clients): + current_client_id = self.users[i] + classes, class_to_idx = self._find_classes(self.roots[i]) + samples = make_dataset(self.roots[i], class_to_idx, extensions, is_valid_file) + if len(samples) == 0: + msg = "Found 0 files in subfolders of: {}\n".format(self.root) + if extensions is not None: + msg += "Supported extensions are: {}".format(",".join(extensions)) + raise RuntimeError(msg) + + self.classes[current_client_id] = classes + self.class_to_idx[current_client_id] = class_to_idx + temp_client = {'x': [i[0] for i in samples], 'y': [i[1] for i in samples]} + self.data[current_client_id] = temp_client + elif do_simulate: + self.root = root + classes, class_to_idx = self._find_classes(self.root) + samples = make_dataset(self.root, class_to_idx, extensions, is_valid_file) + if len(samples) == 0: + msg = "Found 0 files in subfolders of: {}\n".format(self.root) + if extensions is not None: + msg += "Supported extensions are: {}".format(",".join(extensions)) + raise RuntimeError(msg) + self.extensions = extensions + self.classes = classes + self.class_to_idx = class_to_idx + self.samples = samples + self.inputs = [i[0] for i in self.samples] + self.labels = [i[1] for i in self.samples] + self.simulation(num_of_clients, simulation_method, weights, alpha, min_size, class_per_client) + + def simulation(self, num_of_clients, niid="iid", weights=[1], alpha=0.5, min_size=10, class_per_client=1): + if self.simulated: + logger.warning("The dataset is already simulated, the simulation would not proceed.") + return + self.users, self.data = data_simulation(self.inputs, + self.labels, + num_of_clients, + niid, + weights, + alpha, + min_size, + class_per_client) + self.simulated = True + + def loader(self, batch_size, client_id=None, shuffle=True, seed=0, num_workers=2, transform=None): + """Get dataset loader. + + Args: + batch_size (int): The batch size. + client_id (str, optional): The id of client. + shuffle (bool, optional): Whether to shuffle before batching. + seed (int, optional): The shuffle seed. + transform (torchvision.transforms.transforms.Compose, optional): Data transformation. + num_workers (int, optional): The number of workers for dataset loader. + + Returns: + torch.utils.data.DataLoader: The data loader to load data. + """ + assert self.simulated is True + if client_id is None: + data = self.data + else: + data = self.data[client_id] + data_x = data['x'][:] + data_y = data['y'][:] + + # randomly shuffle data + if shuffle: + np.random.seed(seed) + rng_state = np.random.get_state() + np.random.shuffle(data_x) + np.random.set_state(rng_state) + np.random.shuffle(data_y) + + transform = self.transform if transform is None else transform + dataset = ImageDataset(data_x, data_y, transform, self.target_transform) + loader = torch.utils.data.DataLoader(dataset, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + pin_memory=False) + return loader + + @property + def users(self): + return self._users + + @users.setter + def users(self, value): + self._users = value + + def size(self, cid=None): + if cid is not None: + return len(self.data[cid]['y']) + else: + return len(self.data['y']) + + def _find_classes(self, dir): + """Get the classes of the dataset. + + Args: + dir (str): Root directory path. + + Returns: + tuple: (classes, class_to_idx) where classes are relative to directory and class_to_idx is a dictionary. + + Note: + Need to ensure that no class is a subdirectory of another. + """ + classes = [d.name for d in os.scandir(dir) if d.is_dir()] + classes.sort() + class_to_idx = {cls_name: i for i, cls_name in enumerate(classes)} + return classes, class_to_idx + + +class FederatedTorchDataset(FederatedDataset): + """Wrapper over PyTorch dataset. + + Args: + data (dict): A dictionary of client datasets, format {"client_id": loader1, "client_id2": loader2}. + """ + + def __init__(self, data, users): + super(FederatedTorchDataset, self).__init__() + self.data = data + self._users = users + + def loader(self, batch_size, client_id=None, shuffle=True, seed=0, num_workers=2, transform=None): + if client_id is None: + data = self.data + else: + data = self.data[client_id] + + loader = torch.utils.data.DataLoader( + data, batch_size=batch_size, shuffle=shuffle, num_workers=num_workers, pin_memory=True) + return loader + + @property + def users(self): + return self._users + + @users.setter + def users(self, value): + self._users = value + + def size(self, cid=None): + if cid is not None: + return len(self.data[cid]) + else: + return len(self.data) diff --git a/easyfl/datasets/dataset_util.py b/easyfl/datasets/dataset_util.py new file mode 100644 index 0000000..49136ee --- /dev/null +++ b/easyfl/datasets/dataset_util.py @@ -0,0 +1,45 @@ +from PIL import Image +from torch.utils.data import Dataset + + +class ImageDataset(Dataset): + def __init__(self, images, labels, transform_x=None, transform_y=None): + self.images = images + self.labels = labels + self.transform_x = transform_x + self.transform_y = transform_y + + def __len__(self): + return len(self.labels) + + def __getitem__(self, index): + data, label = self.images[index], self.labels[index] + if self.transform_x is not None: + data = self.transform_x(Image.open(data)) + else: + data = Image.open(data) + if self.transform_y is not None: + label = self.transform_y(label) + return data, label + + +class TransformDataset(Dataset): + def __init__(self, images, labels, transform_x=None, transform_y=None): + self.data = images + self.targets = labels + self.transform_x = transform_x + self.transform_y = transform_y + + def __len__(self): + return len(self.data) + + def __getitem__(self, idx): + sample = self.data[idx] + target = self.targets[idx] + + if self.transform_x: + sample = self.transform_x(sample) + if self.transform_y: + target = self.transform_y(target) + + return sample, target diff --git a/easyfl/datasets/femnist/__init__.py b/easyfl/datasets/femnist/__init__.py new file mode 100644 index 0000000..3114b65 --- /dev/null +++ b/easyfl/datasets/femnist/__init__.py @@ -0,0 +1 @@ +from easyfl.datasets.femnist.femnist import Femnist diff --git a/easyfl/datasets/femnist/femnist.py b/easyfl/datasets/femnist/femnist.py new file mode 100644 index 0000000..1242bea --- /dev/null +++ b/easyfl/datasets/femnist/femnist.py @@ -0,0 +1,109 @@ +import logging +import os + +from easyfl.datasets.femnist.preprocess.data_to_json import data_to_json +from easyfl.datasets.femnist.preprocess.get_file_dirs import get_file_dir +from easyfl.datasets.femnist.preprocess.get_hashes import get_hash +from easyfl.datasets.femnist.preprocess.group_by_writer import group_by_writer +from easyfl.datasets.femnist.preprocess.match_hashes import match_hash +from easyfl.datasets.utils.base_dataset import BaseDataset +from easyfl.datasets.utils.download import download_url, extract_archive, download_from_google_drive + +logger = logging.getLogger(__name__) + + +class Femnist(BaseDataset): + """FEMNIST dataset implementation. It gets FEMNIST dataset according to configurations. + It stores the processed datasets locally. + + Attributes: + base_folder (str): The base folder path of the datasets folder. + class_url (str): The url to get the by_class split FEMNIST. + write_url (str): The url to get the by_write split FEMNIST. + """ + + def __init__(self, + root, + fraction, + split_type, + user, + iid_user_fraction=0.1, + train_test_split=0.9, + minsample=10, + num_class=62, + num_of_client=100, + class_per_client=2, + setting_folder=None, + seed=-1, + **kwargs): + super(Femnist, self).__init__(root, + "femnist", + fraction, + split_type, + user, + iid_user_fraction, + train_test_split, + minsample, + num_class, + num_of_client, + class_per_client, + setting_folder, + seed) + self.class_url = "https://s3.amazonaws.com/nist-srd/SD19/by_class.zip" + self.write_url = "https://s3.amazonaws.com/nist-srd/SD19/by_write.zip" + self.packaged_data_files = { + "femnist_niid_100_10_1_0.05_0.1_sample_0.9.zip": "https://dl.dropboxusercontent.com/s/oyhegd3c0pxa0tl/femnist_niid_100_10_1_0.05_0.1_sample_0.9.zip", + "femnist_iid_100_10_1_0.05_0.1_sample_0.9.zip": "https://dl.dropboxusercontent.com/s/jcg0xrz5qrri4tv/femnist_iid_100_10_1_0.05_0.1_sample_0.9.zip" + } + # Google Drive ids + # self.packaged_data_files = { + # "femnist_niid_100_10_1_0.05_0.1_sample_0.9.zip": "11vAxASl-af41iHpFqW2jixs1jOUZDXMS", + # "femnist_iid_100_10_1_0.05_0.1_sample_0.9.zip": "1U9Sn2ACbidwhhihdJdZPfK2YddPMr33k" + # } + + def download_packaged_dataset_and_extract(self, filename): + file_path = download_url(self.packaged_data_files[filename], self.base_folder) + extract_archive(file_path, remove_finished=True) + + def download_raw_file_and_extract(self): + raw_data_folder = os.path.join(self.base_folder, "raw_data") + if not os.path.exists(raw_data_folder): + os.makedirs(raw_data_folder) + elif os.listdir(raw_data_folder): + logger.info("raw file exists") + return + class_path = download_url(self.class_url, raw_data_folder) + write_path = download_url(self.write_url, raw_data_folder) + extract_archive(class_path, remove_finished=True) + extract_archive(write_path, remove_finished=True) + logger.info("raw file is downloaded") + + def preprocess(self): + intermediate_folder = os.path.join(self.base_folder, "intermediate") + if not os.path.exists(intermediate_folder): + os.makedirs(intermediate_folder) + if not os.path.exists(intermediate_folder + "/class_file_dirs.pkl"): + logger.info("extracting file directories of images") + get_file_dir(self.base_folder) + logger.info("finished extracting file directories of images") + if not os.path.exists(intermediate_folder + "/class_file_hashes.pkl"): + logger.info("calculating image hashes") + get_hash(self.base_folder) + logger.info("finished calculating image hashes") + if not os.path.exists(intermediate_folder + "/write_with_class.pkl"): + logger.info("assigning class labels to write images") + match_hash(self.base_folder) + logger.info("finished assigning class labels to write images") + if not os.path.exists(intermediate_folder + "/images_by_writer.pkl"): + logger.info("grouping images by writer") + group_by_writer(self.base_folder) + logger.info("finished grouping images by writer") + + def convert_data_to_json(self): + all_data_folder = os.path.join(self.base_folder, "all_data") + if not os.path.exists(all_data_folder): + os.makedirs(all_data_folder) + if not os.listdir(all_data_folder): + logger.info("converting data to .json format") + data_to_json(self.base_folder) + logger.info("finished converting data to .json format") diff --git a/easyfl/datasets/femnist/preprocess/__init__.py b/easyfl/datasets/femnist/preprocess/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/datasets/femnist/preprocess/data_to_json.py b/easyfl/datasets/femnist/preprocess/data_to_json.py new file mode 100644 index 0000000..ab3a563 --- /dev/null +++ b/easyfl/datasets/femnist/preprocess/data_to_json.py @@ -0,0 +1,94 @@ +""" +These codes are adopted from LEAF with some modifications. + +It converts a list of (writer, [list of (file,class)]) tuples into a json object of the form: + {users: [bob, etc], num_samples: [124, etc.], + user_data: {bob : {x:[img1,img2,etc], y:[class1,class2,etc]}, etc}}, +where "img_" is a vectorized representation of the corresponding image. +""" + +from __future__ import division + +import json +import math +import os + +import numpy as np +from PIL import Image + +from easyfl.datasets.utils import util + +MAX_WRITERS = 100 # max number of writers per json file. + + +def relabel_class(c): + """ + maps hexadecimal class value (string) to a decimal number + returns: + - 0 through 9 for classes representing respective numbers + - 10 through 35 for classes representing respective uppercase letters + - 36 through 61 for classes representing respective lowercase letters + """ + if c.isdigit() and int(c) < 40: + return int(c) - 30 + elif int(c, 16) <= 90: # uppercase + return int(c, 16) - 55 + else: + return int(c, 16) - 61 + + +def data_to_json(base_folder): + by_writer_dir = os.path.join(base_folder, "intermediate", "images_by_writer") + writers = util.load_obj(by_writer_dir) + + num_json = int(math.ceil(len(writers) / MAX_WRITERS)) + + users = [] + num_samples = [] + user_data = {} + + writer_count = 0 + json_index = 0 + for (w, l) in writers: + + users.append(w) + num_samples.append(len(l)) + user_data[w] = {"x": [], "y": []} + + size = 28, 28 # original image size is 128, 128 + for (f, c) in l: + file_path = os.path.join(base_folder, f) + img = Image.open(file_path) + gray = img.convert("L") + gray.thumbnail(size, Image.ANTIALIAS) + arr = np.asarray(gray).copy() + vec = arr.flatten() + vec = vec / 255 # scale all pixel values to between 0 and 1 + vec = vec.tolist() + + nc = relabel_class(c) + + user_data[w]["x"].append(vec) + user_data[w]["y"].append(nc) + + writer_count += 1 + if writer_count == MAX_WRITERS: + all_data = {} + all_data["users"] = users + all_data["num_samples"] = num_samples + all_data["user_data"] = user_data + + file_name = "all_data_%d.json" % json_index + file_path = os.path.join(base_folder, "all_data", file_name) + + print("writing %s" % file_name) + + with open(file_path, "w") as outfile: + json.dump(all_data, outfile) + + writer_count = 0 + json_index += 1 + + users[:] = [] + num_samples[:] = [] + user_data.clear() diff --git a/easyfl/datasets/femnist/preprocess/get_file_dirs.py b/easyfl/datasets/femnist/preprocess/get_file_dirs.py new file mode 100644 index 0000000..6b5cc53 --- /dev/null +++ b/easyfl/datasets/femnist/preprocess/get_file_dirs.py @@ -0,0 +1,71 @@ +""" +These codes are adopted from LEAF with some modifications. + +Creates .pkl files for: +1. list of directories of every image in 'by_class' +2. list of directories of every image in 'by_write' +the hierarchal structure of the data is as follows: +- by_class -> classes -> folders containing images -> images +- by_write -> folders containing writers -> writer -> types of images -> images +the directories written into the files are of the form 'raw_data/...' +""" + +import os + +from easyfl.datasets.utils import util + + +def get_file_dir(base_folder): + class_files = [] # (class, file directory) + write_files = [] # (writer, file directory) + + class_dir = os.path.join(base_folder, "raw_data", "by_class") + rel_class_dir = os.path.join(base_folder, "raw_data", "by_class") + classes = os.listdir(class_dir) + classes = [c for c in classes if len(c) == 2] + + for cl in classes: + cldir = os.path.join(class_dir, cl) + rel_cldir = os.path.join(rel_class_dir, cl) + subcls = os.listdir(cldir) + + subcls = [s for s in subcls if (("hsf" in s) and ("mit" not in s))] + + for subcl in subcls: + subcldir = os.path.join(cldir, subcl) + rel_subcldir = os.path.join(rel_cldir, subcl) + images = os.listdir(subcldir) + image_dirs = [os.path.join(rel_subcldir, i) for i in images] + + for image_dir in image_dirs: + class_files.append((cl, image_dir)) + + write_dir = os.path.join(base_folder, "raw_data", "by_write") + rel_write_dir = os.path.join(base_folder, "raw_data", "by_write") + write_parts = os.listdir(write_dir) + + for write_part in write_parts: + writers_dir = os.path.join(write_dir, write_part) + rel_writers_dir = os.path.join(rel_write_dir, write_part) + writers = os.listdir(writers_dir) + + for writer in writers: + writer_dir = os.path.join(writers_dir, writer) + rel_writer_dir = os.path.join(rel_writers_dir, writer) + wtypes = os.listdir(writer_dir) + + for wtype in wtypes: + type_dir = os.path.join(writer_dir, wtype) + rel_type_dir = os.path.join(rel_writer_dir, wtype) + images = os.listdir(type_dir) + image_dirs = [os.path.join(rel_type_dir, i) for i in images] + + for image_dir in image_dirs: + write_files.append((writer, image_dir)) + + util.save_obj( + class_files, + os.path.join(base_folder, "intermediate", "class_file_dirs")) + util.save_obj( + write_files, + os.path.join(base_folder, "intermediate", "write_file_dirs")) diff --git a/easyfl/datasets/femnist/preprocess/get_hashes.py b/easyfl/datasets/femnist/preprocess/get_hashes.py new file mode 100644 index 0000000..cdb5b64 --- /dev/null +++ b/easyfl/datasets/femnist/preprocess/get_hashes.py @@ -0,0 +1,55 @@ +""" +These codes are adopted from LEAF with some modifications. +""" + +import hashlib +import logging +import os + +from easyfl.datasets.utils import util + +logger = logging.getLogger(__name__) + + +def get_hash(base_folder): + cfd = os.path.join(base_folder, "intermediate", "class_file_dirs") + wfd = os.path.join(base_folder, "intermediate", "write_file_dirs") + class_file_dirs = util.load_obj(cfd) + write_file_dirs = util.load_obj(wfd) + + class_file_hashes = [] + write_file_hashes = [] + + count = 0 + for tup in class_file_dirs: + if (count % 100000 == 0): + logger.info("hashed %d class images" % count) + + (cclass, cfile) = tup + file_path = os.path.join(base_folder, cfile) + + chash = hashlib.md5(open(file_path, "rb").read()).hexdigest() + + class_file_hashes.append((cclass, cfile, chash)) + + count += 1 + + cfhd = os.path.join(base_folder, "intermediate", "class_file_hashes") + util.save_obj(class_file_hashes, cfhd) + + count = 0 + for tup in write_file_dirs: + if (count % 100000 == 0): + logger.info("hashed %d write images" % count) + + (cclass, cfile) = tup + file_path = os.path.join(base_folder, cfile) + + chash = hashlib.md5(open(file_path, "rb").read()).hexdigest() + + write_file_hashes.append((cclass, cfile, chash)) + + count += 1 + + wfhd = os.path.join(base_folder, "intermediate", "write_file_hashes") + util.save_obj(write_file_hashes, wfhd) diff --git a/easyfl/datasets/femnist/preprocess/group_by_writer.py b/easyfl/datasets/femnist/preprocess/group_by_writer.py new file mode 100644 index 0000000..b58b3a3 --- /dev/null +++ b/easyfl/datasets/femnist/preprocess/group_by_writer.py @@ -0,0 +1,25 @@ +""" +These codes are adopted from LEAF with some modifications. +""" +import os + +from easyfl.datasets.utils import util + + +def group_by_writer(base_folder): + wwcd = os.path.join(base_folder, "intermediate", "write_with_class") + write_class = util.load_obj(wwcd) + + writers = [] # each entry is a (writer, [list of (file, class)]) tuple + cimages = [] + (cw, _, _) = write_class[0] + for (w, f, c) in write_class: + if w != cw: + writers.append((cw, cimages)) + cw = w + cimages = [(f, c)] + cimages.append((f, c)) + writers.append((cw, cimages)) + + ibwd = os.path.join(base_folder, "intermediate", "images_by_writer") + util.save_obj(writers, ibwd) diff --git a/easyfl/datasets/femnist/preprocess/match_hashes.py b/easyfl/datasets/femnist/preprocess/match_hashes.py new file mode 100644 index 0000000..1ad2501 --- /dev/null +++ b/easyfl/datasets/femnist/preprocess/match_hashes.py @@ -0,0 +1,25 @@ +""" +These codes are adopted from LEAF with some modifications. +""" +import os + +from easyfl.datasets.utils import util + + +def match_hash(base_folder): + cfhd = os.path.join(base_folder, "intermediate", "class_file_hashes") + wfhd = os.path.join(base_folder, "intermediate", "write_file_hashes") + class_file_hashes = util.load_obj(cfhd) + write_file_hashes = util.load_obj(wfhd) + class_hash_dict = {} + for i in range(len(class_file_hashes)): + (c, f, h) = class_file_hashes[len(class_file_hashes) - i - 1] + class_hash_dict[h] = (c, f) + + write_classes = [] + for tup in write_file_hashes: + (w, f, h) = tup + write_classes.append((w, f, class_hash_dict[h][0])) + + wwcd = os.path.join(base_folder, "intermediate", "write_with_class") + util.save_obj(write_classes, wwcd) diff --git a/easyfl/datasets/shakespeare/__init__.py b/easyfl/datasets/shakespeare/__init__.py new file mode 100644 index 0000000..122c995 --- /dev/null +++ b/easyfl/datasets/shakespeare/__init__.py @@ -0,0 +1 @@ +from easyfl.datasets.shakespeare.shakespeare import Shakespeare diff --git a/easyfl/datasets/shakespeare/shakespeare.py b/easyfl/datasets/shakespeare/shakespeare.py new file mode 100644 index 0000000..1b6e1e0 --- /dev/null +++ b/easyfl/datasets/shakespeare/shakespeare.py @@ -0,0 +1,89 @@ +import logging +import os + +from easyfl.datasets.shakespeare.utils.gen_all_data import generated_all_data +from easyfl.datasets.shakespeare.utils.preprocess_shakespeare import shakespeare_preprocess +from easyfl.datasets.utils.base_dataset import BaseDataset +from easyfl.datasets.utils.download import download_url, extract_archive, download_from_google_drive + +logger = logging.getLogger(__name__) + + +class Shakespeare(BaseDataset): + """Shakespeare dataset implementation. It gets Shakespeare dataset according to configurations. + + Attributes: + base_folder (str): The base folder path of the datasets folder. + raw_data_url (str): The url to get the `by_class` split shakespeare. + write_url (str): The url to get the `by_write` split shakespeare. + """ + + def __init__(self, + root, + fraction, + split_type, + user, + iid_user_fraction=0.1, + train_test_split=0.9, + minsample=10, + num_class=80, + num_of_client=100, + class_per_client=2, + setting_folder=None, + seed=-1, + **kwargs): + super(Shakespeare, self).__init__(root, + "shakespeare", + fraction, + split_type, + user, + iid_user_fraction, + train_test_split, + minsample, + num_class, + num_of_client, + class_per_client, + setting_folder, + seed) + self.raw_data_url = "http://www.gutenberg.org/files/100/old/1994-01-100.zip" + self.packaged_data_files = { + "shakespeare_niid_100_10_1_0.05_0.1_sample_0.9.zip": "https://dl.dropboxusercontent.com/s/5qr9ozziy3yfzss/shakespeare_niid_100_10_1_0.05_0.1_sample_0.9.zip", + "shakespeare_iid_100_10_1_0.05_0.1_sample_0.9.zip": "https://dl.dropboxusercontent.com/s/4p7osgjd2pecsi3/shakespeare_iid_100_10_1_0.05_0.1_sample_0.9.zip" + } + # Google drive ids. + # self.packaged_data_files = { + # "shakespeare_niid_100_10_1_0.05_0.1_sample_0.9.zip": "1zvmNiUNu7r0h4t0jBhOJ204qyc61NvfJ", + # "shakespeare_iid_100_10_1_0.05_0.1_sample_0.9.zip": "1Lb8n1zDtrj2DX_QkjNnL6DH5IrnYFdsR" + # } + + def download_packaged_dataset_and_extract(self, filename): + file_path = download_url(self.packaged_data_files[filename], self.base_folder) + extract_archive(file_path, remove_finished=True) + + def download_raw_file_and_extract(self): + raw_data_folder = os.path.join(self.base_folder, "raw_data") + if not os.path.exists(raw_data_folder): + os.makedirs(raw_data_folder) + elif os.listdir(raw_data_folder): + logger.info("raw file exists") + return + raw_data_path = download_url(self.raw_data_url, raw_data_folder) + extract_archive(raw_data_path, remove_finished=True) + os.rename(os.path.join(raw_data_folder, "100.txt"), os.path.join(raw_data_folder, "raw_data.txt")) + logger.info("raw file is downloaded") + + def preprocess(self): + filename = os.path.join(self.base_folder, "raw_data", "raw_data.txt") + raw_data_folder = os.path.join(self.base_folder, "raw_data") + if not os.path.exists(raw_data_folder): + os.makedirs(raw_data_folder) + shakespeare_preprocess(filename, raw_data_folder) + + def convert_data_to_json(self): + all_data_folder = os.path.join(self.base_folder, "all_data") + if not os.path.exists(all_data_folder): + os.makedirs(all_data_folder) + if not os.listdir(all_data_folder): + logger.info("converting data to .json format") + generated_all_data(self.base_folder) + logger.info("finished converting data to .json format") diff --git a/easyfl/datasets/shakespeare/utils/__init__.py b/easyfl/datasets/shakespeare/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/datasets/shakespeare/utils/gen_all_data.py b/easyfl/datasets/shakespeare/utils/gen_all_data.py new file mode 100644 index 0000000..842f41f --- /dev/null +++ b/easyfl/datasets/shakespeare/utils/gen_all_data.py @@ -0,0 +1,17 @@ +""" +These codes are adopted from LEAF with some modifications. +""" + +import json +import os + +from easyfl.datasets.shakespeare.utils.shake_utils import parse_data_in + + +def generated_all_data(parent_path): + users_and_plays_path = os.path.join(parent_path, 'raw_data', 'users_and_plays.json') + txt_dir = os.path.join(parent_path, 'raw_data', 'by_play_and_character') + json_data = parse_data_in(txt_dir, users_and_plays_path) + json_path = os.path.join(parent_path, 'all_data', 'all_data.json') + with open(json_path, 'w') as outfile: + json.dump(json_data, outfile) diff --git a/easyfl/datasets/shakespeare/utils/preprocess_shakespeare.py b/easyfl/datasets/shakespeare/utils/preprocess_shakespeare.py new file mode 100644 index 0000000..e100615 --- /dev/null +++ b/easyfl/datasets/shakespeare/utils/preprocess_shakespeare.py @@ -0,0 +1,183 @@ +"""Preprocesses the Shakespeare dataset for federated training. +These codes are adopted from LEAF with some modifications. +""" + +import collections +import json +import os +import re + +RANDOM_SEED = 1234 +# Regular expression to capture an actors name, and line continuation +CHARACTER_RE = re.compile(r'^ ([a-zA-Z][a-zA-Z ]*)\. (.*)') +CONT_RE = re.compile(r'^ (.*)') +# The Comedy of Errors has errors in its indentation so we need to use +# different regular expressions. +COE_CHARACTER_RE = re.compile(r'^([a-zA-Z][a-zA-Z ]*)\. (.*)') +COE_CONT_RE = re.compile(r'^(.*)') + + +def _match_character_regex(line, comedy_of_errors=False): + return (COE_CHARACTER_RE.match(line) if comedy_of_errors + else CHARACTER_RE.match(line)) + + +def _match_continuation_regex(line, comedy_of_errors=False): + return ( + COE_CONT_RE.match(line) if comedy_of_errors else CONT_RE.match(line)) + + +def _split_into_plays(shakespeare_full): + """Splits the full data by play.""" + # List of tuples (play_name, dict from character to list of lines) + plays = [] + discarded_lines = [] # Track discarded lines. + slines = shakespeare_full.splitlines(True)[1:] + + # skip contents, the sonnets, and all's well that ends well + author_count = 0 + start_i = 0 + for i, l in enumerate(slines): + if 'by William Shakespeare' in l: + author_count += 1 + if author_count == 2: + start_i = i - 5 + break + slines = slines[start_i:] + + current_character = None + comedy_of_errors = False + for i, line in enumerate(slines): + # This marks the end of the plays in the file. + if i > 124195 - start_i: + break + # This is a pretty good heuristic for detecting the start of a new play: + if 'by William Shakespeare' in line: + current_character = None + characters = collections.defaultdict(list) + # The title will be 2, 3, 4, 5, 6, or 7 lines above "by William Shakespeare". + if slines[i - 2].strip(): + title = slines[i - 2] + elif slines[i - 3].strip(): + title = slines[i - 3] + elif slines[i - 4].strip(): + title = slines[i - 4] + elif slines[i - 5].strip(): + title = slines[i - 5] + elif slines[i - 6].strip(): + title = slines[i - 6] + else: + title = slines[i - 7] + title = title.strip() + + assert title, ('Parsing error on line %d. Expecting title 2 or 3 lines above.' % i) + comedy_of_errors = (title == 'THE COMEDY OF ERRORS') + # Degenerate plays are removed at the end of the method. + plays.append((title, characters)) + continue + match = _match_character_regex(line, comedy_of_errors) + if match: + character, snippet = match.group(1), match.group(2) + # Some character names are written with multiple casings, e.g., SIR_Toby + # and SIR_TOBY. To normalize the character names, we uppercase each name. + # Note that this was not done in the original preprocessing and is a + # recent fix. + character = character.upper() + if not (comedy_of_errors and character.startswith('ACT ')): + characters[character].append(snippet) + current_character = character + continue + else: + current_character = None + continue + elif current_character: + match = _match_continuation_regex(line, comedy_of_errors) + if match: + if comedy_of_errors and match.group(1).startswith('<'): + current_character = None + continue + else: + characters[current_character].append(match.group(1)) + continue + # Didn't consume the line. + line = line.strip() + if line and i > 2646: + # Before 2646 are the sonnets, which we expect to discard. + discarded_lines.append('%d:%s' % (i, line)) + # Remove degenerate "plays". + return [play for play in plays if len(play[1]) > 1], discarded_lines + + +def _remove_nonalphanumerics(filename): + return re.sub('\\W+', '_', filename) + + +def play_and_character(play, character): + return _remove_nonalphanumerics((play + '_' + character).replace(' ', '_')) + + +def _get_train_test_by_character(plays, test_fraction=0.2): + """ + Splits character data into train and test sets. + if test_fraction <= 0, returns {} for all_test_examples + plays := list of (play, dict) tuples where play is a string and dict + is a dictionary with character names as keys + """ + skipped_characters = 0 + all_train_examples = collections.defaultdict(list) + all_test_examples = collections.defaultdict(list) + + def add_examples(example_dict, example_tuple_list): + for play, character, sound_bite in example_tuple_list: + example_dict[play_and_character( + play, character)].append(sound_bite) + + users_and_plays = {} + for play, characters in plays: + curr_characters = list(characters.keys()) + for c in curr_characters: + users_and_plays[play_and_character(play, c)] = play + for character, sound_bites in characters.items(): + examples = [(play, character, sound_bite) + for sound_bite in sound_bites] + if len(examples) <= 2: + skipped_characters += 1 + # Skip characters with fewer than 2 lines since we need at least one + # train and one test line. + continue + train_examples = examples + if test_fraction > 0: + num_test = max(int(len(examples) * test_fraction), 1) + train_examples = examples[:-num_test] + test_examples = examples[-num_test:] + assert len(test_examples) == num_test + assert len(train_examples) >= len(test_examples) + add_examples(all_test_examples, test_examples) + add_examples(all_train_examples, train_examples) + return users_and_plays, all_train_examples, all_test_examples + + +def _write_data_by_character(examples, output_directory): + """Writes a collection of data files by play & character.""" + if not os.path.exists(output_directory): + os.makedirs(output_directory) + for character_name, sound_bites in examples.items(): + filename = os.path.join(output_directory, character_name + '.txt') + with open(filename, 'w') as output: + for sound_bite in sound_bites: + output.write(sound_bite + '\n') + + +def shakespeare_preprocess(input_filename, output_directory): + print('Splitting .txt data between users') + input_filename = input_filename + with open(input_filename, 'r') as input_file: + shakespeare_full = input_file.read() + plays, discarded_lines = _split_into_plays(shakespeare_full) + print('Discarded %d lines' % len(discarded_lines)) + users_and_plays, all_examples, _ = _get_train_test_by_character(plays, test_fraction=-1.0) + with open(os.path.join(output_directory, 'users_and_plays.json'), 'w') as ouf: + json.dump(users_and_plays, ouf) + _write_data_by_character(all_examples, + os.path.join(output_directory, + 'by_play_and_character/')) diff --git a/easyfl/datasets/shakespeare/utils/shake_utils.py b/easyfl/datasets/shakespeare/utils/shake_utils.py new file mode 100644 index 0000000..59b3004 --- /dev/null +++ b/easyfl/datasets/shakespeare/utils/shake_utils.py @@ -0,0 +1,69 @@ +""" +Helper functions for preprocessing shakespeare data. + +These codes are adopted from LEAF with some modifications. +""" +import json +import os +import re + + +def __txt_to_data(txt_dir, seq_length=80): + """Parses text file in given directory into data for next-character model. + + Args: + txt_dir: path to text file + seq_length: length of strings in X + """ + raw_text = "" + with open(txt_dir, 'r') as inf: + raw_text = inf.read() + raw_text = raw_text.replace('\n', ' ') + raw_text = re.sub(r" *", r' ', raw_text) + dataX = [] + dataY = [] + for i in range(0, len(raw_text) - seq_length, 1): + seq_in = raw_text[i:i + seq_length] + seq_out = raw_text[i + seq_length] + dataX.append(seq_in) + dataY.append(seq_out) + return dataX, dataY + + +def parse_data_in(data_dir, users_and_plays_path, raw=False): + """ + returns dictionary with keys: users, num_samples, user_data + raw := bool representing whether to include raw text in all_data + if raw is True, then user_data key + removes users with no data + """ + with open(users_and_plays_path, 'r') as inf: + users_and_plays = json.load(inf) + files = os.listdir(data_dir) + users = [] + hierarchies = [] + num_samples = [] + user_data = {} + for f in files: + user = f[:-4] + passage = '' + filename = os.path.join(data_dir, f) + with open(filename, 'r') as inf: + passage = inf.read() + dataX, dataY = __txt_to_data(filename) + if (len(dataX) > 0): + users.append(user) + if raw: + user_data[user] = {'raw': passage} + else: + user_data[user] = {} + user_data[user]['x'] = dataX + user_data[user]['y'] = dataY + hierarchies.append(users_and_plays[user]) + num_samples.append(len(dataY)) + all_data = {} + all_data['users'] = users + all_data['hierarchies'] = hierarchies + all_data['num_samples'] = num_samples + all_data['user_data'] = user_data + return all_data diff --git a/easyfl/datasets/simulation.py b/easyfl/datasets/simulation.py new file mode 100644 index 0000000..0f69e07 --- /dev/null +++ b/easyfl/datasets/simulation.py @@ -0,0 +1,350 @@ +import heapq +import logging +import math + +import numpy as np + +SIMULATE_IID = "iid" +SIMULATE_NIID_DIR = "dir" +SIMULATE_NIID_CLASS = "class" + +logger = logging.getLogger(__name__) + + +def shuffle(data_x, data_y): + num_of_data = len(data_y) + data_x = np.array(data_x) + data_y = np.array(data_y) + index = [i for i in range(num_of_data)] + np.random.shuffle(index) + data_x = data_x[index] + data_y = data_y[index] + return data_x, data_y + + +def equal_division(num_groups, data_x, data_y=None): + """Partition data into multiple clients with equal quantity. + + Args: + num_groups (int): THe number of groups to partition to. + data_x (list[Object]): A list of elements to be divided. + data_y (list[Object], optional): A list of data labels to be divided together with the data. + + Returns: + list[list]: A list where each element is a list of data of a group/client. + list[list]: A list where each element is a list of data label of a group/client. + + Example: + >>> equal_division(3, list[range(9)]) + >>> ([[0,4,2],[3,1,7],[6,5,8]], []) + """ + if data_y is not None: + assert (len(data_x) == len(data_y)) + data_x, data_y = shuffle(data_x, data_y) + else: + np.random.shuffle(data_x) + num_of_data = len(data_x) + assert num_of_data > 0 + data_per_client = num_of_data // num_groups + large_group_num = num_of_data - num_groups * data_per_client + small_group_num = num_groups - large_group_num + splitted_data_x = [] + splitted_data_y = [] + for i in range(small_group_num): + base_index = data_per_client * i + splitted_data_x.append(data_x[base_index: base_index + data_per_client]) + if data_y is not None: + splitted_data_y.append(data_y[base_index: base_index + data_per_client]) + small_size = data_per_client * small_group_num + data_per_client += 1 + for i in range(large_group_num): + base_index = small_size + data_per_client * i + splitted_data_x.append(data_x[base_index: base_index + data_per_client]) + if data_y is not None: + splitted_data_y.append(data_y[base_index: base_index + data_per_client]) + + return splitted_data_x, splitted_data_y + + +def quantity_hetero(weights, data_x, data_y=None): + """Partition data into multiple clients with different quantities. + The number of groups is the same as the number of elements of `weights`. + The quantity of each group depends on the values of `weights`. + + Args: + weights (list[float]): The targeted distribution of data quantities. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + data_x (list[Object]): A list of elements to be divided. + data_y (list[Object], optional): A list of data labels to be divided together with the data. + + Returns: + list[list]: A list where each element is a list of data of a group/client. + list[list]: A list where each element is a list of data label of a group/client. + + Example: + >>> quantity_hetero([0.1, 0.2, 0.7], list(range(0, 10))) + >>> ([[4], [8, 9], [6, 0, 1, 7, 3, 2, 5]], []) + """ + # This is due to the float number in python, + # e.g.sum([0.1,0.2,0.4,0.2,0.1]) is not exactly 1, but 1.0000000000000002. + assert (round(sum(weights), 3) == 1) + + if data_y is not None: + assert (len(data_x) == len(data_y)) + data_x, data_y = shuffle(data_x, data_y) + else: + np.random.shuffle(data_x) + data_size = len(data_x) + + i = 0 + + splitted_data_x = [] + splitted_data_y = [] + for w in weights: + size = math.floor(data_size * w) + splitted_data_x.append(data_x[i:i + size]) + if data_y is not None: + splitted_data_y.append(data_y[i:i + size]) + i += size + + parts = len(weights) + if i < data_size: + remain = data_size - i + for i in range(-remain, 0, 1): + splitted_data_x[(-i) % parts].append(data_x[i]) + if data_y is not None: + splitted_data_y[(-i) % parts].append(data_y[i]) + + return splitted_data_x, splitted_data_y + + +def iid(data_x, data_y, num_of_clients, x_dtype, y_dtype): + """Partition dataset into multiple clients with equal data quantity (difference is less than 1) randomly. + + Args: + data_x (list[Object]): A list of data. + data_y (list[Object]): A list of dataset labels. + num_of_clients (int): The number of clients to partition to. + x_dtype (numpy.dtype): The type of data. + y_dtype (numpy.dtype): The type of data label. + + Returns: + list[str]: A list of client ids. + dict: The partitioned data, key is client id, value is the client data. e.g., {'client_1': {'x': [data_x], 'y': [data_y]}}. + """ + data_x, data_y = shuffle(data_x, data_y) + x_divided_list, y_divided_list = equal_division(num_of_clients, data_x, data_y) + clients = [] + federated_data = {} + for i in range(num_of_clients): + client_id = "f%07.0f" % (i) + temp_client = {} + temp_client['x'] = np.array(x_divided_list[i]).astype(x_dtype) + temp_client['y'] = np.array(y_divided_list[i]).astype(y_dtype) + federated_data[client_id] = temp_client + clients.append(client_id) + return clients, federated_data + + +def non_iid_dirichlet(data_x, data_y, num_of_clients, alpha, min_size, x_dtype, y_dtype): + """Partition dataset into multiple clients following the Dirichlet process. + + Args: + data_x (list[Object]): A list of data. + data_y (list[Object]): A list of dataset labels. + num_of_clients (int): The number of clients to partition to. + alpha (float): The parameter for Dirichlet process simulation. + min_size (int): The minimum number of data size of a client. + x_dtype (numpy.dtype): The type of data. + y_dtype (numpy.dtype): The type of data label. + + Returns: + list[str]: A list of client ids. + dict: The partitioned data, key is client id, value is the client data. e.g., {'client_1': {'x': [data_x], 'y': [data_y]}}. + """ + n_train = data_x.shape[0] + current_min_size = 0 + num_class = np.amax(data_y) + 1 + data_size = data_y.shape[0] + net_dataidx_map = {} + + while current_min_size < min_size: + idx_batch = [[] for _ in range(num_of_clients)] + for k in range(num_class): + idx_k = np.where(data_y == k)[0] + np.random.shuffle(idx_k) + proportions = np.random.dirichlet(np.repeat(alpha, num_of_clients)) + # using the proportions from dirichlet, only selet those clients having data amount less than average + proportions = np.array( + [p * (len(idx_j) < data_size / num_of_clients) for p, idx_j in zip(proportions, idx_batch)]) + # scale proportions + proportions = proportions / proportions.sum() + proportions = (np.cumsum(proportions) * len(idx_k)).astype(int)[:-1] + idx_batch = [idx_j + idx.tolist() for idx_j, idx in zip(idx_batch, np.split(idx_k, proportions))] + current_min_size = min([len(idx_j) for idx_j in idx_batch]) + + federated_data = {} + clients = [] + for j in range(num_of_clients): + np.random.shuffle(idx_batch[j]) + client_id = "f%07.0f" % j + clients.append(client_id) + temp = {} + temp['x'] = np.array(data_x[idx_batch[j]]).astype(x_dtype) + temp['y'] = np.array(data_y[idx_batch[j]]).astype(y_dtype) + federated_data[client_id] = temp + net_dataidx_map[client_id] = idx_batch[j] + print_data_distribution(data_y, net_dataidx_map) + return clients, federated_data + + +def non_iid_class(data_x, data_y, class_per_client, num_of_clients, x_dtype, y_dtype, stack_x=True): + """Partition dataset into multiple clients based on label classes. + Each client contains [1, n] classes, where n is the number of classes of a dataset. + + Note: Each class is divided into `ceil(class_per_client * num_of_clients / num_class)` parts + and each client chooses `class_per_client` parts from each class to construct its dataset. + + Args: + data_x (list[Object]): A list of data. + data_y (list[Object]): A list of dataset labels. + class_per_client (int): The number of classes in each client. + num_of_clients (int): The number of clients to partition to. + x_dtype (numpy.dtype): The type of data. + y_dtype (numpy.dtype): The type of data label. + stack_x (bool, optional): A flag to indicate whether using np.vstack or append to construct dataset. + + Returns: + list[str]: A list of client ids. + dict: The partitioned data, key is client id, value is the client data. e.g., {'client_1': {'x': [data_x], 'y': [data_y]}}. + """ + num_class = np.amax(data_y) + 1 + all_index = [] + clients = [] + data_index_map = {} + for i in range(num_class): + # get indexes for all data with current label i at index i in all_index + all_index.append(np.where(data_y == i)[0].tolist()) + + federated_data = {} + + # total no. of parts + total_amount = class_per_client * num_of_clients + # no. of parts each class should be diveded into + parts_per_class = math.ceil(total_amount / num_class) + + for i in range(num_of_clients): + client_id = "f%07.0f" % (i) + clients.append(client_id) + data_index_map[client_id] = [] + data = {} + data['x'] = np.array([]) + data['y'] = np.array([]) + federated_data[client_id] = data + + class_map = {} + parts_consumed = [] + for i in range(num_class): + class_map[i], _ = equal_division(parts_per_class, all_index[i]) + heapq.heappush(parts_consumed, (0, i)) + for i in clients: + for j in range(class_per_client): + class_chosen = heapq.heappop(parts_consumed) + part_indexes = class_map[class_chosen[1]].pop(0) + if len(federated_data[i]['x']) != 0: + if stack_x: + federated_data[i]['x'] = np.vstack((federated_data[i]['x'], data_x[part_indexes])).astype(x_dtype) + else: + federated_data[i]['x'] = np.append(federated_data[i]['x'], data_x[part_indexes]).astype(x_dtype) + federated_data[i]['y'] = np.append(federated_data[i]['y'], data_y[part_indexes]).astype(y_dtype) + else: + federated_data[i]['x'] = data_x[part_indexes].astype(x_dtype) + federated_data[i]['y'] = data_y[part_indexes].astype(y_dtype) + heapq.heappush(parts_consumed, (class_chosen[0] + 1, class_chosen[1])) + data_index_map[i].extend(part_indexes) + print_data_distribution(data_y, data_index_map) + return clients, federated_data + + +def data_simulation(data_x, data_y, num_of_clients, data_distribution, weights=None, alpha=0.5, min_size=10, + class_per_client=1, stack_x=True): + """Simulate federated learning datasets by partitioning a data into multiple clients using different strategies. + + Args: + data_x (list[Object]): A list of data. + data_y (list[Object]): A list of dataset labels. + num_of_clients (int): The number of clients to partition to. + data_distribution (str): The ways to partition the dataset, options: + `iid`: Partition dataset into multiple clients with equal quantity (difference is less than 1) randomly. + `dir`: partition dataset into multiple clients following the Dirichlet process. + `class`: partition dataset into multiple clients based on classes. + weights: list, for simulating data quantity heterogeneity + If None, each client are simulated with same data quantity + Note: num_of_clients should be divisible by len(weights) + weights (list[float], optional): The targeted distribution of data quantities. + The values should sum up to 1. e.g., [0.1, 0.2, 0.7]. + When `weights=None`, the data quantity of clients only depends on data_distribution. + alpha (float, optional): The parameter for Dirichlet process simulation. + It is only applicable when data_distribution is `dir`. + min_size (int, optional): The minimum number of data size of a client. + It is only applicable when data_distribution is `dir`. + class_per_client (int): The number of classes in each client. + It is only applicable when data_distribution is `class`. + stack_x (bool, optional): A flag to indicate whether using np.vstack or append to construct dataset. + It is only applicable when data_distribution is `class`. + + Raise: + ValueError: When the simulation method `data_distribution` is not supported. + + Returns: + list[str]: A list of client ids. + dict: The partitioned data, key is client id, value is the client data. e.g., {'client_1': {'x': [data_x], 'y': [data_y]}}. + """ + data_x = np.array(data_x) + data_y = np.array(data_y) + x_dtype = data_x.dtype + y_dtype = data_y.dtype + if weights is not None: + assert num_of_clients % len(weights) == 0 + num_of_clients = num_of_clients // len(weights) + + if data_distribution == SIMULATE_IID: + group_client_list, group_federated_data = iid(data_x, data_y, num_of_clients, x_dtype, y_dtype) + elif data_distribution == SIMULATE_NIID_DIR: + group_client_list, group_federated_data = non_iid_dirichlet(data_x, data_y, num_of_clients, alpha, min_size, + x_dtype, y_dtype) + elif data_distribution == SIMULATE_NIID_CLASS: + group_client_list, group_federated_data = non_iid_class(data_x, data_y, class_per_client, num_of_clients, + x_dtype, + y_dtype, stack_x=stack_x) + else: + raise ValueError("Simulation type not supported") + if weights is None: + return group_client_list, group_federated_data + + clients = [] + federated_data = {} + cur_key = 0 + for i in group_client_list: + current_client = group_federated_data[i] + input_lists, label_lists = quantity_hetero(weights, current_client['x'], current_client['y']) + for j in range(len(input_lists)): + client_id = "f%07.0f" % (cur_key) + temp_client = {} + temp_client['x'] = np.array(input_lists[j]).astype(x_dtype) + temp_client['y'] = np.array(label_lists[j]).astype(y_dtype) + federated_data[client_id] = temp_client + clients.append(client_id) + cur_key += 1 + return clients, federated_data + + +def print_data_distribution(data_y, data_index_map): + """Log the distribution of client datasets.""" + data_distribution = {} + for index, dataidx in data_index_map.items(): + unique_values, counts = np.unique(data_y[dataidx], return_counts=True) + distribution = {unique_values[i]: counts[i] for i in range(len(unique_values))} + data_distribution[index] = distribution + logger.info(data_distribution) + return data_distribution diff --git a/easyfl/datasets/utils/__init__.py b/easyfl/datasets/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/datasets/utils/base_dataset.py b/easyfl/datasets/utils/base_dataset.py new file mode 100644 index 0000000..b105f12 --- /dev/null +++ b/easyfl/datasets/utils/base_dataset.py @@ -0,0 +1,158 @@ +import logging +import os +from abc import abstractmethod + +from easyfl.datasets.utils.remove_users import remove +from easyfl.datasets.utils.sample import sample, extreme +from easyfl.datasets.utils.split_data import split_train_test + +logger = logging.getLogger(__name__) + +CIFAR10 = "cifar10" +CIFAR100 = "cifar100" + + +class BaseDataset(object): + """The internal base dataset implementation. + + Args: + root (str): The root directory where datasets stored. + dataset_name (str): The name of the dataset. + fraction (float): The fraction of the data chosen from the raw data to use. + num_of_clients (int): The targeted number of clients to construct. + split_type (str): The type of statistical simulation, options: iid, dir, and class. + `iid` means independent and identically distributed data. + `niid` means non-independent and identically distributed data for Femnist and Shakespeare. + `dir` means using Dirichlet process to simulate non-iid data, for CIFAR-10 and CIFAR-100 datasets. + `class` means partitioning the dataset by label classes, for datasets like CIFAR-10, CIFAR-100. + minsample (int): The minimal number of samples in each client. + It is applicable for LEAF datasets and dir simulation of CIFAR-10 and CIFAR-100. + class_per_client (int): The number of classes in each client. Only applicable when the split_type is 'class'. + iid_user_fraction (float): The fraction of the number of clients used when the split_type is 'iid'. + user (bool): A flag to indicate whether partition users of the dataset into train-test groups. + Only applicable to LEAF datasets. + True means partitioning users of the dataset into train-test groups. + False means partitioning each users' samples into train-test groups. + train_test_split (float): The fraction of data for training; the rest are for testing. + e.g., 0.9 means 90% of data are used for training and 10% are used for testing. + num_class: The number of classes in this dataset. + seed: Random seed. + """ + + def __init__(self, + root, + dataset_name, + fraction, + split_type, + user, + iid_user_fraction, + train_test_split, + minsample, + num_class, + num_of_client, + class_per_client, + setting_folder, + seed=-1, + **kwargs): + # file_path = os.path.dirname(os.path.realpath(__file__)) + # self.base_folder = os.path.join(os.path.dirname(file_path), "data", dataset_name) + self.base_folder = root + self.dataset_name = dataset_name + self.fraction = fraction + self.split_type = split_type # iid, niid, class + self.user = user + self.iid_user_fraction = iid_user_fraction + self.train_test_split = train_test_split + self.minsample = minsample + self.num_class = num_class + self.num_of_client = num_of_client + self.class_per_client = class_per_client + self.seed = seed + if split_type == "iid": + assert self.user == False + self.iid = True + elif split_type == "niid": + # if niid, user can be either True or False + self.iid = False + + self.setting_folder = setting_folder + self.data_folder = os.path.join(self.base_folder, self.setting_folder) + + @abstractmethod + def download_packaged_dataset_and_extract(self, filename): + raise NotImplementedError("download_packaged_dataset_and_extract not implemented") + + @abstractmethod + def download_raw_file_and_extract(self): + raise NotImplementedError("download_raw_file_and_extract not implemented") + + @abstractmethod + def preprocess(self): + raise NotImplementedError("preprocess not implemented") + + @abstractmethod + def convert_data_to_json(self): + raise NotImplementedError("convert_data_to_json not implemented") + + @staticmethod + def get_setting_folder(dataset, split_type, num_of_client, min_size, class_per_client, + fraction, iid_fraction, user_str, train_test_split, alpha=None, weights=None): + if dataset == CIFAR10 or dataset == CIFAR100: + return "{}_{}_{}_{}_{}_{}_{}".format(dataset, split_type, num_of_client, min_size, class_per_client, alpha, + 1 if weights else 0) + else: + return "{}_{}_{}_{}_{}_{}_{}_{}_{}".format(dataset, split_type, num_of_client, min_size, class_per_client, + fraction, iid_fraction, user_str, train_test_split) + + def setup(self): + self.download_raw_file_and_extract() + self.preprocess() + self.convert_data_to_json() + + def sample_customized(self): + meta_folder = os.path.join(self.base_folder, "meta") + if not os.path.exists(meta_folder): + os.makedirs(meta_folder) + sample_folder = os.path.join(self.data_folder, "sampled_data") + if not os.path.exists(sample_folder): + os.makedirs(sample_folder) + if not os.listdir(sample_folder): + sample(self.base_folder, self.data_folder, meta_folder, self.fraction, self.iid, self.iid_user_fraction, self.seed) + + def sample_extreme(self): + meta_folder = os.path.join(self.base_folder, "meta") + if not os.path.exists(meta_folder): + os.makedirs(meta_folder) + sample_folder = os.path.join(self.data_folder, "sampled_data") + if not os.path.exists(sample_folder): + os.makedirs(sample_folder) + if not os.listdir(sample_folder): + extreme(self.base_folder, self.data_folder, meta_folder, self.fraction, self.num_class, self.num_of_client, self.class_per_client, self.seed) + + def remove_unqualified_user(self): + rm_folder = os.path.join(self.data_folder, "rem_user_data") + if not os.path.exists(rm_folder): + os.makedirs(rm_folder) + if not os.listdir(rm_folder): + remove(self.data_folder, self.dataset_name, self.minsample) + + def split_train_test_set(self): + meta_folder = os.path.join(self.base_folder, "meta") + train = os.path.join(self.data_folder, "train") + if not os.path.exists(train): + os.makedirs(train) + test = os.path.join(self.data_folder, "test") + if not os.path.exists(test): + os.makedirs(test) + if not os.listdir(train) and not os.listdir(test): + split_train_test(self.data_folder, meta_folder, self.dataset_name, self.user, self.train_test_split, self.seed) + + def sampling(self): + if self.split_type == "iid": + self.sample_customized() + elif self.split_type == "niid": + self.sample_customized() + elif self.split_type == "class": + self.sample_extreme() + self.remove_unqualified_user() + self.split_train_test_set() diff --git a/easyfl/datasets/utils/constants.py b/easyfl/datasets/utils/constants.py new file mode 100644 index 0000000..baef471 --- /dev/null +++ b/easyfl/datasets/utils/constants.py @@ -0,0 +1,2 @@ +DATASETS = ['sent140', 'femnist', 'shakespeare', 'celeba', 'synthetic'] +SEED_FILES = {'sampling': 'sampling_seed.txt', 'split': 'split_seed.txt'} diff --git a/easyfl/datasets/utils/download.py b/easyfl/datasets/utils/download.py new file mode 100644 index 0000000..7dc11ae --- /dev/null +++ b/easyfl/datasets/utils/download.py @@ -0,0 +1,176 @@ +""" +These codes are adopted from torchvison with some modifications. +""" +import gzip +import hashlib +import logging +import os +import tarfile +import zipfile + +import requests +from tqdm import tqdm + +logger = logging.getLogger(__name__) + + +def gen_bar_updater(): + pbar = tqdm(total=None) + + def bar_update(count, block_size, total_size): + if pbar.total is None and total_size: + pbar.total = total_size + progress_bytes = count * block_size + pbar.update(progress_bytes - pbar.n) + + return bar_update + + +def calculate_md5(fpath, chunk_size=1024 * 1024): + md5 = hashlib.md5() + with open(fpath, 'rb') as f: + for chunk in iter(lambda: f.read(chunk_size), b''): + md5.update(chunk) + return md5.hexdigest() + + +def check_md5(fpath, md5, **kwargs): + return md5 == calculate_md5(fpath, **kwargs) + + +def check_integrity(fpath, md5=None): + if not os.path.isfile(fpath): + return False + if md5 is None: + return True + return check_md5(fpath, md5) + + +def download_url(url, root, filename=None, md5=None): + """Download a file from a url and place it in root. + Args: + url (str): URL to download file from + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. If None, use the basename of the URL + """ + import urllib.request + import urllib.error + + root = os.path.expanduser(root) + if not filename: + filename = os.path.basename(url) + fpath = os.path.join(root, filename) + + os.makedirs(root, exist_ok=True) + + # check if file is already present locally + if check_integrity(fpath, md5): + logger.info("Using downloaded and verified file: " + fpath) + return fpath + else: # download the file + try: + logger.info("Downloading {} to {}".format(url, fpath)) + urllib.request.urlretrieve( + url, fpath, + reporthook=gen_bar_updater() + ) + except (urllib.error.URLError, IOError) as e: + if url[:5] != 'https': + raise e + url = url.replace('https:', 'http:') + logger.info("Failed download. Trying https -> http instead." + "Downloading {} to {}".format(url, fpath)) + urllib.request.urlretrieve( + url, fpath, + reporthook=gen_bar_updater() + ) + + # check integrity of downloaded file + if not check_integrity(fpath, md5): + raise RuntimeError("File not found or corrupted.") + return fpath + + +def download_from_google_drive(id, destination): + # taken from this StackOverflow answer: https://stackoverflow.com/a/39225039 + URL = "https://docs.google.com/uc?export=download" + + session = requests.Session() + + response = session.get(URL, params={'id': id}, stream=True) + token = get_confirm_token(response) + + if token: + params = {'id': id, 'confirm': token} + response = session.get(URL, params=params, stream=True) + else: + raise FileNotFoundError("Google drive file id does not exist") + save_response_content(response, destination) + + +def get_confirm_token(response): + for key, value in response.cookies.items(): + if key.startswith('download_warning'): + return value + + return None + + +def save_response_content(response, destination): + CHUNK_SIZE = 32768 + + with open(destination, "wb") as f: + for chunk in response.iter_content(CHUNK_SIZE): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + + +def _is_tarxz(filename): + return filename.endswith(".tar.xz") + + +def _is_tar(filename): + return filename.endswith(".tar") + + +def _is_targz(filename): + return filename.endswith(".tar.gz") + + +def _is_tgz(filename): + return filename.endswith(".tgz") + + +def _is_gzip(filename): + return filename.endswith(".gz") and not filename.endswith(".tar.gz") + + +def _is_zip(filename): + return filename.endswith(".zip") + + +def extract_archive(from_path, to_path=None, remove_finished=False): + if to_path is None: + to_path = os.path.dirname(from_path) + + if _is_tar(from_path): + with tarfile.open(from_path, 'r') as tar: + tar.extractall(path=to_path) + elif _is_targz(from_path) or _is_tgz(from_path): + with tarfile.open(from_path, 'r:gz') as tar: + tar.extractall(path=to_path) + elif _is_tarxz(from_path): + with tarfile.open(from_path, 'r:xz') as tar: + tar.extractall(path=to_path) + elif _is_gzip(from_path): + to_path = os.path.join(to_path, os.path.splitext(os.path.basename(from_path))[0]) + with open(to_path, "wb") as out_f, gzip.GzipFile(from_path) as zip_f: + out_f.write(zip_f.read()) + elif _is_zip(from_path): + with zipfile.ZipFile(from_path, 'r') as z: + z.extractall(to_path) + else: + raise ValueError("file format not supported") + + if remove_finished: + os.remove(from_path) diff --git a/easyfl/datasets/utils/remove_users.py b/easyfl/datasets/utils/remove_users.py new file mode 100644 index 0000000..1841ef2 --- /dev/null +++ b/easyfl/datasets/utils/remove_users.py @@ -0,0 +1,62 @@ +""" +Removes users with less than the given number of samples. + +These codes are adopted from LEAF with some modifications. +""" + +import json +import logging +import os + +logger = logging.getLogger(__name__) + + +def remove(setting_folder, dataset, min_samples): + parent_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + dir = os.path.join(parent_path, dataset, "data") + subdir = os.path.join(dir, setting_folder, "sampled_data") + files = [] + if os.path.exists(subdir): + files = os.listdir(subdir) + if len(files) == 0: + subdir = os.path.join(dir, "all_data") + files = os.listdir(subdir) + files = [f for f in files if f.endswith(".json")] + + for f in files: + users = [] + hierarchies = [] + num_samples = [] + user_data = {} + + file_dir = os.path.join(subdir, f) + with open(file_dir, "r") as inf: + data = json.load(inf) + + num_users = len(data["users"]) + for i in range(num_users): + curr_user = data["users"][i] + curr_hierarchy = None + if "hierarchies" in data: + curr_hierarchy = data["hierarchies"][i] + curr_num_samples = data["num_samples"][i] + if (curr_num_samples >= min_samples): + user_data[curr_user] = data["user_data"][curr_user] + users.append(curr_user) + if curr_hierarchy is not None: + hierarchies.append(curr_hierarchy) + num_samples.append(data["num_samples"][i]) + + all_data = {} + all_data["users"] = users + if len(hierarchies) == len(users): + all_data["hierarchies"] = hierarchies + all_data["num_samples"] = num_samples + all_data["user_data"] = user_data + + file_name = "{}_keep_{}.json".format((f[:-5]), min_samples) + ouf_dir = os.path.join(dir, setting_folder, "rem_user_data", file_name) + + logger.info("writing {}".format(file_name)) + with open(ouf_dir, "w") as outfile: + json.dump(all_data, outfile) diff --git a/easyfl/datasets/utils/sample.py b/easyfl/datasets/utils/sample.py new file mode 100644 index 0000000..48ffab8 --- /dev/null +++ b/easyfl/datasets/utils/sample.py @@ -0,0 +1,274 @@ +""" +These codes are adopted from LEAF with some modifications. + +Samples from all raw data; +by default samples in a non-iid manner; namely, randomly selects users from +raw data until their cumulative amount of data exceeds the given number of +datapoints to sample (specified by --fraction argument); +ordering of original data points is not preserved in sampled data +""" + +import json +import logging +import os +import random +import time +from collections import OrderedDict + +from easyfl.datasets.simulation import non_iid_class +from easyfl.datasets.utils.constants import SEED_FILES +from easyfl.datasets.utils.util import iid_divide + +logger = logging.getLogger(__name__) + + +def extreme(data_dir, data_folder, metafile, fraction, num_class=62, num_of_client=100, class_per_client=2, seed=-1): + """ + Note: for extreme split, there are two ways, one is divide each class into parts and then distribute to the clients; + The second way is to let clients to go through classes to get a part of the data; Current version is the latter one, we + can also provide the previous one (the one we adopt in CIFA10); If (num_of_client*class_per_client)%num_class, there is no + difference(assume each class is equal), otherwise, how to deal with some remain parts is a question to discuss. (currently, + the method will just give the remain part to the next client coming for collection, which may make the last clients have more + than class_per_client;) + """ + logger.info("------------------------------") + logger.info("sampling data") + + subdir = os.path.join(data_dir, 'all_data') + files = os.listdir(subdir) + files = [f for f in files if f.endswith('.json')] + + rng_seed = (seed if (seed is not None and seed >= 0) else int(time.time())) + logger.info("Using seed {}".format(rng_seed)) + rng = random.Random(rng_seed) + + logger.info(metafile) + if metafile is not None: + seed_fname = os.path.join(metafile, SEED_FILES['sampling']) + with open(seed_fname, 'w+') as f: + f.write("# sampling_seed used by sampling script - supply as " + "--smplseed to preprocess.sh or --seed to utils/sample.py\n") + f.write(str(rng_seed)) + logger.info("- random seed written out to {file}".format(file=seed_fname)) + else: + logger.info("- using random seed '{seed}' for sampling".format(seed=rng_seed)) + new_user_count = 0 # for iid case + all_users = [] + all_user_data = {} + for f in files: + file_dir = os.path.join(subdir, f) + with open(file_dir, 'r') as inf: + data = json.load(inf, object_pairs_hook=OrderedDict) + + num_users = len(data['users']) + + tot_num_samples = sum(data['num_samples']) + num_new_samples = int(fraction * tot_num_samples) + + raw_list = list(data['user_data'].values()) + raw_x = [elem['x'] for elem in raw_list] + raw_y = [elem['y'] for elem in raw_list] + x_list = [item for sublist in raw_x for item in sublist] # flatten raw_x + y_list = [item for sublist in raw_y for item in sublist] # flatten raw_y + num_new_users = num_users + + indices = [i for i in range(tot_num_samples)] + new_indices = rng.sample(indices, num_new_samples) + users = [str(i + new_user_count) for i in range(num_new_users)] + all_users.extend(users) + user_data = {} + for user in users: + user_data[user] = {'x': [], 'y': []} + all_x_samples = [x_list[i] for i in new_indices] + all_y_samples = [y_list[i] for i in new_indices] + x_groups = iid_divide(all_x_samples, num_new_users) + y_groups = iid_divide(all_y_samples, num_new_users) + for i in range(num_new_users): + user_data[users[i]]['x'] = x_groups[i] + user_data[users[i]]['y'] = y_groups[i] + all_user_data.update(user_data) + + num_samples = [len(user_data[u]['y']) for u in users] + new_user_count += num_new_users + + allx = [] + ally = [] + for i in all_users: + allx.extend(all_user_data[i]['x']) + ally.extend(all_user_data[i]['y']) + clients, all_user_data = non_iid_class(x_list, y_list, class_per_client, num_of_client) + + # ------------ + # create .json file + all_num_samples = [] + for i in clients: + all_num_samples.append(len(all_user_data[i]['y'])) + all_data = {} + all_data['users'] = clients + all_data['num_samples'] = all_num_samples + all_data['user_data'] = all_user_data + + slabel = '' + + arg_frac = str(fraction) + arg_frac = arg_frac[2:] + arg_label = arg_frac + file_name = '%s_%s_%s.json' % ("class", slabel, arg_label) + ouf_dir = os.path.join(data_folder, 'sampled_data', file_name) + + logger.info("writing {}".format(file_name)) + with open(ouf_dir, 'w') as outfile: + json.dump(all_data, outfile) + + +def sample(data_dir, data_folder, metafile, fraction, iid, iid_user_fraction=0.01, seed=-1): + logger.info("------------------------------") + logger.info("sampling data") + subdir = os.path.join(data_dir, 'all_data') + files = os.listdir(subdir) + files = [f for f in files if f.endswith('.json')] + + rng_seed = (seed if (seed is not None and seed >= 0) else int(time.time())) + logger.info("Using seed {}".format(rng_seed)) + rng = random.Random(rng_seed) + + logger.info(metafile) + if metafile is not None: + seed_fname = os.path.join(metafile, SEED_FILES['sampling']) + with open(seed_fname, 'w+') as f: + f.write("# sampling_seed used by sampling script - supply as " + "--smplseed to preprocess.sh or --seed to utils/sample.py\n") + f.write(str(rng_seed)) + logger.info("- random seed written out to {file}".format(file=seed_fname)) + else: + logger.info("- using random seed '{seed}' for sampling".format(seed=rng_seed)) + + new_user_count = 0 # for iid case + for f in files: + file_dir = os.path.join(subdir, f) + with open(file_dir, 'r') as inf: + # Load data into an OrderedDict, to prevent ordering changes + # and enable reproducibility + data = json.load(inf, object_pairs_hook=OrderedDict) + + num_users = len(data['users']) + + tot_num_samples = sum(data['num_samples']) + num_new_samples = int(fraction * tot_num_samples) + + hierarchies = None + + if iid: + # iid in femnist is to put all data together, and then split them according to + # iid_user_fraction * num_users numbers of clients evenly + raw_list = list(data['user_data'].values()) + raw_x = [elem['x'] for elem in raw_list] + raw_y = [elem['y'] for elem in raw_list] + x_list = [item for sublist in raw_x for item in sublist] # flatten raw_x + y_list = [item for sublist in raw_y for item in sublist] # flatten raw_y + + num_new_users = int(round(iid_user_fraction * num_users)) + if num_new_users == 0: + num_new_users += 1 + + indices = [i for i in range(tot_num_samples)] + new_indices = rng.sample(indices, num_new_samples) + users = ["f%07.0f" % (i + new_user_count) for i in range(num_new_users)] + + user_data = {} + for user in users: + user_data[user] = {'x': [], 'y': []} + all_x_samples = [x_list[i] for i in new_indices] + all_y_samples = [y_list[i] for i in new_indices] + x_groups = iid_divide(all_x_samples, num_new_users) + y_groups = iid_divide(all_y_samples, num_new_users) + for i in range(num_new_users): + user_data[users[i]]['x'] = x_groups[i] + user_data[users[i]]['y'] = y_groups[i] + + num_samples = [len(user_data[u]['y']) for u in users] + + new_user_count += num_new_users + + else: + # niid's fraction in femnist is to choose some clients, one by one, + # until the data size meets the fration * total data size + ctot_num_samples = 0 + + users = data['users'] + users_and_hiers = None + if 'hierarchies' in data: + users_and_hiers = list(zip(users, data['hierarchies'])) + rng.shuffle(users_and_hiers) + else: + rng.shuffle(users) + user_i = 0 + num_samples = [] + user_data = {} + + if 'hierarchies' in data: + hierarchies = [] + + while ctot_num_samples < num_new_samples: + hierarchy = None + if users_and_hiers is not None: + user, hier = users_and_hiers[user_i] + else: + user = users[user_i] + + cdata = data['user_data'][user] + + cnum_samples = len(data['user_data'][user]['y']) + + if ctot_num_samples + cnum_samples > num_new_samples: + cnum_samples = num_new_samples - ctot_num_samples + indices = [i for i in range(cnum_samples)] + new_indices = rng.sample(indices, cnum_samples) + x = [] + y = [] + for i in new_indices: + x.append(data['user_data'][user]['x'][i]) + y.append(data['user_data'][user]['y'][i]) + cdata = {'x': x, 'y': y} + + if 'hierarchies' in data: + hierarchies.append(hier) + + num_samples.append(cnum_samples) + user_data[user] = cdata + + ctot_num_samples += cnum_samples + user_i += 1 + + if 'hierarchies' in data: + users = [u for u, h in users_and_hiers][:user_i] + else: + users = users[:user_i] + + # ------------ + # create .json file + + all_data = {} + all_data['users'] = users + if hierarchies is not None: + all_data['hierarchies'] = hierarchies + all_data['num_samples'] = num_samples + all_data['user_data'] = user_data + + slabel = 'niid' + if iid: + slabel = 'iid' + + arg_frac = str(fraction) + arg_frac = arg_frac[2:] + arg_nu = str(iid_user_fraction) + arg_nu = arg_nu[2:] + arg_label = arg_frac + if iid: + arg_label = '%s_%s' % (arg_nu, arg_label) + file_name = '%s_%s_%s.json' % ((f[:-5]), slabel, arg_label) + ouf_dir = os.path.join(data_folder, 'sampled_data', file_name) + + logger.info('writing %s' % file_name) + with open(ouf_dir, 'w') as outfile: + json.dump(all_data, outfile) diff --git a/easyfl/datasets/utils/split_data.py b/easyfl/datasets/utils/split_data.py new file mode 100644 index 0000000..87546a4 --- /dev/null +++ b/easyfl/datasets/utils/split_data.py @@ -0,0 +1,235 @@ +""" +These codes are adopted from LEAF with some modifications. + +Splits data into train and test sets. +""" + +import json +import logging +import os +import random +import sys +import time +from collections import OrderedDict + +from easyfl.datasets.utils.constants import SEED_FILES + +logger = logging.getLogger(__name__) + + +def create_jsons_for(dir, setting_folder, user_files, which_set, max_users, include_hierarchy, subdir, arg_label): + """Used in split-by-user case""" + user_count = 0 + json_index = 0 + users = [] + if include_hierarchy: + hierarchies = [] + else: + hierarchies = None + num_samples = [] + user_data = {} + for (i, t) in enumerate(user_files): + if include_hierarchy: + (u, h, ns, f) = t + else: + (u, ns, f) = t + + file_dir = os.path.join(subdir, f) + with open(file_dir, 'r') as inf: + data = json.load(inf) + + users.append(u) + if include_hierarchy: + hierarchies.append(h) + num_samples.append(ns) + user_data[u] = data['user_data'][u] + user_count += 1 + + if (user_count == max_users) or (i == len(user_files) - 1): + + all_data = {} + all_data['users'] = users + if include_hierarchy: + all_data['hierarchies'] = hierarchies + all_data['num_samples'] = num_samples + all_data['user_data'] = user_data + + data_i = f.find('data') + num_i = data_i + 5 + num_to_end = f[num_i:] + param_i = num_to_end.find('_') + param_to_end = '.json' + if param_i != -1: + param_to_end = num_to_end[param_i:] + nf = "{}_{}{}".format(f[:(num_i - 1)], json_index, param_to_end) + file_name = '{}_{}_{}.json'.format((nf[:-5]), which_set, arg_label) + ouf_dir = os.path.join(dir, setting_folder, which_set, file_name) + + logger.info('writing {}'.format(file_name)) + with open(ouf_dir, 'w') as outfile: + json.dump(all_data, outfile) + + user_count = 0 + json_index += 1 + users = [] + if include_hierarchy: + hierarchies = [] + num_samples = [] + user_data = {} + + +def split_train_test(setting_folder, metafile, name, user, frac, seed): + logger.info("------------------------------") + logger.info("generating training and test sets") + + parent_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + dir = os.path.join(parent_path, name, 'data') + subdir = os.path.join(dir, setting_folder, 'rem_user_data') + files = [] + if os.path.exists(subdir): + files = os.listdir(subdir) + if len(files) == 0: + subdir = os.path.join(dir, setting_folder, 'sampled_data') + if os.path.exists(subdir): + files = os.listdir(subdir) + if len(files) == 0: + subdir = os.path.join(dir, 'all_data') + files = os.listdir(subdir) + files = [f for f in files if f.endswith('.json')] + + rng_seed = (seed if (seed is not None and seed >= 0) else int(time.time())) + rng = random.Random(rng_seed) + if metafile is not None: + seed_fname = os.path.join(metafile, SEED_FILES['split']) + with open(seed_fname, 'w+') as f: + f.write("# split_seed used by sampling script - supply as " + "--spltseed to preprocess.sh or --seed to utils/split_data.py\n") + f.write(str(rng_seed)) + logger.info("- random seed written out to {file}".format(file=seed_fname)) + else: + logger.info("- using random seed '{seed}' for sampling".format(seed=rng_seed)) + + arg_label = str(frac) + arg_label = arg_label[2:] + + # check if data contains information on hierarchies + file_dir = os.path.join(subdir, files[0]) + with open(file_dir, 'r') as inf: + data = json.load(inf) + include_hierarchy = 'hierarchies' in data + + if (user): + logger.info("splitting data by user") + + # 1 pass through all the json files to instantiate arr + # containing all possible (user, .json file name) tuples + user_files = [] + for f in files: + file_dir = os.path.join(subdir, f) + with open(file_dir, 'r') as inf: + # Load data into an OrderedDict, to prevent ordering changes + # and enable reproducibility + data = json.load(inf, object_pairs_hook=OrderedDict) + if include_hierarchy: + user_files.extend([(u, h, ns, f) for (u, h, ns) in + zip(data['users'], data['hierarchies'], data['num_samples'])]) + else: + user_files.extend([(u, ns, f) for (u, ns) in + zip(data['users'], data['num_samples'])]) + + # randomly sample from user_files to pick training set users + num_users = len(user_files) + num_train_users = int(frac * num_users) + indices = [i for i in range(num_users)] + train_indices = rng.sample(indices, num_train_users) + train_blist = [False for i in range(num_users)] + for i in train_indices: + train_blist[i] = True + train_user_files = [] + test_user_files = [] + for i in range(num_users): + if (train_blist[i]): + train_user_files.append(user_files[i]) + else: + test_user_files.append(user_files[i]) + + max_users = sys.maxsize + if name == 'femnist': + max_users = 50 # max number of users per json file + create_jsons_for(dir, setting_folder, train_user_files, 'train', max_users, include_hierarchy, subdir, + arg_label) + create_jsons_for(dir, setting_folder, test_user_files, 'test', max_users, include_hierarchy, subdir, arg_label) + + else: + logger.info("splitting data by sample") + + for f in files: + file_dir = os.path.join(subdir, f) + with open(file_dir, 'r') as inf: + # Load data into an OrderedDict, to prevent ordering changes + # and enable reproducibility + data = json.load(inf, object_pairs_hook=OrderedDict) + + num_samples_train = [] + user_data_train = {} + num_samples_test = [] + user_data_test = {} + + user_indices = [] # indices of users in data['users'] that are not deleted + + for i, u in enumerate(data['users']): + user_data_train[u] = {'x': [], 'y': []} + user_data_test[u] = {'x': [], 'y': []} + + curr_num_samples = len(data['user_data'][u]['y']) + if curr_num_samples >= 2: + user_indices.append(i) + + # ensures number of train and test samples both >= 1 + num_train_samples = max(1, int(frac * curr_num_samples)) + if curr_num_samples == 2: + num_train_samples = 1 + + num_test_samples = curr_num_samples - num_train_samples + num_samples_train.append(num_train_samples) + num_samples_test.append(num_test_samples) + + indices = [j for j in range(curr_num_samples)] + train_indices = rng.sample(indices, num_train_samples) + train_blist = [False for _ in range(curr_num_samples)] + for j in train_indices: + train_blist[j] = True + + for j in range(curr_num_samples): + if (train_blist[j]): + user_data_train[u]['x'].append(data['user_data'][u]['x'][j]) + user_data_train[u]['y'].append(data['user_data'][u]['y'][j]) + else: + user_data_test[u]['x'].append(data['user_data'][u]['x'][j]) + user_data_test[u]['y'].append(data['user_data'][u]['y'][j]) + + users = [data['users'][i] for i in user_indices] + + all_data_train = {} + all_data_train['users'] = users + all_data_train['num_samples'] = num_samples_train + all_data_train['user_data'] = user_data_train + all_data_test = {} + all_data_test['users'] = users + all_data_test['num_samples'] = num_samples_test + all_data_test['user_data'] = user_data_test + + if include_hierarchy: + all_data_train['hierarchies'] = data['hierarchies'] + all_data_test['hierarchies'] = data['hierarchies'] + + file_name_train = '{}_train_{}.json'.format((f[:-5]), arg_label) + file_name_test = '{}_test_{}.json'.format((f[:-5]), arg_label) + ouf_dir_train = os.path.join(dir, setting_folder, 'train', file_name_train) + ouf_dir_test = os.path.join(dir, setting_folder, 'test', file_name_test) + logger.info("writing {}".format(file_name_train)) + with open(ouf_dir_train, 'w') as outfile: + json.dump(all_data_train, outfile) + logger.info("writing {}".format(file_name_test)) + with open(ouf_dir_test, 'w') as outfile: + json.dump(all_data_test, outfile) diff --git a/easyfl/datasets/utils/util.py b/easyfl/datasets/utils/util.py new file mode 100644 index 0000000..031824a --- /dev/null +++ b/easyfl/datasets/utils/util.py @@ -0,0 +1,42 @@ +import pickle + + +def save_obj(obj, name): + with open(name + '.pkl', 'wb') as f: + pickle.dump(obj, f, pickle.HIGHEST_PROTOCOL) + + +def load_obj(name): + with open(name + '.pkl', 'rb') as f: + return pickle.load(f) + + +def save_dict(dic, filename): + with open(filename, 'wb') as f: + pickle.dump(dic, f) + + +def load_dict(filename): + with open(filename, 'rb') as f: + dic = pickle.load(f) + return dic + + +def iid_divide(l, g): + """ + divide list l among g groups + each group has either int(len(l)/g) or int(len(l)/g)+1 elements + returns a list of groups + """ + num_elems = len(l) + group_size = int(len(l) / g) + num_big_groups = num_elems - g * group_size + num_small_groups = g - num_big_groups + glist = [] + for i in range(num_small_groups): + glist.append(l[group_size * i: group_size * (i + 1)]) + bi = group_size * num_small_groups + group_size += 1 + for i in range(num_big_groups): + glist.append(l[bi + group_size * i:bi + group_size * (i + 1)]) + return glist diff --git a/easyfl/distributed/__init__.py b/easyfl/distributed/__init__.py new file mode 100644 index 0000000..9dc94ab --- /dev/null +++ b/easyfl/distributed/__init__.py @@ -0,0 +1,18 @@ +from easyfl.distributed.distributed import ( + dist_init, + get_device, + grouping, + reduce_models, + reduce_models_only_params, + reduce_value, + reduce_values, + reduce_weighted_values, + gather_value, + CPU +) + +from easyfl.distributed.slurm import setup, get_ip + +__all__ = ['dist_init', 'get_device', 'grouping', 'gather_value', 'setup', 'get_ip', + 'reduce_models', 'reduce_models_only_params', 'reduce_value', 'reduce_values', 'reduce_weighted_values'] + diff --git a/easyfl/distributed/distributed.py b/easyfl/distributed/distributed.py new file mode 100644 index 0000000..27e91c2 --- /dev/null +++ b/easyfl/distributed/distributed.py @@ -0,0 +1,257 @@ +import logging + +import numpy as np +import torch +import torch.distributed as dist + +logger = logging.getLogger(__name__) + +CPU = "cpu" + +RANDOMIZE_GROUPING = "random" +GREEDY_GROUPING = "greedy" +SLOWEST_GROUPING = "slowest" + + +def reduce_models(model, sample_sum): + """Aggregate models across devices and update the model with the new aggregated model parameters. + + Args: + model (nn.Module): The model in a device to aggregate. + sample_sum (int): Sum of the total dataset sizes of clients in a device. + """ + dist.all_reduce(sample_sum, op=dist.ReduceOp.SUM) + state = model.state_dict() + for k in state.keys(): + dist.all_reduce(state[k], op=dist.ReduceOp.SUM) + state[k] = torch.div(state[k], sample_sum) + model.load_state_dict(state) + + +def reduce_models_only_params(model, sample_sum): + """Aggregate models across devices and update the model with the new aggregated model parameters, + excluding the persistent buffers like BN stats. + + Args: + model (nn.Module): The model in a device to aggregate. + sample_sum (torch.Tensor): Sum of the total dataset sizes of clients in a device. + """ + dist.all_reduce(sample_sum, op=dist.ReduceOp.SUM) + for param in model.parameters(): + dist.all_reduce(param.data, op=dist.ReduceOp.SUM) + param.data = torch.div(param.data, sample_sum) + + +def reduce_value(value, device): + """Calculate the sum of the value across devices. + + Args: + value (float/int): Value to sum. + device (str): The device where the value is on, either cpu or cuda devices. + Returns: + torch.Tensor: Sum of the values. + """ + v = torch.tensor(value).to(device) + dist.all_reduce(v, op=dist.ReduceOp.SUM) + return v + + +def reduce_values(values, device): + """Calculate the average of values across devices. + + Args: + values (list[float|int]): Values to average. + device (str): The device where the value is on, either cpu or cuda devices. + Returns: + torch.Tensor: The average of the values across devices. + """ + length = torch.tensor(len(values)).to(device) + total = torch.tensor(sum(values)).to(device) + dist.all_reduce(length, op=dist.ReduceOp.SUM) + dist.all_reduce(total, op=dist.ReduceOp.SUM) + return torch.div(total, length) + + +def reduce_weighted_values(values, weights, device): + """Calculate the weighted average of values across devices. + + Args: + values (list[float|int]): Values to average. + weights (list[float|int]): The weights to calculate weighted average. + device (str): The device where the value is on, either cpu or cuda devices. + Returns: + torch.Tensor: The average of values across devices. + """ + values = torch.tensor(values).to(device) + weights = torch.tensor(weights).to(device) + total_weights = torch.sum(weights).to(device) + weighted_sum = torch.sum(values * weights).to(device) + dist.all_reduce(total_weights, op=dist.ReduceOp.SUM) + dist.all_reduce(weighted_sum, op=dist.ReduceOp.SUM) + return torch.div(weighted_sum, total_weights) + + +def gather_value(value, world_size, device): + """Gather the value from devices to a list. + + Args: + value (float|int): The value to gather. + world_size (int): The number of processes. + device (str): The device where the value is on, either cpu or cuda devices. + Returns: + list[torch.Tensor]: A list of gathered values. + """ + v = torch.tensor(value).to(device) + target = [v.clone() for _ in range(world_size)] + dist.all_gather(target, v) + return target + + +def grouping(clients, world_size, default_time=10, strategy=RANDOMIZE_GROUPING, seed=1): + """Divide clients into groups with different strategies. + + Args: + clients (list[:obj:`BaseClient`]): A list of clients. + world_size (int): The number of processes, it represent the number of groups here. + default_time (float, optional): The default training time for not profiled clients. + strategy (str, optional): Strategy of grouping, options: random, greedy, worst. + When no strategy is applied, each client is a group. + seed (int, optional): Random seed. + + Returns: + list[list[:obj:`BaseClient`]]: Groups of clients, each group is a sub-list. + """ + np.random.seed(seed) + if strategy == RANDOMIZE_GROUPING: + return randomize_grouping(clients, world_size) + elif strategy == GREEDY_GROUPING: + return greedy_grouping(clients, world_size, default_time) + elif strategy == SLOWEST_GROUPING: + return slowest_grouping(clients, world_size) + else: + # default, no strategy applied + return [[client] for client in clients] + + +def randomize_grouping(clients, world_size): + """"Randomly divide clients into groups. + + Args: + clients (list[:obj:`BaseClient`]): A list of clients. + world_size (int): The number of processes, it represent the number of groups here. + + Returns: + list[list[:obj:`BaseClient`]]: Groups of clients, each group is a sub-list. + """ + num_of_clients = len(clients) + np.random.shuffle(clients) + data_per_client = num_of_clients // world_size + large_group_num = num_of_clients - world_size * data_per_client + small_group_num = world_size - large_group_num + grouped_clients = [] + for i in range(small_group_num): + base_index = data_per_client * i + grouped_clients.append(clients[base_index: base_index + data_per_client]) + small_size = data_per_client * small_group_num + data_per_client += 1 + for i in range(large_group_num): + base_index = small_size + data_per_client * i + grouped_clients.append(clients[base_index: base_index + data_per_client]) + return grouped_clients + + +def greedy_grouping(clients, world_size, default_time): + """"Greedily allocate the clients with longest training time to the most available device. + + + Args: + clients (list[:obj:`BaseClient`]): A list of clients. + world_size (int): The number of processes, it represent the number of groups here. + default_time (float, optional): The default training time for not profiled clients. + + Returns: + list[list[:obj:`BaseClient`]]: Groups of clients, each group is a sub-list. + """ + round_time_estimation = [[i, c.round_time] if c.round_time != 0 + else [i, default_time] for i, c in enumerate(clients)] + round_time_estimation = sorted(round_time_estimation, reverse=True, key=lambda tup: (tup[1], tup[0])) + top_world_size = round_time_estimation[:world_size] + groups = [[clients[index]] for (index, time) in top_world_size] + time_sum = [time for (index, time) in top_world_size] + for i in round_time_estimation[world_size:]: + min_index = np.argmin(time_sum) + groups[min_index].append(clients[i[0]]) + time_sum[min_index] += i[1] + return groups + + +def slowest_grouping(clients, world_size): + """"Allocate the clients with longest training time to the most busy device. + Only for experiment, not practical in use. + + + Args: + clients (list[:obj:`BaseClient`]): A list of clients. + world_size (int): The number of processes, it represent the number of groups here. + + Returns: + list[list[:obj:`BaseClient`]]: Groups of clients, each group is a sub-list. + """ + num_of_clients = len(clients) + clients = sorted(clients, key=lambda tup: (tup.round_time, tup.cid)) + data_per_client = num_of_clients // world_size + large_group_num = num_of_clients - world_size * data_per_client + small_group_num = world_size - large_group_num + grouped_clients = [] + for i in range(small_group_num): + base_index = data_per_client * i + grouped_clients.append(clients[base_index: base_index + data_per_client]) + small_size = data_per_client * small_group_num + data_per_client += 1 + for i in range(large_group_num): + base_index = small_size + data_per_client * i + grouped_clients.append(clients[base_index: base_index + data_per_client]) + return grouped_clients + + +def dist_init(backend, init_method, world_size, rank, local_rank): + """Initialize PyTorch distribute. + + Args: + backend (str or Backend): Distributed backend to use, e.g., `nccl`, `gloo`. + init_method (str, optional): URL specifying how to initialize the process group. + world_size (int, optional): Number of processes participating in the job. + rank (int, optional): Rank of the current process. + local rank (int, optional): Local rank of the current process. + + Returns: + int: Rank of current process. + int: Total number of processes. + """ + dist.init_process_group(backend, init_method=init_method, rank=rank, world_size=world_size) + assert dist.is_initialized() + return rank, world_size + + +def get_device(gpu, world_size, local_rank): + """Obtain the device by checking the number of GPUs and distributed settings. + + Args: + gpu (int): The number of requested gpu. + world_size (int): The number of processes. + local_rank (int): The local rank of the current process. + + Returns: + str: Device to be used in PyTorch like `tensor.to(device)`. + """ + if gpu > world_size: + logger.error("Available gpu: {}, requested gpu: {}".format(world_size, gpu)) + raise ValueError("available number of gpu are less than requested") + + # TODO: think of a better way to handle this, maybe just use one config param instead of two. + assert gpu == world_size + + n = torch.cuda.device_count() + + device_ids = list(range(n)) + return device_ids[local_rank] diff --git a/easyfl/distributed/slurm.py b/easyfl/distributed/slurm.py new file mode 100644 index 0000000..5b2e1c6 --- /dev/null +++ b/easyfl/distributed/slurm.py @@ -0,0 +1,64 @@ +import logging +import os +import re +import socket + +logger = logging.getLogger(__name__) + + +def setup(port=23344): + """Setup distributed settings of slurm. + + Args: + port (int, optional): The port of the primary server. + It respectively auto-increments by 1 when the port is in-use. + + Returns: + int: The rank of current process. + int: The local rank of current process. + int: Total number of processes. + str: The address of the distributed init method. + """ + try: + rank = int(os.environ['SLURM_PROCID']) + local_rank = int(os.environ['SLURM_LOCALID']) + world_size = int(os.environ['SLURM_NTASKS']) + host = get_ip(os.environ['SLURM_STEP_NODELIST']) + while is_port_in_use(host, port): + port += 1 + host_addr = 'tcp://' + host + ':' + str(port) + except KeyError: + return 0, 0, 0, "" + return rank, local_rank, world_size, host_addr + + +def get_ip(node_list): + """Get the ip address of nodes. + + Args: + node_list (str): Name of the nodes. + + Returns: + str: The first node in the nodes. + """ + if "[" not in node_list: + return node_list + r = re.search(r'([\w-]*)\[(\d*)[-+,+\d]*\]', node_list) + if not r: + return + base, node = r.groups() + return base + node + + +def is_port_in_use(host, port): + """Check whether the port is in use. + + Args: + host (str): Host address. + port (int): Port to use. + + Returns: + bool: A flag to indicate whether the port is in use in the host. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 diff --git a/easyfl/encryption/__init__.py b/easyfl/encryption/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/models/__init__.py b/easyfl/models/__init__.py new file mode 100644 index 0000000..bce4a1d --- /dev/null +++ b/easyfl/models/__init__.py @@ -0,0 +1 @@ +from easyfl.models.model import BaseModel diff --git a/easyfl/models/lenet.py b/easyfl/models/lenet.py new file mode 100644 index 0000000..eb00c27 --- /dev/null +++ b/easyfl/models/lenet.py @@ -0,0 +1,36 @@ +from torch import nn +import torch.nn.functional as F + +from easyfl.models.model import BaseModel + + +class Model(BaseModel): + def __init__(self): + super(Model, self).__init__() + self.conv1 = nn.Conv2d(1, 32, 5, padding=(2, 2)) + self.conv2 = nn.Conv2d(32, 64, 5, padding=(2, 2)) + self.fc1 = nn.Linear(7 * 7 * 64, 2048) + self.fc2 = nn.Linear(2048, 62) + self.init_weights() + + def forward(self, x): + x = F.relu(self.conv1(x)) + x = F.max_pool2d(x, 2, 2) + x = F.relu(self.conv2(x)) + x = F.max_pool2d(x, 2, 2) + x = x.view(-1, 7 * 7 * 64) + x = F.relu(self.fc1(x)) + x = self.fc2(x) + + return x + + def init_weights(self): + init_range = 0.1 + self.conv1.weight.data.uniform_(-init_range, init_range) + self.conv1.bias.data.zero_() + self.conv2.weight.data.uniform_(-init_range, init_range) + self.conv2.bias.data.zero_() + self.fc1.weight.data.uniform_(-init_range, init_range) + self.fc1.bias.data.zero_() + self.fc2.weight.data.uniform_(-init_range, init_range) + self.fc2.bias.data.zero_() diff --git a/easyfl/models/model.py b/easyfl/models/model.py new file mode 100644 index 0000000..a688639 --- /dev/null +++ b/easyfl/models/model.py @@ -0,0 +1,24 @@ +import importlib +import logging +from os import path + +from torch import nn + +logger = logging.getLogger(__name__) + + +class BaseModel(nn.Module): + def __init__(self): + super(BaseModel, self).__init__() + + +def load_model(model_name: str): + dir_path = path.dirname(path.realpath(__file__)) + model_file = path.join(dir_path, "{}.py".format(model_name)) + if not path.exists(model_file): + logger.error("Please specify a valid model.") + model_path = "easyfl.models.{}".format(model_name) + model_lib = importlib.import_module(model_path) + model = getattr(model_lib, "Model") + # TODO: maybe return the model class initiator + return model diff --git a/easyfl/models/resnet.py b/easyfl/models/resnet.py new file mode 100644 index 0000000..4a11e33 --- /dev/null +++ b/easyfl/models/resnet.py @@ -0,0 +1,124 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +import torchvision.models.resnet + + +class BasicBlock(nn.Module): + expansion = 1 + + def __init__(self, in_planes, planes, stride=1): + super(BasicBlock, self).__init__() + self.conv1 = nn.Conv2d( + in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class Bottleneck(nn.Module): + expansion = 4 + + def __init__(self, in_planes, planes, stride=1): + super(Bottleneck, self).__init__() + self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=stride, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + self.conv3 = nn.Conv2d(planes, self.expansion * + planes, kernel_size=1, bias=False) + self.bn3 = nn.BatchNorm2d(self.expansion * planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = F.relu(self.bn2(self.conv2(out))) + out = self.bn3(self.conv3(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class ResNet(nn.Module): + """ResNet + Note two main differences from official pytorch version: + 1. conv1 kernel size: pytorch version uses kernel_size=7 + 2. average pooling: pytorch version uses AdaptiveAvgPool + """ + + def __init__(self, block, num_blocks, num_classes=10): + super(ResNet, self).__init__() + self.in_planes = 64 + self.feature_dim = 512 * block.expansion + + self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(64) + self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1) + self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2) + self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2) + self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2) + self.avgpool = nn.AvgPool2d((4, 4)) + self.fc = nn.Linear(512 * block.expansion, num_classes) + + def _make_layer(self, block, planes, num_blocks, stride): + strides = [stride] + [1] * (num_blocks - 1) + layers = [] + for stride in strides: + layers.append(block(self.in_planes, planes, stride)) + self.in_planes = planes * block.expansion + return nn.Sequential(*layers) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.layer1(out) + out = self.layer2(out) + out = self.layer3(out) + out = self.layer4(out) + out = self.avgpool(out) + out = out.view(out.size(0), -1) + out = self.fc(out) + return out + + +def ResNet18(num_classes=10): + return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) + + +def ResNet34(num_classes=10): + return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes) + + +def ResNet50(num_classes=10): + return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes) + + +def ResNet101(num_classes=10): + return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes) + + +def ResNet152(num_classes=10): + return ResNet(Bottleneck, [3, 8, 36, 3], num_classes=num_classes) diff --git a/easyfl/models/resnet18.py b/easyfl/models/resnet18.py new file mode 100644 index 0000000..65d4cb4 --- /dev/null +++ b/easyfl/models/resnet18.py @@ -0,0 +1,102 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +from easyfl.models import BaseModel + + +class BasicBlock(nn.Module): + expansion = 1 + + def __init__(self, in_planes, planes, stride=1): + super(BasicBlock, self).__init__() + self.conv1 = nn.Conv2d( + in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class Bottleneck(nn.Module): + expansion = 4 + + def __init__(self, in_planes, planes, stride=1): + super(Bottleneck, self).__init__() + self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=stride, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + self.conv3 = nn.Conv2d(planes, self.expansion * + planes, kernel_size=1, bias=False) + self.bn3 = nn.BatchNorm2d(self.expansion * planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = F.relu(self.bn2(self.conv2(out))) + out = self.bn3(self.conv3(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class Model(BaseModel): + """ResNet18 model + Note two main differences from official pytorch version: + 1. conv1 kernel size: pytorch version uses kernel_size=7 + 2. average pooling: pytorch version uses AdaptiveAvgPool + """ + + def __init__(self, block=BasicBlock, num_blocks=[2, 2, 2, 2], num_classes=10): + super(Model, self).__init__() + self.in_planes = 64 + + self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(64) + self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1) + self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2) + self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2) + self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2) + self.linear = nn.Linear(512 * block.expansion, num_classes) + + def _make_layer(self, block, planes, num_blocks, stride): + strides = [stride] + [1] * (num_blocks - 1) + layers = [] + for stride in strides: + layers.append(block(self.in_planes, planes, stride)) + self.in_planes = planes * block.expansion + return nn.Sequential(*layers) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.layer1(out) + out = self.layer2(out) + out = self.layer3(out) + out = self.layer4(out) + out = F.avg_pool2d(out, 4) + out = out.view(out.size(0), -1) + out = self.linear(out) + return out diff --git a/easyfl/models/resnet50.py b/easyfl/models/resnet50.py new file mode 100644 index 0000000..2dd6bd7 --- /dev/null +++ b/easyfl/models/resnet50.py @@ -0,0 +1,107 @@ +'''ResNet in PyTorch. +For Pre-activation ResNet, see 'preact_resnet.py'. +Reference: +[1] Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun + Deep Residual Learning for Image Recognition. arXiv:1512.03385 +''' +import torch +import torch.nn as nn +import torch.nn.functional as F +from easyfl.models import BaseModel + + +class BasicBlock(BaseModel): + expansion = 1 + + def __init__(self, in_planes, planes, stride=1): + super(BasicBlock, self).__init__() + self.conv1 = nn.Conv2d( + in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=1, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.bn2(self.conv2(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class Bottleneck(BaseModel): + expansion = 4 + + def __init__(self, in_planes, planes, stride=1): + super(Bottleneck, self).__init__() + self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False) + self.bn1 = nn.BatchNorm2d(planes) + self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, + stride=stride, padding=1, bias=False) + self.bn2 = nn.BatchNorm2d(planes) + self.conv3 = nn.Conv2d(planes, self.expansion * + planes, kernel_size=1, bias=False) + self.bn3 = nn.BatchNorm2d(self.expansion * planes) + + self.shortcut = nn.Sequential() + if stride != 1 or in_planes != self.expansion * planes: + self.shortcut = nn.Sequential( + nn.Conv2d(in_planes, self.expansion * planes, + kernel_size=1, stride=stride, bias=False), + nn.BatchNorm2d(self.expansion * planes) + ) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = F.relu(self.bn2(self.conv2(out))) + out = self.bn3(self.conv3(out)) + out += self.shortcut(x) + out = F.relu(out) + return out + + +class Model(BaseModel): + def __init__(self, block=Bottleneck, num_blocks=[3, 4, 6, 3], num_classes=10): + super(Model, self).__init__() + self.in_planes = 64 + + self.conv1 = nn.Conv2d(3, 64, kernel_size=3, + stride=1, padding=1, bias=False) + self.bn1 = nn.BatchNorm2d(64) + self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1) + self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2) + self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2) + self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2) + self.linear = nn.Linear(512 * block.expansion, num_classes) + + def _make_layer(self, block, planes, num_blocks, stride): + strides = [stride] + [1] * (num_blocks - 1) + layers = [] + for stride in strides: + layers.append(block(self.in_planes, planes, stride)) + self.in_planes = planes * block.expansion + return nn.Sequential(*layers) + + def forward(self, x): + out = F.relu(self.bn1(self.conv1(x))) + out = self.layer1(out) + out = self.layer2(out) + out = self.layer3(out) + out = self.layer4(out) + out = F.avg_pool2d(out, 4) + out = out.view(out.size(0), -1) + out = self.linear(out) + return out + + +def ResNet50(): + return Model(Bottleneck, [3, 4, 6, 3]) diff --git a/easyfl/models/rnn.py b/easyfl/models/rnn.py new file mode 100644 index 0000000..72caea5 --- /dev/null +++ b/easyfl/models/rnn.py @@ -0,0 +1,36 @@ +import torch +import torch.nn as nn + +from easyfl.models.model import BaseModel + + +def repackage_hidden(h): + """Wraps hidden states in new Tensors, to detach them from their history.""" + + if isinstance(h, torch.Tensor): + return h.detach() + else: + return tuple(repackage_hidden(v) for v in h) + + +class Model(BaseModel): + def __init__(self, embedding_dim=8, voc_size=80, lstm_unit=256, batch_first=True, n_layers=2): + super(Model, self).__init__() + self.encoder = nn.Embedding(voc_size, embedding_dim) + self.lstm = nn.LSTM(embedding_dim, lstm_unit, n_layers, batch_first=batch_first) + self.decoder = nn.Linear(lstm_unit, voc_size) + self.init_weights() + + def forward(self, inp): + inp = self.encoder(inp) + inp, _ = self.lstm(inp) + # extract the last state of output for prediction + hidden = inp[:, -1] + output = self.decoder(hidden) + return output + + def init_weights(self): + init_range = 0.1 + self.encoder.weight.data.uniform_(-init_range, init_range) + self.decoder.bias.data.zero_() + self.decoder.weight.data.uniform_(-init_range, init_range) diff --git a/easyfl/models/simple_cnn.py b/easyfl/models/simple_cnn.py new file mode 100644 index 0000000..46645f6 --- /dev/null +++ b/easyfl/models/simple_cnn.py @@ -0,0 +1,40 @@ +import torch +import torch.nn.functional as F +from torch import nn + +from easyfl.models import BaseModel + + +class Model(BaseModel): + def __init__(self, channels=32): + super(Model, self).__init__() + self.num_channels = channels + self.conv1 = nn.Conv2d(3, self.num_channels, 3, stride=1) + self.conv2 = nn.Conv2d(self.num_channels, self.num_channels * 2, 3, stride=1) + self.conv3 = nn.Conv2d(self.num_channels * 2, self.num_channels * 2, 3, stride=1) + + # 2 fully connected layers to transform the output of the convolution layers to the final output + self.fc1 = nn.Linear(4 * 4 * self.num_channels * 2, self.num_channels * 2) + self.fc2 = nn.Linear(self.num_channels * 2, 10) + + def forward(self, s): + s = self.conv1(s) # batch_size x num_channels x 32 x 32 + + s = F.relu(F.max_pool2d(s, 2)) # batch_size x num_channels x 16 x 16 + + s = self.conv2(s) # batch_size x num_channels*2 x 16 x 16 + + s = F.relu(F.max_pool2d(s, 2)) # batch_size x num_channels*2 x 8 x 8 + + s = self.conv3(s) # batch_size x num_channels*2 x 8 x 8 + + # s = F.relu(F.max_pool2d(s, 2)) # batch_size x num_channels*2 x 4 x 4 + + # flatten the output for each image + s = s.view(-1, 4 * 4 * self.num_channels * 2) # batch_size x 4*4*num_channels*4 + + # apply 2 fully connected layers with dropout + s = F.relu(self.fc1(s)) + s = self.fc2(s) # batch_size x 10 + + return s diff --git a/easyfl/models/vgg9.py b/easyfl/models/vgg9.py new file mode 100644 index 0000000..3b12f11 --- /dev/null +++ b/easyfl/models/vgg9.py @@ -0,0 +1,65 @@ +import torch +import torch.nn as nn +import math +from easyfl.models import BaseModel + +cfg = { + 'VGG9': [32, 64, 'M', 128, 128, 'M', 256, 256, 'M'], +} + + +def make_layers(cfg, batch_norm): + layers = [] + in_channels = 3 + for v in cfg: + if v == 'M': + layers += [nn.MaxPool2d(kernel_size=2, stride=2)] + else: + conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1) + if batch_norm: + layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)] + + else: + layers += [conv2d, nn.ReLU(inplace=True)] + in_channels = v + return nn.Sequential(*layers) + + +class Model(BaseModel): + def __init__(self, features=make_layers(cfg['VGG9'], batch_norm=False), num_classes=10): + super(Model, self).__init__() + self.features = features + self.classifier = nn.Sequential( + nn.Dropout(p=0.1), + nn.Linear(4096, 512), + nn.ReLU(True), + nn.Dropout(p=0.1), + nn.Linear(512, 512), + nn.ReLU(True), + nn.Linear(512, num_classes), + ) + self._initialize_weights() + + def forward(self, x): + x = self.features(x) + x = x.view(x.size(0), -1) + x = self.classifier(x) + return x + + def _initialize_weights(self): + for m in self.modules(): + if isinstance(m, nn.Conv2d): + n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels + m.weight.data.normal_(0, math.sqrt(2. / n)) + if m.bias is not None: + m.bias.data.zero_() + elif isinstance(m, nn.BatchNorm2d): + m.reset_parameters() + elif isinstance(m, nn.Linear): + m.weight.data.normal_(0, 0.01) + m.bias.data.zero_() + + +def VGG9(batch_norm=False, **kwargs): + model = Model(make_layers(cfg['VGG9'], batch_norm), **kwargs) + return model diff --git a/easyfl/pb/__init__.py b/easyfl/pb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/pb/client_service_pb2.py b/easyfl/pb/client_service_pb2.py new file mode 100644 index 0000000..bdfc4f4 --- /dev/null +++ b/easyfl/pb/client_service_pb2.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: easyfl/pb/client_service.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from easyfl.pb import common_pb2 as easyfl_dot_pb_dot_common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x65\x61syfl/pb/client_service.proto\x12\teasyfl.pb\x1a\x16\x65\x61syfl/pb/common.proto\"\x85\x01\n\x0eOperateRequest\x12&\n\x04type\x18\x01 \x01(\x0e\x32\x18.easyfl.pb.OperationType\x12\r\n\x05model\x18\x02 \x01(\x0c\x12\x12\n\ndata_index\x18\x03 \x01(\x05\x12(\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x18.easyfl.pb.OperateConfig\"\xce\x01\n\rOperateConfig\x12\x12\n\nbatch_size\x18\x01 \x01(\x05\x12\x13\n\x0blocal_epoch\x18\x02 \x01(\x05\x12\x0c\n\x04seed\x18\x03 \x01(\x03\x12\'\n\toptimizer\x18\x04 \x01(\x0b\x32\x14.easyfl.pb.Optimizer\x12\x12\n\nlocal_test\x18\x05 \x01(\x08\x12\x0f\n\x07task_id\x18\x06 \x01(\t\x12\x10\n\x08round_id\x18\x07 \x01(\x05\x12\r\n\x05track\x18\x08 \x01(\x08\x12\x17\n\x0ftest_batch_size\x18\t \x01(\x05\"7\n\tOptimizer\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\n\n\x02lr\x18\x02 \x01(\x02\x12\x10\n\x08momentum\x18\x03 \x01(\x02\"4\n\x0fOperateResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status*4\n\rOperationType\x12\x11\n\rOP_TYPE_TRAIN\x10\x00\x12\x10\n\x0cOP_TYPE_TEST\x10\x01\x32S\n\rClientService\x12\x42\n\x07Operate\x12\x19.easyfl.pb.OperateRequest\x1a\x1a.easyfl.pb.OperateResponse\"\x00\x62\x06proto3') + +_OPERATIONTYPE = DESCRIPTOR.enum_types_by_name['OperationType'] +OperationType = enum_type_wrapper.EnumTypeWrapper(_OPERATIONTYPE) +OP_TYPE_TRAIN = 0 +OP_TYPE_TEST = 1 + + +_OPERATEREQUEST = DESCRIPTOR.message_types_by_name['OperateRequest'] +_OPERATECONFIG = DESCRIPTOR.message_types_by_name['OperateConfig'] +_OPTIMIZER = DESCRIPTOR.message_types_by_name['Optimizer'] +_OPERATERESPONSE = DESCRIPTOR.message_types_by_name['OperateResponse'] +OperateRequest = _reflection.GeneratedProtocolMessageType('OperateRequest', (_message.Message,), { + 'DESCRIPTOR' : _OPERATEREQUEST, + '__module__' : 'easyfl.pb.client_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.OperateRequest) + }) +_sym_db.RegisterMessage(OperateRequest) + +OperateConfig = _reflection.GeneratedProtocolMessageType('OperateConfig', (_message.Message,), { + 'DESCRIPTOR' : _OPERATECONFIG, + '__module__' : 'easyfl.pb.client_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.OperateConfig) + }) +_sym_db.RegisterMessage(OperateConfig) + +Optimizer = _reflection.GeneratedProtocolMessageType('Optimizer', (_message.Message,), { + 'DESCRIPTOR' : _OPTIMIZER, + '__module__' : 'easyfl.pb.client_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.Optimizer) + }) +_sym_db.RegisterMessage(Optimizer) + +OperateResponse = _reflection.GeneratedProtocolMessageType('OperateResponse', (_message.Message,), { + 'DESCRIPTOR' : _OPERATERESPONSE, + '__module__' : 'easyfl.pb.client_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.OperateResponse) + }) +_sym_db.RegisterMessage(OperateResponse) + +_CLIENTSERVICE = DESCRIPTOR.services_by_name['ClientService'] +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _OPERATIONTYPE._serialized_start=525 + _OPERATIONTYPE._serialized_end=577 + _OPERATEREQUEST._serialized_start=70 + _OPERATEREQUEST._serialized_end=203 + _OPERATECONFIG._serialized_start=206 + _OPERATECONFIG._serialized_end=412 + _OPTIMIZER._serialized_start=414 + _OPTIMIZER._serialized_end=469 + _OPERATERESPONSE._serialized_start=471 + _OPERATERESPONSE._serialized_end=523 + _CLIENTSERVICE._serialized_start=579 + _CLIENTSERVICE._serialized_end=662 +# @@protoc_insertion_point(module_scope) diff --git a/easyfl/pb/client_service_pb2_grpc.py b/easyfl/pb/client_service_pb2_grpc.py new file mode 100644 index 0000000..556ea75 --- /dev/null +++ b/easyfl/pb/client_service_pb2_grpc.py @@ -0,0 +1,66 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from easyfl.pb import client_service_pb2 as easyfl_dot_pb_dot_client__service__pb2 + + +class ClientServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Operate = channel.unary_unary( + '/easyfl.pb.ClientService/Operate', + request_serializer=easyfl_dot_pb_dot_client__service__pb2.OperateRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_client__service__pb2.OperateResponse.FromString, + ) + + +class ClientServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Operate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ClientServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Operate': grpc.unary_unary_rpc_method_handler( + servicer.Operate, + request_deserializer=easyfl_dot_pb_dot_client__service__pb2.OperateRequest.FromString, + response_serializer=easyfl_dot_pb_dot_client__service__pb2.OperateResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'easyfl.pb.ClientService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class ClientService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Operate(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.ClientService/Operate', + easyfl_dot_pb_dot_client__service__pb2.OperateRequest.SerializeToString, + easyfl_dot_pb_dot_client__service__pb2.OperateResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/easyfl/pb/common_pb2.py b/easyfl/pb/common_pb2.py new file mode 100644 index 0000000..8e56167 --- /dev/null +++ b/easyfl/pb/common_pb2.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: easyfl/pb/common.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16\x65\x61syfl/pb/common.proto\x12\teasyfl.pb\">\n\x06Status\x12#\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x15.easyfl.pb.StatusCode\x12\x0f\n\x07message\x18\x02 \x01(\t\"4\n\nTaskMetric\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x15\n\rconfiguration\x18\x02 \x01(\t\"\xcf\x02\n\x0bRoundMetric\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x15\n\rtest_accuracy\x18\x03 \x01(\x02\x12\x11\n\ttest_loss\x18\x04 \x01(\x02\x12\x12\n\nround_time\x18\x05 \x01(\x02\x12\x12\n\ntrain_time\x18\x06 \x01(\x02\x12\x11\n\ttest_time\x18\x07 \x01(\x02\x12\x1d\n\x15train_distribute_time\x18\x08 \x01(\x02\x12\x1c\n\x14test_distribute_time\x18\t \x01(\x02\x12\x19\n\x11train_upload_size\x18\n \x01(\x02\x12\x1b\n\x13train_download_size\x18\x0b \x01(\x02\x12\x18\n\x10test_upload_size\x18\x0c \x01(\x02\x12\x1a\n\x12test_download_size\x18\r \x01(\x02\x12\r\n\x05\x65xtra\x18\x0e \x01(\t\"\xf3\x02\n\x0c\x43lientMetric\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x11\n\tclient_id\x18\x03 \x01(\t\x12\x16\n\x0etrain_accuracy\x18\x04 \x03(\x02\x12\x12\n\ntrain_loss\x18\x05 \x03(\x02\x12\x15\n\rtest_accuracy\x18\x06 \x01(\x02\x12\x11\n\ttest_loss\x18\x07 \x01(\x02\x12\x12\n\ntrain_time\x18\x08 \x01(\x02\x12\x11\n\ttest_time\x18\t \x01(\x02\x12\x19\n\x11train_upload_time\x18\n \x01(\x02\x12\x18\n\x10test_upload_time\x18\x0b \x01(\x02\x12\x19\n\x11train_upload_size\x18\x0c \x01(\x02\x12\x1b\n\x13train_download_size\x18\r \x01(\x02\x12\x18\n\x10test_upload_size\x18\x0e \x01(\x02\x12\x1a\n\x12test_download_size\x18\x0f \x01(\x02\x12\r\n\x05\x65xtra\x18\x10 \x01(\t*\x82\x01\n\nStatusCode\x12\t\n\x05SC_OK\x10\x00\x12\x0e\n\nSC_UNKNOWN\x10\x01\x12\x16\n\x12SC_INVALID_REQUEST\x10\x02\x12\x18\n\x14SC_DEADLINE_EXCEEDED\x10\x03\x12\x10\n\x0cSC_NOT_FOUND\x10\x04\x12\x15\n\x11SC_ALREADY_EXISTS\x10\x05*;\n\x08\x44\x61taType\x12\x14\n\x10\x44\x41TA_TYPE_PARAMS\x10\x00\x12\x19\n\x15\x44\x41TA_TYPE_PERFORMANCE\x10\x01\x62\x06proto3') + +_STATUSCODE = DESCRIPTOR.enum_types_by_name['StatusCode'] +StatusCode = enum_type_wrapper.EnumTypeWrapper(_STATUSCODE) +_DATATYPE = DESCRIPTOR.enum_types_by_name['DataType'] +DataType = enum_type_wrapper.EnumTypeWrapper(_DATATYPE) +SC_OK = 0 +SC_UNKNOWN = 1 +SC_INVALID_REQUEST = 2 +SC_DEADLINE_EXCEEDED = 3 +SC_NOT_FOUND = 4 +SC_ALREADY_EXISTS = 5 +DATA_TYPE_PARAMS = 0 +DATA_TYPE_PERFORMANCE = 1 + + +_STATUS = DESCRIPTOR.message_types_by_name['Status'] +_TASKMETRIC = DESCRIPTOR.message_types_by_name['TaskMetric'] +_ROUNDMETRIC = DESCRIPTOR.message_types_by_name['RoundMetric'] +_CLIENTMETRIC = DESCRIPTOR.message_types_by_name['ClientMetric'] +Status = _reflection.GeneratedProtocolMessageType('Status', (_message.Message,), { + 'DESCRIPTOR' : _STATUS, + '__module__' : 'easyfl.pb.common_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.Status) + }) +_sym_db.RegisterMessage(Status) + +TaskMetric = _reflection.GeneratedProtocolMessageType('TaskMetric', (_message.Message,), { + 'DESCRIPTOR' : _TASKMETRIC, + '__module__' : 'easyfl.pb.common_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TaskMetric) + }) +_sym_db.RegisterMessage(TaskMetric) + +RoundMetric = _reflection.GeneratedProtocolMessageType('RoundMetric', (_message.Message,), { + 'DESCRIPTOR' : _ROUNDMETRIC, + '__module__' : 'easyfl.pb.common_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.RoundMetric) + }) +_sym_db.RegisterMessage(RoundMetric) + +ClientMetric = _reflection.GeneratedProtocolMessageType('ClientMetric', (_message.Message,), { + 'DESCRIPTOR' : _CLIENTMETRIC, + '__module__' : 'easyfl.pb.common_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.ClientMetric) + }) +_sym_db.RegisterMessage(ClientMetric) + +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _STATUSCODE._serialized_start=868 + _STATUSCODE._serialized_end=998 + _DATATYPE._serialized_start=1000 + _DATATYPE._serialized_end=1059 + _STATUS._serialized_start=37 + _STATUS._serialized_end=99 + _TASKMETRIC._serialized_start=101 + _TASKMETRIC._serialized_end=153 + _ROUNDMETRIC._serialized_start=156 + _ROUNDMETRIC._serialized_end=491 + _CLIENTMETRIC._serialized_start=494 + _CLIENTMETRIC._serialized_end=865 +# @@protoc_insertion_point(module_scope) diff --git a/easyfl/pb/common_pb2_grpc.py b/easyfl/pb/common_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/easyfl/pb/common_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/easyfl/pb/server_service_pb2.py b/easyfl/pb/server_service_pb2.py new file mode 100644 index 0000000..7e3b95e --- /dev/null +++ b/easyfl/pb/server_service_pb2.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: easyfl/pb/server_service.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from easyfl.pb import common_pb2 as easyfl_dot_pb_dot_common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x65\x61syfl/pb/server_service.proto\x12\teasyfl.pb\x1a\x16\x65\x61syfl/pb/common.proto\"p\n\rUploadRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x11\n\tclient_id\x18\x03 \x01(\t\x12)\n\x07\x63ontent\x18\x04 \x01(\x0b\x32\x18.easyfl.pb.UploadContent\"\x8b\x01\n\rUploadContent\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.easyfl.pb.DataType\x12\x11\n\tdata_size\x18\x03 \x01(\x03\x12\'\n\x06metric\x18\x04 \x01(\x0b\x32\x17.easyfl.pb.ClientMetric\x12\r\n\x05\x65xtra\x18\x63 \x01(\x0c\"-\n\x0bPerformance\x12\x10\n\x08\x61\x63\x63uracy\x18\x01 \x01(\x02\x12\x0c\n\x04loss\x18\x02 \x01(\x02\"3\n\x0eUploadResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"W\n\nRunRequest\x12\r\n\x05model\x18\x01 \x01(\x0c\x12\"\n\x07\x63lients\x18\x02 \x03(\x0b\x32\x11.easyfl.pb.Client\x12\x16\n\x0e\x65tcd_addresses\x18\x03 \x01(\t\"0\n\x0bRunResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"\r\n\x0bStopRequest\"1\n\x0cStopResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\";\n\x06\x43lient\x12\x11\n\tclient_id\x18\x01 \x01(\t\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\x12\r\n\x05index\x18\x03 \x01(\x05\x32\xc3\x01\n\rServerService\x12?\n\x06Upload\x12\x18.easyfl.pb.UploadRequest\x1a\x19.easyfl.pb.UploadResponse\"\x00\x12\x36\n\x03Run\x12\x15.easyfl.pb.RunRequest\x1a\x16.easyfl.pb.RunResponse\"\x00\x12\x39\n\x04Stop\x12\x16.easyfl.pb.StopRequest\x1a\x17.easyfl.pb.StopResponse\"\x00\x62\x06proto3') + + + +_UPLOADREQUEST = DESCRIPTOR.message_types_by_name['UploadRequest'] +_UPLOADCONTENT = DESCRIPTOR.message_types_by_name['UploadContent'] +_PERFORMANCE = DESCRIPTOR.message_types_by_name['Performance'] +_UPLOADRESPONSE = DESCRIPTOR.message_types_by_name['UploadResponse'] +_RUNREQUEST = DESCRIPTOR.message_types_by_name['RunRequest'] +_RUNRESPONSE = DESCRIPTOR.message_types_by_name['RunResponse'] +_STOPREQUEST = DESCRIPTOR.message_types_by_name['StopRequest'] +_STOPRESPONSE = DESCRIPTOR.message_types_by_name['StopResponse'] +_CLIENT = DESCRIPTOR.message_types_by_name['Client'] +UploadRequest = _reflection.GeneratedProtocolMessageType('UploadRequest', (_message.Message,), { + 'DESCRIPTOR' : _UPLOADREQUEST, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.UploadRequest) + }) +_sym_db.RegisterMessage(UploadRequest) + +UploadContent = _reflection.GeneratedProtocolMessageType('UploadContent', (_message.Message,), { + 'DESCRIPTOR' : _UPLOADCONTENT, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.UploadContent) + }) +_sym_db.RegisterMessage(UploadContent) + +Performance = _reflection.GeneratedProtocolMessageType('Performance', (_message.Message,), { + 'DESCRIPTOR' : _PERFORMANCE, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.Performance) + }) +_sym_db.RegisterMessage(Performance) + +UploadResponse = _reflection.GeneratedProtocolMessageType('UploadResponse', (_message.Message,), { + 'DESCRIPTOR' : _UPLOADRESPONSE, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.UploadResponse) + }) +_sym_db.RegisterMessage(UploadResponse) + +RunRequest = _reflection.GeneratedProtocolMessageType('RunRequest', (_message.Message,), { + 'DESCRIPTOR' : _RUNREQUEST, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.RunRequest) + }) +_sym_db.RegisterMessage(RunRequest) + +RunResponse = _reflection.GeneratedProtocolMessageType('RunResponse', (_message.Message,), { + 'DESCRIPTOR' : _RUNRESPONSE, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.RunResponse) + }) +_sym_db.RegisterMessage(RunResponse) + +StopRequest = _reflection.GeneratedProtocolMessageType('StopRequest', (_message.Message,), { + 'DESCRIPTOR' : _STOPREQUEST, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.StopRequest) + }) +_sym_db.RegisterMessage(StopRequest) + +StopResponse = _reflection.GeneratedProtocolMessageType('StopResponse', (_message.Message,), { + 'DESCRIPTOR' : _STOPRESPONSE, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.StopResponse) + }) +_sym_db.RegisterMessage(StopResponse) + +Client = _reflection.GeneratedProtocolMessageType('Client', (_message.Message,), { + 'DESCRIPTOR' : _CLIENT, + '__module__' : 'easyfl.pb.server_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.Client) + }) +_sym_db.RegisterMessage(Client) + +_SERVERSERVICE = DESCRIPTOR.services_by_name['ServerService'] +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _UPLOADREQUEST._serialized_start=69 + _UPLOADREQUEST._serialized_end=181 + _UPLOADCONTENT._serialized_start=184 + _UPLOADCONTENT._serialized_end=323 + _PERFORMANCE._serialized_start=325 + _PERFORMANCE._serialized_end=370 + _UPLOADRESPONSE._serialized_start=372 + _UPLOADRESPONSE._serialized_end=423 + _RUNREQUEST._serialized_start=425 + _RUNREQUEST._serialized_end=512 + _RUNRESPONSE._serialized_start=514 + _RUNRESPONSE._serialized_end=562 + _STOPREQUEST._serialized_start=564 + _STOPREQUEST._serialized_end=577 + _STOPRESPONSE._serialized_start=579 + _STOPRESPONSE._serialized_end=628 + _CLIENT._serialized_start=630 + _CLIENT._serialized_end=689 + _SERVERSERVICE._serialized_start=692 + _SERVERSERVICE._serialized_end=887 +# @@protoc_insertion_point(module_scope) diff --git a/easyfl/pb/server_service_pb2_grpc.py b/easyfl/pb/server_service_pb2_grpc.py new file mode 100644 index 0000000..adc8c14 --- /dev/null +++ b/easyfl/pb/server_service_pb2_grpc.py @@ -0,0 +1,132 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from easyfl.pb import server_service_pb2 as easyfl_dot_pb_dot_server__service__pb2 + + +class ServerServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Upload = channel.unary_unary( + '/easyfl.pb.ServerService/Upload', + request_serializer=easyfl_dot_pb_dot_server__service__pb2.UploadRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_server__service__pb2.UploadResponse.FromString, + ) + self.Run = channel.unary_unary( + '/easyfl.pb.ServerService/Run', + request_serializer=easyfl_dot_pb_dot_server__service__pb2.RunRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_server__service__pb2.RunResponse.FromString, + ) + self.Stop = channel.unary_unary( + '/easyfl.pb.ServerService/Stop', + request_serializer=easyfl_dot_pb_dot_server__service__pb2.StopRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_server__service__pb2.StopResponse.FromString, + ) + + +class ServerServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Upload(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Run(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Stop(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ServerServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Upload': grpc.unary_unary_rpc_method_handler( + servicer.Upload, + request_deserializer=easyfl_dot_pb_dot_server__service__pb2.UploadRequest.FromString, + response_serializer=easyfl_dot_pb_dot_server__service__pb2.UploadResponse.SerializeToString, + ), + 'Run': grpc.unary_unary_rpc_method_handler( + servicer.Run, + request_deserializer=easyfl_dot_pb_dot_server__service__pb2.RunRequest.FromString, + response_serializer=easyfl_dot_pb_dot_server__service__pb2.RunResponse.SerializeToString, + ), + 'Stop': grpc.unary_unary_rpc_method_handler( + servicer.Stop, + request_deserializer=easyfl_dot_pb_dot_server__service__pb2.StopRequest.FromString, + response_serializer=easyfl_dot_pb_dot_server__service__pb2.StopResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'easyfl.pb.ServerService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class ServerService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Upload(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.ServerService/Upload', + easyfl_dot_pb_dot_server__service__pb2.UploadRequest.SerializeToString, + easyfl_dot_pb_dot_server__service__pb2.UploadResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Run(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.ServerService/Run', + easyfl_dot_pb_dot_server__service__pb2.RunRequest.SerializeToString, + easyfl_dot_pb_dot_server__service__pb2.RunResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Stop(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.ServerService/Stop', + easyfl_dot_pb_dot_server__service__pb2.StopRequest.SerializeToString, + easyfl_dot_pb_dot_server__service__pb2.StopResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/easyfl/pb/tracking_service_pb2.py b/easyfl/pb/tracking_service_pb2.py new file mode 100644 index 0000000..a6c4701 --- /dev/null +++ b/easyfl/pb/tracking_service_pb2.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: easyfl/pb/tracking_service.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from easyfl.pb import common_pb2 as easyfl_dot_pb_dot_common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n easyfl/pb/tracking_service.proto\x12\teasyfl.pb\x1a\x16\x65\x61syfl/pb/common.proto\"D\n\x16TrackTaskMetricRequest\x12*\n\x0btask_metric\x18\x01 \x01(\x0b\x32\x15.easyfl.pb.TaskMetric\"<\n\x17TrackTaskMetricResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"G\n\x17TrackRoundMetricRequest\x12,\n\x0cround_metric\x18\x01 \x01(\x0b\x32\x16.easyfl.pb.RoundMetric\"=\n\x18TrackRoundMetricResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"K\n\x18TrackClientMetricRequest\x12/\n\x0e\x63lient_metrics\x18\x01 \x03(\x0b\x32\x17.easyfl.pb.ClientMetric\">\n\x19TrackClientMetricResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"\xd0\x01\n\x1dTrackClientTrainMetricRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x11\n\tclient_id\x18\x03 \x01(\t\x12\x12\n\ntrain_loss\x18\x04 \x03(\x02\x12\x12\n\ntrain_time\x18\x05 \x01(\x02\x12\x19\n\x11train_upload_time\x18\x06 \x01(\x02\x12\x1b\n\x13train_download_size\x18\x07 \x01(\x02\x12\x19\n\x11train_upload_size\x18\x08 \x01(\x02\"C\n\x1eTrackClientTrainMetricResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"\xc7\x01\n\x1cTrackClientTestMetricRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x11\n\tclient_id\x18\x03 \x01(\t\x12\x15\n\rtest_accuracy\x18\x04 \x01(\x02\x12\x11\n\ttest_loss\x18\x05 \x01(\x02\x12\x11\n\ttest_time\x18\x06 \x01(\x02\x12\x18\n\x10test_upload_time\x18\x07 \x01(\x02\x12\x1a\n\x12test_download_size\x18\x08 \x01(\x02\"B\n\x1dTrackClientTestMetricResponse\x12!\n\x06status\x18\x01 \x01(\x0b\x32\x11.easyfl.pb.Status\"Q\n\x1cGetRoundTrainTestTimeRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x0e\n\x06rounds\x18\x02 \x01(\x05\x12\x10\n\x08interval\x18\x03 \x01(\x05\"/\n\rTrainTestTime\x12\x10\n\x08round_id\x18\x01 \x01(\x05\x12\x0c\n\x04time\x18\x02 \x01(\x02\"v\n\x1dGetRoundTrainTestTimeResponse\x12\x32\n\x10train_test_times\x18\x01 \x03(\x0b\x32\x18.easyfl.pb.TrainTestTime\x12!\n\x06status\x18\x02 \x01(\x0b\x32\x11.easyfl.pb.Status\"9\n\x16GetRoundMetricsRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x0e\n\x06rounds\x18\x02 \x03(\x05\"\x88\x01\n\x17GetRoundMetricsResponse\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\'\n\x07metrics\x18\x03 \x03(\x0b\x32\x16.easyfl.pb.RoundMetric\x12!\n\x06status\x18\x04 \x01(\x0b\x32\x11.easyfl.pb.Status\"P\n\x17GetClientMetricsRequest\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12\x12\n\nclient_ids\x18\x03 \x03(\t\"\x8a\x01\n\x18GetClientMetricsResponse\x12\x0f\n\x07task_id\x18\x01 \x01(\t\x12\x10\n\x08round_id\x18\x02 \x01(\x05\x12(\n\x07metrics\x18\x03 \x03(\x0b\x32\x17.easyfl.pb.ClientMetric\x12!\n\x06status\x18\x04 \x01(\x0b\x32\x11.easyfl.pb.Status2\xb6\x06\n\x0fTrackingService\x12Z\n\x0fTrackTaskMetric\x12!.easyfl.pb.TrackTaskMetricRequest\x1a\".easyfl.pb.TrackTaskMetricResponse\"\x00\x12]\n\x10TrackRoundMetric\x12\".easyfl.pb.TrackRoundMetricRequest\x1a#.easyfl.pb.TrackRoundMetricResponse\"\x00\x12`\n\x11TrackClientMetric\x12#.easyfl.pb.TrackClientMetricRequest\x1a$.easyfl.pb.TrackClientMetricResponse\"\x00\x12o\n\x16TrackClientTrainMetric\x12(.easyfl.pb.TrackClientTrainMetricRequest\x1a).easyfl.pb.TrackClientTrainMetricResponse\"\x00\x12l\n\x15TrackClientTestMetric\x12\'.easyfl.pb.TrackClientTestMetricRequest\x1a(.easyfl.pb.TrackClientTestMetricResponse\"\x00\x12Z\n\x0fGetRoundMetrics\x12!.easyfl.pb.GetRoundMetricsRequest\x1a\".easyfl.pb.GetRoundMetricsResponse\"\x00\x12]\n\x10GetClientMetrics\x12\".easyfl.pb.GetClientMetricsRequest\x1a#.easyfl.pb.GetClientMetricsResponse\"\x00\x12l\n\x15GetRoundTrainTestTime\x12\'.easyfl.pb.GetRoundTrainTestTimeRequest\x1a(.easyfl.pb.GetRoundTrainTestTimeResponse\"\x00\x62\x06proto3') + + + +_TRACKTASKMETRICREQUEST = DESCRIPTOR.message_types_by_name['TrackTaskMetricRequest'] +_TRACKTASKMETRICRESPONSE = DESCRIPTOR.message_types_by_name['TrackTaskMetricResponse'] +_TRACKROUNDMETRICREQUEST = DESCRIPTOR.message_types_by_name['TrackRoundMetricRequest'] +_TRACKROUNDMETRICRESPONSE = DESCRIPTOR.message_types_by_name['TrackRoundMetricResponse'] +_TRACKCLIENTMETRICREQUEST = DESCRIPTOR.message_types_by_name['TrackClientMetricRequest'] +_TRACKCLIENTMETRICRESPONSE = DESCRIPTOR.message_types_by_name['TrackClientMetricResponse'] +_TRACKCLIENTTRAINMETRICREQUEST = DESCRIPTOR.message_types_by_name['TrackClientTrainMetricRequest'] +_TRACKCLIENTTRAINMETRICRESPONSE = DESCRIPTOR.message_types_by_name['TrackClientTrainMetricResponse'] +_TRACKCLIENTTESTMETRICREQUEST = DESCRIPTOR.message_types_by_name['TrackClientTestMetricRequest'] +_TRACKCLIENTTESTMETRICRESPONSE = DESCRIPTOR.message_types_by_name['TrackClientTestMetricResponse'] +_GETROUNDTRAINTESTTIMEREQUEST = DESCRIPTOR.message_types_by_name['GetRoundTrainTestTimeRequest'] +_TRAINTESTTIME = DESCRIPTOR.message_types_by_name['TrainTestTime'] +_GETROUNDTRAINTESTTIMERESPONSE = DESCRIPTOR.message_types_by_name['GetRoundTrainTestTimeResponse'] +_GETROUNDMETRICSREQUEST = DESCRIPTOR.message_types_by_name['GetRoundMetricsRequest'] +_GETROUNDMETRICSRESPONSE = DESCRIPTOR.message_types_by_name['GetRoundMetricsResponse'] +_GETCLIENTMETRICSREQUEST = DESCRIPTOR.message_types_by_name['GetClientMetricsRequest'] +_GETCLIENTMETRICSRESPONSE = DESCRIPTOR.message_types_by_name['GetClientMetricsResponse'] +TrackTaskMetricRequest = _reflection.GeneratedProtocolMessageType('TrackTaskMetricRequest', (_message.Message,), { + 'DESCRIPTOR' : _TRACKTASKMETRICREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackTaskMetricRequest) + }) +_sym_db.RegisterMessage(TrackTaskMetricRequest) + +TrackTaskMetricResponse = _reflection.GeneratedProtocolMessageType('TrackTaskMetricResponse', (_message.Message,), { + 'DESCRIPTOR' : _TRACKTASKMETRICRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackTaskMetricResponse) + }) +_sym_db.RegisterMessage(TrackTaskMetricResponse) + +TrackRoundMetricRequest = _reflection.GeneratedProtocolMessageType('TrackRoundMetricRequest', (_message.Message,), { + 'DESCRIPTOR' : _TRACKROUNDMETRICREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackRoundMetricRequest) + }) +_sym_db.RegisterMessage(TrackRoundMetricRequest) + +TrackRoundMetricResponse = _reflection.GeneratedProtocolMessageType('TrackRoundMetricResponse', (_message.Message,), { + 'DESCRIPTOR' : _TRACKROUNDMETRICRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackRoundMetricResponse) + }) +_sym_db.RegisterMessage(TrackRoundMetricResponse) + +TrackClientMetricRequest = _reflection.GeneratedProtocolMessageType('TrackClientMetricRequest', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTMETRICREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientMetricRequest) + }) +_sym_db.RegisterMessage(TrackClientMetricRequest) + +TrackClientMetricResponse = _reflection.GeneratedProtocolMessageType('TrackClientMetricResponse', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTMETRICRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientMetricResponse) + }) +_sym_db.RegisterMessage(TrackClientMetricResponse) + +TrackClientTrainMetricRequest = _reflection.GeneratedProtocolMessageType('TrackClientTrainMetricRequest', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTTRAINMETRICREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientTrainMetricRequest) + }) +_sym_db.RegisterMessage(TrackClientTrainMetricRequest) + +TrackClientTrainMetricResponse = _reflection.GeneratedProtocolMessageType('TrackClientTrainMetricResponse', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTTRAINMETRICRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientTrainMetricResponse) + }) +_sym_db.RegisterMessage(TrackClientTrainMetricResponse) + +TrackClientTestMetricRequest = _reflection.GeneratedProtocolMessageType('TrackClientTestMetricRequest', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTTESTMETRICREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientTestMetricRequest) + }) +_sym_db.RegisterMessage(TrackClientTestMetricRequest) + +TrackClientTestMetricResponse = _reflection.GeneratedProtocolMessageType('TrackClientTestMetricResponse', (_message.Message,), { + 'DESCRIPTOR' : _TRACKCLIENTTESTMETRICRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrackClientTestMetricResponse) + }) +_sym_db.RegisterMessage(TrackClientTestMetricResponse) + +GetRoundTrainTestTimeRequest = _reflection.GeneratedProtocolMessageType('GetRoundTrainTestTimeRequest', (_message.Message,), { + 'DESCRIPTOR' : _GETROUNDTRAINTESTTIMEREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetRoundTrainTestTimeRequest) + }) +_sym_db.RegisterMessage(GetRoundTrainTestTimeRequest) + +TrainTestTime = _reflection.GeneratedProtocolMessageType('TrainTestTime', (_message.Message,), { + 'DESCRIPTOR' : _TRAINTESTTIME, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.TrainTestTime) + }) +_sym_db.RegisterMessage(TrainTestTime) + +GetRoundTrainTestTimeResponse = _reflection.GeneratedProtocolMessageType('GetRoundTrainTestTimeResponse', (_message.Message,), { + 'DESCRIPTOR' : _GETROUNDTRAINTESTTIMERESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetRoundTrainTestTimeResponse) + }) +_sym_db.RegisterMessage(GetRoundTrainTestTimeResponse) + +GetRoundMetricsRequest = _reflection.GeneratedProtocolMessageType('GetRoundMetricsRequest', (_message.Message,), { + 'DESCRIPTOR' : _GETROUNDMETRICSREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetRoundMetricsRequest) + }) +_sym_db.RegisterMessage(GetRoundMetricsRequest) + +GetRoundMetricsResponse = _reflection.GeneratedProtocolMessageType('GetRoundMetricsResponse', (_message.Message,), { + 'DESCRIPTOR' : _GETROUNDMETRICSRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetRoundMetricsResponse) + }) +_sym_db.RegisterMessage(GetRoundMetricsResponse) + +GetClientMetricsRequest = _reflection.GeneratedProtocolMessageType('GetClientMetricsRequest', (_message.Message,), { + 'DESCRIPTOR' : _GETCLIENTMETRICSREQUEST, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetClientMetricsRequest) + }) +_sym_db.RegisterMessage(GetClientMetricsRequest) + +GetClientMetricsResponse = _reflection.GeneratedProtocolMessageType('GetClientMetricsResponse', (_message.Message,), { + 'DESCRIPTOR' : _GETCLIENTMETRICSRESPONSE, + '__module__' : 'easyfl.pb.tracking_service_pb2' + # @@protoc_insertion_point(class_scope:easyfl.pb.GetClientMetricsResponse) + }) +_sym_db.RegisterMessage(GetClientMetricsResponse) + +_TRACKINGSERVICE = DESCRIPTOR.services_by_name['TrackingService'] +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _TRACKTASKMETRICREQUEST._serialized_start=71 + _TRACKTASKMETRICREQUEST._serialized_end=139 + _TRACKTASKMETRICRESPONSE._serialized_start=141 + _TRACKTASKMETRICRESPONSE._serialized_end=201 + _TRACKROUNDMETRICREQUEST._serialized_start=203 + _TRACKROUNDMETRICREQUEST._serialized_end=274 + _TRACKROUNDMETRICRESPONSE._serialized_start=276 + _TRACKROUNDMETRICRESPONSE._serialized_end=337 + _TRACKCLIENTMETRICREQUEST._serialized_start=339 + _TRACKCLIENTMETRICREQUEST._serialized_end=414 + _TRACKCLIENTMETRICRESPONSE._serialized_start=416 + _TRACKCLIENTMETRICRESPONSE._serialized_end=478 + _TRACKCLIENTTRAINMETRICREQUEST._serialized_start=481 + _TRACKCLIENTTRAINMETRICREQUEST._serialized_end=689 + _TRACKCLIENTTRAINMETRICRESPONSE._serialized_start=691 + _TRACKCLIENTTRAINMETRICRESPONSE._serialized_end=758 + _TRACKCLIENTTESTMETRICREQUEST._serialized_start=761 + _TRACKCLIENTTESTMETRICREQUEST._serialized_end=960 + _TRACKCLIENTTESTMETRICRESPONSE._serialized_start=962 + _TRACKCLIENTTESTMETRICRESPONSE._serialized_end=1028 + _GETROUNDTRAINTESTTIMEREQUEST._serialized_start=1030 + _GETROUNDTRAINTESTTIMEREQUEST._serialized_end=1111 + _TRAINTESTTIME._serialized_start=1113 + _TRAINTESTTIME._serialized_end=1160 + _GETROUNDTRAINTESTTIMERESPONSE._serialized_start=1162 + _GETROUNDTRAINTESTTIMERESPONSE._serialized_end=1280 + _GETROUNDMETRICSREQUEST._serialized_start=1282 + _GETROUNDMETRICSREQUEST._serialized_end=1339 + _GETROUNDMETRICSRESPONSE._serialized_start=1342 + _GETROUNDMETRICSRESPONSE._serialized_end=1478 + _GETCLIENTMETRICSREQUEST._serialized_start=1480 + _GETCLIENTMETRICSREQUEST._serialized_end=1560 + _GETCLIENTMETRICSRESPONSE._serialized_start=1563 + _GETCLIENTMETRICSRESPONSE._serialized_end=1701 + _TRACKINGSERVICE._serialized_start=1704 + _TRACKINGSERVICE._serialized_end=2526 +# @@protoc_insertion_point(module_scope) diff --git a/easyfl/pb/tracking_service_pb2_grpc.py b/easyfl/pb/tracking_service_pb2_grpc.py new file mode 100644 index 0000000..6495eab --- /dev/null +++ b/easyfl/pb/tracking_service_pb2_grpc.py @@ -0,0 +1,297 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from easyfl.pb import tracking_service_pb2 as easyfl_dot_pb_dot_tracking__service__pb2 + + +class TrackingServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.TrackTaskMetric = channel.unary_unary( + '/easyfl.pb.TrackingService/TrackTaskMetric', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricResponse.FromString, + ) + self.TrackRoundMetric = channel.unary_unary( + '/easyfl.pb.TrackingService/TrackRoundMetric', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricResponse.FromString, + ) + self.TrackClientMetric = channel.unary_unary( + '/easyfl.pb.TrackingService/TrackClientMetric', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricResponse.FromString, + ) + self.TrackClientTrainMetric = channel.unary_unary( + '/easyfl.pb.TrackingService/TrackClientTrainMetric', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricResponse.FromString, + ) + self.TrackClientTestMetric = channel.unary_unary( + '/easyfl.pb.TrackingService/TrackClientTestMetric', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricResponse.FromString, + ) + self.GetRoundMetrics = channel.unary_unary( + '/easyfl.pb.TrackingService/GetRoundMetrics', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsResponse.FromString, + ) + self.GetClientMetrics = channel.unary_unary( + '/easyfl.pb.TrackingService/GetClientMetrics', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsResponse.FromString, + ) + self.GetRoundTrainTestTime = channel.unary_unary( + '/easyfl.pb.TrackingService/GetRoundTrainTestTime', + request_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeRequest.SerializeToString, + response_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeResponse.FromString, + ) + + +class TrackingServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def TrackTaskMetric(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def TrackRoundMetric(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def TrackClientMetric(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def TrackClientTrainMetric(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def TrackClientTestMetric(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetRoundMetrics(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetClientMetrics(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetRoundTrainTestTime(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_TrackingServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'TrackTaskMetric': grpc.unary_unary_rpc_method_handler( + servicer.TrackTaskMetric, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricResponse.SerializeToString, + ), + 'TrackRoundMetric': grpc.unary_unary_rpc_method_handler( + servicer.TrackRoundMetric, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricResponse.SerializeToString, + ), + 'TrackClientMetric': grpc.unary_unary_rpc_method_handler( + servicer.TrackClientMetric, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricResponse.SerializeToString, + ), + 'TrackClientTrainMetric': grpc.unary_unary_rpc_method_handler( + servicer.TrackClientTrainMetric, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricResponse.SerializeToString, + ), + 'TrackClientTestMetric': grpc.unary_unary_rpc_method_handler( + servicer.TrackClientTestMetric, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricResponse.SerializeToString, + ), + 'GetRoundMetrics': grpc.unary_unary_rpc_method_handler( + servicer.GetRoundMetrics, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsResponse.SerializeToString, + ), + 'GetClientMetrics': grpc.unary_unary_rpc_method_handler( + servicer.GetClientMetrics, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsResponse.SerializeToString, + ), + 'GetRoundTrainTestTime': grpc.unary_unary_rpc_method_handler( + servicer.GetRoundTrainTestTime, + request_deserializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeRequest.FromString, + response_serializer=easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'easyfl.pb.TrackingService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class TrackingService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def TrackTaskMetric(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/TrackTaskMetric', + easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.TrackTaskMetricResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def TrackRoundMetric(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/TrackRoundMetric', + easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.TrackRoundMetricResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def TrackClientMetric(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/TrackClientMetric', + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientMetricResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def TrackClientTrainMetric(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/TrackClientTrainMetric', + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTrainMetricResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def TrackClientTestMetric(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/TrackClientTestMetric', + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.TrackClientTestMetricResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetRoundMetrics(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/GetRoundMetrics', + easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.GetRoundMetricsResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetClientMetrics(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/GetClientMetrics', + easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.GetClientMetricsResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetRoundTrainTestTime(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/easyfl.pb.TrackingService/GetRoundTrainTestTime', + easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeRequest.SerializeToString, + easyfl_dot_pb_dot_tracking__service__pb2.GetRoundTrainTestTimeResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/easyfl/protocol/__init__.py b/easyfl/protocol/__init__.py new file mode 100644 index 0000000..25a7bb3 --- /dev/null +++ b/easyfl/protocol/__init__.py @@ -0,0 +1,3 @@ +from easyfl.protocol.codec import marshal, unmarshal + +__all__ = ['marshal', 'unmarshal'] diff --git a/easyfl/protocol/codec.py b/easyfl/protocol/codec.py new file mode 100644 index 0000000..16a8614 --- /dev/null +++ b/easyfl/protocol/codec.py @@ -0,0 +1,9 @@ +import pickle + + +def marshal(raw_data): + return pickle.dumps(raw_data) + + +def unmarshal(data): + return pickle.loads(data) diff --git a/easyfl/registor/Dockerfile b/easyfl/registor/Dockerfile new file mode 100644 index 0000000..bdeb177 --- /dev/null +++ b/easyfl/registor/Dockerfile @@ -0,0 +1,25 @@ +FROM ubuntu:16.04 +MAINTAINER Zhuang Weiming + +RUN apt update +RUN apt -y install software-properties-common +RUN add-apt-repository ppa:deadsnakes/ppa +RUN apt update +RUN apt install -y python3.7 +RUN apt-get update +RUN apt-get install -y wget python3-pip python3-dev libssl-dev libffi-dev bash + +RUN mkdir /app +WORKDIR /app + +RUN wget https://github.com/jwilder/docker-gen/releases/download/0.3.3/docker-gen-linux-amd64-0.3.3.tar.gz +RUN tar xvzf docker-gen-linux-amd64-0.3.3.tar.gz -C /usr/local/bin + +RUN pip3 install --upgrade pip setuptools wheel +RUN pip3 install etcd3 + +ADD . /app + +ENV DOCKER_HOST unix:///var/run/docker.sock + +CMD docker-gen -interval 10 -watch -notify "python3 /tmp/register.py" etcd.tmpl /tmp/register.py diff --git a/easyfl/registor/README.md b/easyfl/registor/README.md new file mode 100644 index 0000000..e6875b9 --- /dev/null +++ b/easyfl/registor/README.md @@ -0,0 +1,60 @@ +# Docker Register + +docker-register sets up a container running [docker-gen][1]. docker-gen dynamically generate a +python script when containers are started and stopped. This generated script registers the running +containers host IP and port in etcd with a TTL. It works in tandem with docker-discover which +generates haproxy routes on the host to forward requests to registered containers. + +Together, they implement [service discovery][2] for docker containers with a similar architecture +to [SmartStack][3]. docker-register is analagous to [nerve][4] in the SmartStack system. + +See also [Docker Service Discovery Using Etcd and Haproxy][5] + + +### Upgrade + +The original [docker-register](https://github.com/jwilder/docker-register) only supports etcd v2. + +It has been modified to support etcd v3. + +You can build the docker image with following command and follow the same usage. +``` +docker build -t docker-register -f Dockerfile . +``` + + +### Usage + +To run it: + + $ docker run -d -e HOST_IP=1.2.3.4 -e ETCD_HOST=1.2.3.4:4001 -v /var/run/docker.sock:/var/run/docker.sock -t jwilder/docker-register + +Then start any containers you want to be discoverable and publish their exposed port to the host. + + $ docker run -d -P -t ... + +If you run the container on multiple hosts, they will be grouped together automatically. + +### Limitations + +There are a few simplications that were made: + +* *Containers can only expose one port* - This is a simplification but if the container `EXPOSE`s +multiple ports, it won't be registered in etcd. +* *Exposed ports must be unique to the service* - Each container must expose it's service on a unique +port. For example, if you have two different backend web services and they both expose their service +over port 80, then one will need to use a port 80 and the other a different port. + + +[1]: https://github.com/jwilder/docker-gen +[2]: http://jasonwilder.com/blog/2014/02/04/service-discovery-in-the-cloud/ +[3]: http://nerds.airbnb.com/smartstack-service-discovery-cloud/ +[4]: https://github.com/airbnb/nerve +[5]: http://jasonwilder.com/blog/2014/07/15/docker-service-discovery/ + +### TODO + +* Support http, udp proxying +* Support multiple ports +* Make ETCD prefix configurable +* Support other backends (consul, zookeeper, redis, etc.) diff --git a/easyfl/registor/docker-register.service b/easyfl/registor/docker-register.service new file mode 100644 index 0000000..d170016 --- /dev/null +++ b/easyfl/registor/docker-register.service @@ -0,0 +1,15 @@ +[Unit] +Description=docker-register +After=docker.service + +[Service] +EnvironmentFile=/etc/environment +TimeoutStartSec=0 +ExecStartPre=-/usr/bin/docker kill docker-register +ExecStartPre=-/usr/bin/docker rm docker-register +ExecStartPre=/usr/bin/docker pull jwilder/docker-register +ExecStart=/usr/bin/docker run --name docker-register -e HOST_IP=${COREOS_PRIVATE_IPV4} -e ETCD_HOST=${COREOS_PRIVATE_IPV4}:4001 -v /var/run/docker.sock:/var/run/docker.sock jwilder/docker-register +ExecStop=/usr/bin/docker stop docker-register + +[X-Fleet] +Global=true diff --git a/easyfl/registor/etcd.tmpl b/easyfl/registor/etcd.tmpl new file mode 100644 index 0000000..324a2ad --- /dev/null +++ b/easyfl/registor/etcd.tmpl @@ -0,0 +1,37 @@ + +#!/usr/bin/python + +import etcd3 +import sys + +etcd_host = "{{ .Env.ETCD_HOST }}" +if not etcd_host: + print("ETCD_HOST not set") + sys.exit(1) + +port = 2379 +host = etcd_host + +if ":" in etcd_host: + host, port = etcd_host.split(":") + +print("{}:{}".format(host, port)) + +client = etcd3.client(host=host, port=int(port)) + +{{ $local := . }} +{{ range $key, $value := . }} + + {{ $addrLen := len $value.Addresses }} + + {{ if gt $addrLen 0 }} + + {{ with $address := index $value.Addresses 0 }} + {{ if $address.HostPort}} + +client.put("/backends/{{ $value.Image.Repository }}/{{ $local.Env.HOST_IP }}:{{ $address.HostPort }}", "{{ $value.Name }}") + + {{end}} + {{end}} + {{end}} +{{end}} diff --git a/easyfl/registry/__init__.py b/easyfl/registry/__init__.py new file mode 100644 index 0000000..40776e7 --- /dev/null +++ b/easyfl/registry/__init__.py @@ -0,0 +1,4 @@ +from easyfl.registry.registry import get_clients, SOURCES +from easyfl.registry.etcd_client import EtcdClient + +__all__ = ['get_clients', 'EtcdClient'] diff --git a/easyfl/registry/etcd_client.py b/easyfl/registry/etcd_client.py new file mode 100644 index 0000000..6f51c1c --- /dev/null +++ b/easyfl/registry/etcd_client.py @@ -0,0 +1,207 @@ +import random +import threading +from contextlib import contextmanager + +import etcd3 + +from easyfl.registry import mock_etcd +from easyfl.registry.vclient import VirtualClient + + +class EtcdClient(object): + """Etcd client to connect and communicate with etcd service. + Etcd is the serves as the registry for remote training. + Clients register themselves in etcd and server queries etcd to get client addresses. + + Args: + name (str): The name of etcd. + addrs (str): Etcd addresses, format: ":,:". + base_dir (str): The prefix of all etcd requests, default to "backends". + use_mock_etcd (bool): Whether use mocked etcd for testing. + """ + + ETCD_CLIENT_POOL_LOCK = threading.Lock() + ETCD_CLIENT_POOL = {} + ETCD_CLIENT_POOL_DESTROY = False + + class Event(object): + def __init__(self, event, base_dir): + self._event = event + self._base_dir = base_dir + + def __getattr__(self, attr): + return getattr(self._event, attr) + + @property + def key(self): + return EtcdClient.normalize_output_key(self._event.key, self._base_dir) + + def __init__(self, name, addrs, base_dir, use_mock_etcd=False): + self._name = name + self._base_dir = '/' + EtcdClient._normalize_input_key(base_dir) + self._addrs = self._normalize_addr(addrs) + if len(self._addrs) == 0: + raise ValueError('Empty hosts EtcdClient') + self._cur_addr_idx = random.randint(0, len(self._addrs) - 1) + self._use_mock_etcd = use_mock_etcd + + def get_data(self, key): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + return clnt.get(self._generate_path(key))[0] + + def set_data(self, key, data): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + clnt.put(self._generate_path(key), data) + + def delete(self, key): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + return clnt.delete(self._generate_path(key)) + + def delete_prefix(self, key): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + return clnt.delete_prefix(self._generate_path(key)) + + def cas(self, key, old_data, new_data): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + etcd_path = self._generate_path(key) + if old_data is None: + return clnt.put_if_not_exists(etcd_path, new_data) + return clnt.replace(etcd_path, old_data, new_data) + + def watch_key(self, key): + addr = self._get_next_addr() + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + notifier, cancel = clnt.watch(self._generate_path(key)) + + def prefix_extractor(notifier, base_dir): + while True: + try: + yield EtcdClient.Event(next(notifier), base_dir) + except StopIteration: + break + + return prefix_extractor(notifier, self._base_dir), cancel + + def get_prefix_kvs(self, prefix, ignore_prefix=False): + addr = self._get_next_addr() + kvs = [] + path = self._generate_path(prefix) + with EtcdClient.closing(self._name, addr, self._use_mock_etcd) as clnt: + for (data, key) in clnt.get_prefix(path, sort_order='ascend'): + if ignore_prefix and key.key == path.encode(): + continue + nkey = EtcdClient.normalize_output_key(key.key, self._base_dir) + kvs.append((nkey, data)) + return kvs + + def get_clients(self, prefix): + """Retrieve client addresses from etcd using prefix. + + Args: + prefix (str): the prefix of clients addresses; default is the docker image name "easyfl-client" + + Returns: + list[:obj:`VirtualClient`]: A list of clients. + """ + key_value_tuples = self.get_prefix_kvs(prefix) + clients = [] + index = 0 + for (key_byte, value_byte) in key_value_tuples: + key, value = key_byte.decode("utf-8"), value_byte.decode("utf-8") + parts = key.split("/") + if len(parts) <= 1: + continue + + addr = parts[1] + if not self._is_addr(addr): + continue + + clients.append(VirtualClient(value, addr, index)) + index += 1 + return clients + + def _is_addr(self, address): + return len(address.split(":")) > 1 + + def _generate_path(self, key): + return '/'.join([self._base_dir, self._normalize_input_key(key)]) + + def _get_next_addr(self): + return self._addrs[random.randint(0, len(self._addrs) - 1)] + + @staticmethod + def _normalize_addr(addrs): + naddrs = [] + for raw_addr in addrs.split(','): + (host, port_str) = raw_addr.split(':') + try: + port = int(port_str) + if port < 0 or port > 65535: + raise ValueError('port {} is out of range') + except ValueError: + raise ValueError('{} is not a valid port'.format(port_str)) + naddrs.append((host, port)) + return naddrs + + @staticmethod + def _normalize_input_key(key): + skip_cnt = 0 + while key[skip_cnt] == '.' or key[skip_cnt] == '/': + skip_cnt += 1 + if skip_cnt > 0: + return key[skip_cnt:] + return key + + @staticmethod + def normalize_output_key(key, base_dir): + if isinstance(base_dir, str): + assert key.startswith(base_dir.encode()) + else: + assert key.startswith(base_dir) + return key[len(base_dir) + 1:] + + @classmethod + @contextmanager + def closing(cls, name, addr, use_mock_etcd): + clnt = None + with cls.ETCD_CLIENT_POOL_LOCK: + if (name in cls.ETCD_CLIENT_POOL and + len(cls.ETCD_CLIENT_POOL[name]) > 0): + clnt = cls.ETCD_CLIENT_POOL[name][0] + cls.ETCD_CLIENT_POOL[name] = cls.ETCD_CLIENT_POOL[name][1:] + if clnt is None: + try: + if use_mock_etcd: + clnt = mock_etcd.MockEtcdClient(addr[0], addr[1]) + else: + clnt = etcd3.client(host=addr[0], port=addr[1]) + except Exception as e: + clnt.close() + raise e + try: + yield clnt + except Exception as e: + clnt.close() + raise e + else: + with cls.ETCD_CLIENT_POOL_LOCK: + if cls.ETCD_CLIENT_POOL_DESTROY: + clnt.close() + else: + if name not in cls.ETCD_CLIENT_POOL: + cls.ETCD_CLIENT_POOL[name] = [clnt] + else: + cls.ETCD_CLIENT_POOL[name].append(clnt) + + @classmethod + def destory_client_pool(cls): + with cls.ETCD_CLIENT_POOL_LOCK: + cls.ETCD_CLIENT_POOL_DESTROY = True + for _, clnts in cls.ETCD_CLIENT_POOL.items(): + for clnt in clnts: + clnt.close() diff --git a/easyfl/registry/k8s.py b/easyfl/registry/k8s.py new file mode 100644 index 0000000..04a2a55 --- /dev/null +++ b/easyfl/registry/k8s.py @@ -0,0 +1,29 @@ +from kubernetes import client, config +from easyfl.registry.vclient import VirtualClient + + +def get_clients(): + """Get clients in kubernetes based on client field selector. + + Returns: + list[:obj:`VirtualClient`]: A list of clients. + """ + config.load_kube_config() + + v1 = client.CoreV1Api() + + ret = v1.list_namespaced_endpoints('easyfl', watch=False, field_selector="metadata.name=easyfl-client-svc") + + clients = [] + for record in ret.items: + for subset in record.subsets: + port = subset.ports[0].port + for index, address in enumerate(subset.addresses): + addr = "{}:{}".format(address.ip, port) + c = VirtualClient(address.target_ref.name, addr, index) + clients.append(c) + return clients + + +if __name__ == '__main__': + get_clients() diff --git a/easyfl/registry/mock_etcd.py b/easyfl/registry/mock_etcd.py new file mode 100644 index 0000000..ed578c7 --- /dev/null +++ b/easyfl/registry/mock_etcd.py @@ -0,0 +1,155 @@ +import threading +try: + import queue +except ImportError: + import Queue as queue + + +class MockEtcd(object): + class KV(object): + def __init__(self, key, value): + self._key = key + self._value = value + + @property + def key(self): + if isinstance(self._key, str): + return self._key.encode() + return self._key + + @property + def value(self): + if isinstance(self._value, str): + return self._value.encode() + return self._value + + class EventNotifier(object): + def __init__(self, clnt): + self._queue = queue.Queue() + self._clnt = clnt + + def get_client_belongto(self): + return self._clnt + + def notify(self, key, value): + self._queue.put(MockEtcd.KV(key, value)) + + def wait_for_event(self): + while True: + event = self._queue.get() + if event is None: + return + yield event + + def cancel(self): + self._queue.put(None) + + def __init__(self): + self._lock = threading.Lock() + self._data = {} + self._event_notifier = {} + + def get(self, key): + with self._lock: + if key in self._data: + if isinstance(self._data[key], str): + return self._data[key].encode(), None + return self._data[key], None + return None, None + + def put(self, key, value): + with self._lock: + self._data[key] = value + self._notify_if_need(key) + + def delete(self, key): + with self._lock: + self._data.pop(key, None) + self._notify_if_need(key) + + def delete_prefix(self, prefix): + with self._lock: + deleted = [] + for key in self._data: + if key.startswith(prefix): + deleted.append(key) + for key in deleted: + self._data.pop(key, None) + self._notify_if_need(key) + + def put_if_not_exists(self, key, value): + with self._lock: + if key in self._data: + return False + self._data[key] = value + self._notify_if_need(key) + return True + + def replace(self, key, old_value, new_value): + with self._lock: + stored = None + if key in self._data: + stored = self._data[key] + if stored != old_value: + return False + self._data[key] = new_value + self._notify_if_need(key) + return True + + def watch(self, key, clnt): + with self._lock: + en = MockEtcd.EventNotifier(clnt) + if key not in self._event_notifier: + self._event_notifier[key] = [en] + else: + self._event_notifier[key].append(en) + return en.wait_for_event(), en.cancel + + def close(self, clnt): + with self._lock: + for key in self._event_notifier: + self._event_notifier[key] = [ + en for en in self._event_notifier[key] if + en.get_client_belongto() == clnt + ] + + def get_prefix(self, prefix, sort_order='ascend'): + kvs = [] + with self._lock: + for key, value in self._data.items(): + if key.startswith(prefix): + kvs.append((value.encode(), MockEtcd.KV(key, None))) + if sort_order == 'descend': + kvs = sorted(kvs, key=lambda kv: kv[1].key, reverse=True) + elif sort_order == 'ascend': + kvs = sorted(kvs, key=lambda kv: kv[1].key, reverse=False) + return kvs + + def _notify_if_need(self, key): + if key in self._event_notifier: + value = None + if key in self._data: + value = self._data[key] + for en in self._event_notifier[key]: + en.notify(key, value) + + +class MockEtcdClient(object): + POOL_LOCK = threading.Lock() + MOCK_ETCD_POOL = {} + + def __init__(self, host, port): + key = '{}:{}'.format(host, port) + with self.POOL_LOCK: + if key not in self.MOCK_ETCD_POOL: + self.MOCK_ETCD_POOL[key] = MockEtcd() + self._mock_etcd = self.MOCK_ETCD_POOL[key] + + def __getattr__(self, attr): + return getattr(self._mock_etcd, attr) + + def watch(self, key): + return self._mock_etcd.watch(key, self) + + def close(self): + self._mock_etcd.close(self) diff --git a/easyfl/registry/registry.py b/easyfl/registry/registry.py new file mode 100644 index 0000000..f8fc4fb --- /dev/null +++ b/easyfl/registry/registry.py @@ -0,0 +1,31 @@ +from easyfl.registry import etcd_client +from easyfl.registry import k8s +from easyfl.registry.vclient import VirtualClient + +SOURCE_MANUAL = "manual" +SOURCE_ETCD = "etcd" +SOURCE_K8S = "kubernetes" +SOURCES = [SOURCE_MANUAL, SOURCE_ETCD, SOURCE_K8S] + +CLIENT_DOCKER_IMAGE = "easyfl-client" + + +def get_clients(source, etcd_addresses=None): + """Get clients from registry. + + Args: + source (str): Registry source, options: manual, etcd, kubernetes. + etcd_addresses (str, optional): The addresses of etcd service. + Returns: + list[:obj:`VirtualClient`]: A list of clients with addresses. + """ + + if source == SOURCE_MANUAL: + return [VirtualClient("1", "localhost:23400", 0), VirtualClient("2", "localhost:23401", 1)] + elif source == SOURCE_ETCD: + etcd = etcd_client.EtcdClient("server", etcd_addresses, "backends") + return etcd.get_clients(CLIENT_DOCKER_IMAGE) + elif source == SOURCE_K8S: + return k8s.get_clients() + else: + raise ValueError("Not supported source type") diff --git a/easyfl/registry/vclient.py b/easyfl/registry/vclient.py new file mode 100644 index 0000000..6cb8b1e --- /dev/null +++ b/easyfl/registry/vclient.py @@ -0,0 +1,5 @@ +class VirtualClient(object): + def __init__(self, name, address, index): + self.id = name + self.address = address + self.index = index diff --git a/easyfl/server/__init__.py b/easyfl/server/__init__.py new file mode 100644 index 0000000..8f101cd --- /dev/null +++ b/easyfl/server/__init__.py @@ -0,0 +1,7 @@ +from easyfl.server.base import BaseServer +from easyfl.server.service import ServerService +from easyfl.server.strategies import federated_averaging, federated_averaging_only_params, \ + weighted_sum, weighted_sum_only_params + +__all__ = ['BaseServer', 'ServerService', 'federated_averaging', 'federated_averaging_only_params', + 'weighted_sum', 'weighted_sum_only_params'] diff --git a/easyfl/server/base.py b/easyfl/server/base.py new file mode 100644 index 0000000..faa9818 --- /dev/null +++ b/easyfl/server/base.py @@ -0,0 +1,917 @@ +import argparse +import concurrent.futures +import copy +import logging +import os +import threading +import time + +import numpy as np +import torch +import torch.distributed as dist +from omegaconf import OmegaConf + +from easyfl.communication import grpc_wrapper +from easyfl.datasets import TEST_IN_SERVER +from easyfl.distributed import grouping, reduce_models, reduce_models_only_params, \ + reduce_value, reduce_values, reduce_weighted_values, gather_value +from easyfl.distributed.distributed import CPU, GREEDY_GROUPING +from easyfl.pb import client_service_pb2 as client_pb +from easyfl.pb import common_pb2 as common_pb +from easyfl.protocol import codec +from easyfl.registry.etcd_client import EtcdClient +from easyfl.server import strategies +from easyfl.server.service import ServerService +from easyfl.tracking import metric +from easyfl.tracking.client import init_tracking +from easyfl.utils.float import rounding + +logger = logging.getLogger(__name__) + +# train and test params +MODEL = "model" +DATA_SIZE = "data_size" +ACCURACY = "accuracy" +LOSS = "loss" +CLIENT_METRICS = "client_metrics" + +FEDERATED_AVERAGE = "FedAvg" +EQUAL_AVERAGE = "equal" + +AGGREGATION_CONTENT_ALL = "all" +AGGREGATION_CONTENT_PARAMS = "parameters" + + +def create_argument_parser(): + """Create argument parser with arguments/configurations for starting server service. + + Returns: + argparse.ArgumentParser: The parser with server service arguments. + """ + parser = argparse.ArgumentParser(description='Federated Server') + parser.add_argument('--local-port', + type=int, + default=22999, + help='Listen port of the client') + parser.add_argument('--tracker-addr', + type=str, + default="localhost:12666", + help='Address of tracking service in [IP]:[PORT] format') + parser.add_argument('--is-remote', + type=bool, + default=False, + help='Whether start as a remote server.') + + return parser + + +class BaseServer(object): + """Default implementation of federated learning server. + + Args: + conf (omegaconf.dictconfig.DictConfig): Configurations of EasyFL. + test_data (:obj:`FederatedDataset`): Test dataset for centralized testing in server, optional. + val_data (:obj:`FederatedDataset`): Validation dataset for centralized validation in server, optional. + is_remote (bool): A flag to indicate whether start remote training. + local_port (int): The port of remote server service. + + Override the class and functions to implement customized server. + + Example: + >>> from easyfl.server import BaseServer + >>> class CustomizedServer(BaseServer): + >>> def __init__(self, conf, test_data=None, val_data=None, is_remote=False, local_port=22999): + >>> super(CustomizedServer, self).__init__(conf, test_data, val_data, is_remote, local_port) + >>> pass # more initialization of attributes. + >>> + >>> def aggregation(self): + >>> # Implement customized aggregation method, which overwrites the default aggregation method. + >>> pass + """ + + def __init__(self, + conf, + test_data=None, + val_data=None, + is_remote=False, + local_port=22999): + self.conf = conf + self.test_data = test_data + self.val_data = val_data + self.is_remote = is_remote + self.local_port = local_port + self._is_training = False + self._should_stop = False + + self._current_round = -1 + self._client_uploads = {} + self._model = None + self._compressed_model = None + self._clients = None + self._etcd = None + self.selected_clients = [] + self.grouped_clients = [] + + self._server_metric = None + self._round_time = None + self._begin_train_time = None # training begin time for a round + self._start_time = None # training start time for a task + self.client_stubs = {} + + if self.conf.is_distributed: + self.default_time = self.conf.resource_heterogeneous.initial_default_time + + self._cumulative_times = [] # cumulative training after each test + self._accuracies = [] + + self._condition = threading.Condition() + + self._tracker = None + self.init_tracker() + + def start(self, model, clients): + """Start federated learning process, including training and testing. + + Args: + model (nn.Module): The model to train. + clients (list[:obj:`BaseClient`]|list[str]): Available clients. + Clients are actually client grpc addresses when in remote training. + """ + # Setup + self._start_time = time.time() + self._reset() + self.set_model(model) + self.set_clients(clients) + + if self._should_track(): + self._tracker.create_task(self.conf.task_id, OmegaConf.to_container(self.conf)) + + # Get initial testing accuracies + if self.conf.server.test_all: + if self._should_track(): + self._tracker.set_round(self._current_round) + self.test() + self.save_tracker() + + while not self.should_stop(): + self._round_time = time.time() + + self._current_round += 1 + self.print_("\n-------- round {} --------".format(self._current_round)) + + # Train + self.pre_train() + self.train() + self.post_train() + + # Test + if self._do_every(self.conf.server.test_every, self._current_round, self.conf.server.rounds): + self.pre_test() + self.test() + self.post_test() + + # Save Model + self.save_model() + + self.track(metric.ROUND_TIME, time.time() - self._round_time) + self.save_tracker() + + self.print_("Accuracies: {}".format(rounding(self._accuracies, 4))) + self.print_("Cumulative training time: {}".format(rounding(self._cumulative_times, 2))) + + def stop(self): + """Set the flag to indicate training should stop.""" + self._should_stop = True + + def pre_train(self): + """Preprocessing before training.""" + pass + + def train(self): + """Training process of federated learning.""" + self.print_("--- start training ---") + + self.selection(self._clients, self.conf.server.clients_per_round) + self.grouping_for_distributed() + self.compression() + + begin_train_time = time.time() + + self.distribution_to_train() + self.aggregation() + + train_time = time.time() - begin_train_time + self.print_("Server train time: {}".format(train_time)) + self.track(metric.TRAIN_TIME, train_time) + + def post_train(self): + """Postprocessing after training.""" + pass + + def pre_test(self): + """Preprocessing before testing.""" + pass + + def test(self): + """Testing process of federated learning.""" + self.print_("--- start testing ---") + + test_begin_time = time.time() + + test_results = {metric.TEST_ACCURACY: 0, metric.TEST_LOSS: 0, metric.TEST_TIME: 0} + if self.conf.test_mode == TEST_IN_SERVER: + if self.is_primary_server(): + test_results = self.test_in_server(self.conf.device) + else: + test_results = self.test_in_client() + + test_results[metric.TEST_TIME] = time.time() - test_begin_time + self.track_test_results(test_results) + + def post_test(self): + """Postprocessing after testing.""" + pass + + def should_stop(self): + """Check whether should stop training. Stops the training under two conditions: + 1. Reach max number of training rounds + 2. TODO: Accuracy higher than certain amount. + + Returns: + bool: A flag to indicate whether should stop training. + """ + if self._should_stop or (self.conf.server.rounds and self._current_round + 1 >= self.conf.server.rounds): + self._is_training = False + return True + return False + + def test_in_client(self): + """Conduct testing in clients. + Currently, it supports testing on the selected clients for training. + TODO: Add optionals to select clients for testing. + + Returns: + dict: Test metrics, {"test_loss": value, "test_accuracy": value, "test_time": value}. + """ + self.compression() + self.distribution_to_test() + return self.aggregation_test() + + def test_in_server(self, device=CPU): + """Conduct testing in the server. + + Args: + device (str): The hardware device to conduct testing, either cpu or cuda devices. + + Returns: + dict: Test metrics, {"test_loss": value, "test_accuracy": value, "test_time": value}. + """ + self._model.eval() + self._model.to(device) + test_loss = 0 + correct = 0 + loss_fn = torch.nn.CrossEntropyLoss().to(device) + with torch.no_grad(): + for batched_x, batched_y in self.test_data.loader(self.conf.server.batch_size, seed=self.conf.seed): + x = batched_x.to(device) + y = batched_y.to(device) + log_probs = self._model(x) + loss = loss_fn(log_probs, y) + _, y_pred = torch.max(log_probs, -1) + correct += y_pred.eq(y.data.view_as(y_pred)).long().cpu().sum() + test_loss += loss.item() + test_data_size = self.test_data.size() + test_loss /= test_data_size + accuracy = 100.00 * correct / test_data_size + + test_results = { + metric.TEST_ACCURACY: float(accuracy), + metric.TEST_LOSS: float(test_loss) + } + return test_results + + def selection(self, clients, clients_per_round): + """Select a fraction of total clients for training. + Two selection strategies are implemented: 1. random selection; 2. select the first K clients. + + Args: + clients (list[:obj:`BaseClient`]|list[str]): Available clients. + clients_per_round (int): Number of clients to participate in training each round. + + Returns: + (list[:obj:`BaseClient`]|list[str]): The selected clients. + """ + if clients_per_round > len(clients): + logger.warning("Available clients for selection are smaller than required clients for each round") + + clients_per_round = min(clients_per_round, len(clients)) + if self.conf.server.random_selection: + np.random.seed(self._current_round) + self.selected_clients = np.random.choice(clients, clients_per_round, replace=False) + else: + self.selected_clients = clients[:clients_per_round] + + return self.selected_clients + + def grouping_for_distributed(self): + """Divide the selected clients into groups for distributed training. + Each group of clients is assigned to conduct training in one GPU. The number of groups = the number of gpus. + + Not in distributed training, selected clients are in the same group. + In distributed, selected clients are grouped with different strategies: greedy and random. + """ + if self.conf.is_distributed: + groups = grouping(self.selected_clients, + self.conf.distributed.world_size, + self.default_time, + self.conf.resource_heterogeneous.grouping_strategy, + self._current_round) + # assign a group for each rank to train with current device. + self.grouped_clients = groups[self.conf.distributed.rank] + grouping_info = [(c.cid, c.round_time) for c in self.grouped_clients] + logger.info("Grouping Result for rank {}: {}".format(self.conf.distributed.rank, grouping_info)) + else: + self.grouped_clients = self.selected_clients + + rank = 0 if len(self.grouped_clients) == len(self.selected_clients) else self.conf.distributed.rank + + def compression(self): + """Model compression to reduce communication cost.""" + self._compressed_model = self._model + + def distribution_to_train(self): + """Distribute model and configurations to selected clients to train.""" + if self.is_remote: + self.distribution_to_train_remotely() + else: + self.distribution_to_train_locally() + + # Adaptively update the training time of clients for greedy grouping. + if self.conf.is_distributed and self.conf.resource_heterogeneous.grouping_strategy == GREEDY_GROUPING: + self.profile_training_speed() + self.update_default_time() + + def distribution_to_train_locally(self): + """Conduct training sequentially for selected clients in the group.""" + uploaded_models = {} + uploaded_weights = {} + uploaded_metrics = [] + for client in self.grouped_clients: + # Update client config before training + self.conf.client.task_id = self.conf.task_id + self.conf.client.round_id = self._current_round + + uploaded_request = client.run_train(self._compressed_model, self.conf.client) + uploaded_content = uploaded_request.content + + model = self.decompression(codec.unmarshal(uploaded_content.data)) + uploaded_models[client.cid] = model + uploaded_weights[client.cid] = uploaded_content.data_size + uploaded_metrics.append(metric.ClientMetric.from_proto(uploaded_content.metric)) + + self.set_client_uploads_train(uploaded_models, uploaded_weights, uploaded_metrics) + + def distribution_to_train_remotely(self): + """Distribute training requests to remote clients through multiple threads. + The main thread waits for signal to proceed. The signal can be triggered via notification, as below example. + + Example to trigger signal: + >>> with self.condition(): + >>> self.notify_all() + """ + start_time = time.time() + should_track = self._tracker is not None and self.conf.client.track + with concurrent.futures.ThreadPoolExecutor() as executor: + for client in self.grouped_clients: + request = client_pb.OperateRequest( + type=client_pb.OP_TYPE_TRAIN, + model=codec.marshal(self._compressed_model), + data_index=client.index, + config=client_pb.OperateConfig( + batch_size=self.conf.client.batch_size, + local_epoch=self.conf.client.local_epoch, + seed=self.conf.seed, + local_test=self.conf.client.local_test, + optimizer=client_pb.Optimizer( + type=self.conf.client.optimizer.type, + lr=self.conf.client.optimizer.lr, + momentum=self.conf.client.optimizer.momentum, + ), + task_id=self.conf.task_id, + round_id=self._current_round, + track=should_track, + ), + ) + executor.submit(self._distribution_remotely, client.client_id, request) + + distribute_time = time.time() - start_time + self.track(metric.TRAIN_DISTRIBUTE_TIME, distribute_time) + logger.info("Distribute to clients, time: {}".format(distribute_time)) + with self._condition: + self._condition.wait() + + def distribution_to_test(self): + """Distribute to conduct testing on clients.""" + if self.is_remote: + self.distribution_to_test_remotely() + else: + self.distribution_to_test_locally() + + def distribution_to_test_locally(self): + """Conduct testing sequentially for selected testing clients.""" + uploaded_accuracies = [] + uploaded_losses = [] + uploaded_data_sizes = [] + uploaded_metrics = [] + + test_clients = self.get_test_clients() + for client in test_clients: + # Update client config before testing + self.conf.client.task_id = self.conf.task_id + self.conf.client.round_id = self._current_round + + uploaded_request = client.run_test(self._compressed_model, self.conf.client) + uploaded_content = uploaded_request.content + performance = codec.unmarshal(uploaded_content.data) + + uploaded_accuracies.append(performance.accuracy) + uploaded_losses.append(performance.loss) + uploaded_data_sizes.append(uploaded_content.data_size) + uploaded_metrics.append(metric.ClientMetric.from_proto(uploaded_content.metric)) + + self.set_client_uploads_test(uploaded_accuracies, uploaded_losses, uploaded_data_sizes, uploaded_metrics) + + def distribution_to_test_remotely(self): + """Distribute testing requests to remote clients through multiple threads. + The main thread waits for signal to proceed. The signal can be triggered via notification, as below example. + + Example to trigger signal: + >>> with self.condition(): + >>> self.notify_all() + """ + start_time = time.time() + should_track = self._tracker is not None and self.conf.client.track + test_clients = self.get_test_clients() + with concurrent.futures.ThreadPoolExecutor() as executor: + for client in test_clients: + request = client_pb.OperateRequest( + type=client_pb.OP_TYPE_TEST, + model=codec.marshal(self._compressed_model), + data_index=client.index, + config=client_pb.OperateConfig( + batch_size=self.conf.client.batch_size, + test_batch_size=self.conf.client.test_batch_size, + seed=self.conf.seed, + task_id=self.conf.task_id, + round_id=self._current_round, + track=should_track, + ) + ) + executor.submit(self._distribution_remotely, client.client_id, request) + + distribute_time = time.time() - start_time + self.track(metric.TEST_DISTRIBUTE_TIME, distribute_time) + logger.info("Distribute to test clients, time: {}".format(distribute_time)) + with self._condition: + self._condition.wait() + + def get_test_clients(self): + """Get clients to run testing. + + Returns: + (list[:obj:`BaseClient`]|list[str]): Clients to test. + """ + if self.conf.server.test_all: + if self.conf.is_distributed: + # Group and assign clients to different hardware devices to test. + test_clients = grouping(self._clients, + self.conf.distributed.world_size, + default_time=self.default_time, + strategy=self.conf.resource_heterogeneous.grouping_strategy) + test_clients = test_clients[self.conf.distributed.rank] + else: + test_clients = self._clients + else: + # For the initial testing, if no clients are selected, test all clients + test_clients = self.grouped_clients if self.grouped_clients is not None else self._clients + return test_clients + + def _distribution_remotely(self, cid, request): + """Distribute request to the assigned client to conduct operations. + + Args: + cid (str): Client id. + request (:obj:`OperateRequest`): gRPC request of specific operations. + """ + resp = self.client_stubs[cid].Operate(request) + if resp.status.code != common_pb.SC_OK: + logger.error("Failed to train/test in client {}, error: {}".format(cid, resp.status.message)) + else: + logger.info("Distribute to train/test remotely successfully, client: {}".format(cid)) + + def aggregation_test(self): + """Aggregate testing results from clients. + + Returns: + dict: Test metrics, format in {"test_loss": value, "test_accuracy": value} + """ + accuracies = self._client_uploads[ACCURACY] + losses = self._client_uploads[LOSS] + test_sizes = self._client_uploads[DATA_SIZE] + + if self.conf.test_method == "average": + loss = self._mean_value(losses) + accuracy = self._mean_value(accuracies) + elif self.conf.test_method == "weighted": + loss = self._weighted_value(losses, test_sizes) + accuracy = self._weighted_value(accuracies, test_sizes) + else: + raise ValueError("test_method not supported, please use average or weighted") + + test_results = { + metric.TEST_ACCURACY: float(accuracy), + metric.TEST_LOSS: float(loss) + } + return test_results + + def _mean_value(self, values): + if self.conf.is_distributed: + return reduce_values(values, self.conf.device) + else: + return np.mean(values) + + def _weighted_value(self, values, weights): + if self.conf.is_distributed: + return reduce_weighted_values(values, weights, self.conf.device) + else: + return np.average(values, weights=weights) + + def decompression(self, model): + """Decompression the models from clients""" + return model + + def aggregation(self): + """Aggregate training updates from clients. + Server aggregates trained models from clients via federated averaging. + """ + uploaded_content = self.get_client_uploads() + models = list(uploaded_content[MODEL].values()) + weights = list(uploaded_content[DATA_SIZE].values()) + + model = self.aggregate(models, weights) + self.set_model(model, load_dict=True) + + def aggregate(self, models, weights): + """Aggregate models uploaded from clients via federated averaging. + + Args: + models (list[nn.Module]): List of models. + weights (list[float]): List of weights, corresponding to each model. + Weights are dataset size of clients by default. + Returns + nn.Module: Aggregated model. + """ + if self.conf.server.aggregation_strategy == EQUAL_AVERAGE: + weights = [1 for _ in range(len(models))] + + fn_average = strategies.federated_averaging + fn_sum = strategies.weighted_sum + fn_reduce = reduce_models + if self.conf.server.aggregation_content == AGGREGATION_CONTENT_PARAMS: + fn_average = strategies.federated_averaging_only_params + fn_sum = strategies.weighted_sum_only_params + fn_reduce = reduce_models_only_params + + if self.conf.is_distributed: + dist.barrier() + model, sample_sum = fn_sum(models, weights) + fn_reduce(model, torch.tensor(sample_sum).to(self.conf.device)) + else: + model = fn_average(models, weights) + return model + + def _reset(self): + self._current_round = -1 + self._should_stop = False + self._is_training = True + + def is_training(self): + """Check whether the server is in training or has stopped training. + + Returns: + bool: A flag to indicate whether server is in training. + """ + return self._is_training + + def set_model(self, model, load_dict=False): + """Update the universal model in the server. + + Args: + model (nn.Module): New model. + load_dict (bool): A flag to indicate whether load state dict or copy the model. + """ + if load_dict: + self._model.load_state_dict(model.state_dict()) + else: + self._model = copy.deepcopy(model) + + def set_clients(self, clients): + self._clients = clients + + def num_of_clients(self): + return len(self._clients) + + def save_model(self): + """Save the model in the server.""" + if self._do_every(self.conf.server.save_model_every, self._current_round, self.conf.server.rounds) and \ + self.is_primary_server(): + save_path = self.conf.server.save_model_path + if save_path == "": + save_path = os.path.join(os.getcwd(), "saved_models") + os.makedirs(save_path, exist_ok=True) + save_path = os.path.join(save_path, + "{}_global_model_r_{}.pth".format(self.conf.task_id, self._current_round)) + torch.save(self._model.cpu().state_dict(), save_path) + self.print_("Model saved at {}".format(save_path)) + + def set_client_uploads_train(self, models, weights, metrics=None): + """Set training updates uploaded from clients. + + Args: + models (dict): A collection of models. + weights (dict): A collection of weights. + metrics (dict): Client training metrics. + """ + self.set_client_uploads(MODEL, models) + self.set_client_uploads(DATA_SIZE, weights) + if self._should_gather_metrics(): + metrics = self.gather_client_train_metrics() + self.set_client_uploads(CLIENT_METRICS, metrics) + + def set_client_uploads_test(self, accuracies, losses, test_sizes, metrics=None): + """Set testing results uploaded from clients. + + Args: + accuracies (list[float]): Testing accuracies of clients. + losses (list[float]): Testing losses of clients. + test_sizes (list[float]): Test dataset sizes of clients. + metrics (dict): Client testing metrics. + """ + self.set_client_uploads(ACCURACY, accuracies) + self.set_client_uploads(LOSS, losses) + self.set_client_uploads(DATA_SIZE, test_sizes) + if self._should_gather_metrics() and CLIENT_METRICS in self._client_uploads: + train_metrics = self.get_client_uploads()[CLIENT_METRICS] + metrics = metric.ClientMetric.merge_train_to_test_metrics(train_metrics, metrics) + self.set_client_uploads(CLIENT_METRICS, metrics) + + def set_client_uploads(self, key, value): + """A general function to set uploaded content from clients. + + Args: + key (str): Dictionary key. + value (*): Uploaded content. + """ + self._client_uploads[key] = value + + def get_client_uploads(self): + """Get client uploaded contents. + + Returns: + dict: A dictionary that contains client uploaded contents. + """ + return self._client_uploads + + def _do_every(self, every, current_round, rounds): + return (current_round + 1) % every == 0 or (current_round + 1) == rounds + + def print_(self, content): + """print only the server is primary server. + + Args: + content (str): The content to log. + """ + if self.is_primary_server(): + logger.info(content) + + def is_primary_server(self): + """Check whether the current process is the primary server. + In standalone or remote training, the server is primary. + In distributed training, the server on rank0 is primary. + + Returns: + bool: A flag to indicate whether current process is the primary server. + """ + return not self.conf.is_distributed or self.conf.distributed.rank == 0 + + # Functions for remote training + + def start_service(self): + """Start federated learning server GRPC service.""" + if self.is_remote: + grpc_wrapper.start_service(grpc_wrapper.TYPE_SERVER, ServerService(self), self.local_port) + logger.info("GRPC server started at :{}".format(self.local_port)) + + def connect_remote_clients(self, clients): + # TODO: This client should be consistent with client started separately. + for client in clients: + if client.client_id not in self.client_stubs: + self.client_stubs[client.client_id] = grpc_wrapper.init_stub(grpc_wrapper.TYPE_CLIENT, client.address) + logger.info("Successfully connected to gRPC client {}".format(client.address)) + + def init_etcd(self, addresses): + """Initialize etcd as the registry for client registration. + + Args: + addresses (str): The etcd addresses split by "," + """ + self._etcd = EtcdClient("server", addresses, "backends") + + def start_remote_training(self, model, clients): + """Start federated learning in the remote training mode. + Server establishes gPRC connection with clients that are not connected first before training. + + Args: + model (nn.Module): The model to train. + clients (list[str]): Client addresses. + """ + self.connect_remote_clients(clients) + self.start(model, clients) + + # Functions for tracking + + def init_tracker(self): + """Initialize tracking""" + if self.conf.server.track: + self._tracker = init_tracking(self.conf.tracking.database, self.conf.tracker_addr) + + def track(self, metric_name, value): + """Track a metric. + + Args: + metric_name (str): Name of the metric of a round. + value (str|int|float|bool|dict|list): Value of the metric. + """ + if not self._should_track(): + return + self._tracker.track_round(metric_name, value) + + def track_test_results(self, results): + """Track test results collected from clients. + + Args: + results (dict): Test metrics, format in {"test_loss": value, "test_accuracy": value, "test_time": value} + """ + self._cumulative_times.append(time.time() - self._start_time) + self._accuracies.append(results[metric.TEST_ACCURACY]) + + for metric_name in results: + self.track(metric_name, results[metric_name]) + + self.print_('Test time {:.2f}s, Test loss: {:.2f}, Test accuracy: {:.2f}%'.format( + results[metric.TEST_TIME], results[metric.TEST_LOSS], results[metric.TEST_ACCURACY])) + + def save_tracker(self): + """Save metrics in the tracker to database.""" + if self._tracker: + self.track_communication_cost() + if self.is_primary_server(): + self._tracker.save_round() + # In distributed training, each server saves their clients separately. + self._tracker.save_clients(self._client_uploads[CLIENT_METRICS]) + + def track_communication_cost(self): + """Track communication cost among server and clients. + Communication cost occurs in `training` and `testing` with downlink and uplink costs. + """ + train_upload_size = 0 + train_download_size = 0 + test_upload_size = 0 + test_download_size = 0 + for client_metric in self._client_uploads[CLIENT_METRICS]: + if client_metric.round_id == self._current_round and client_metric.task_id == self.conf.task_id: + train_upload_size += client_metric.train_upload_size + train_download_size += client_metric.train_download_size + test_upload_size += client_metric.test_upload_size + test_download_size += client_metric.test_download_size + if self.conf.is_distributed: + train_upload_size = reduce_value(train_upload_size, self.conf.device).item() + train_download_size = reduce_value(train_download_size, self.conf.device).item() + test_upload_size = reduce_value(test_upload_size, self.conf.device).item() + test_download_size = reduce_value(test_download_size, self.conf.device).item() + self._tracker.track_round(metric.TRAIN_UPLOAD_SIZE, train_upload_size) + self._tracker.track_round(metric.TRAIN_DOWNLOAD_SIZE, train_download_size) + self._tracker.track_round(metric.TEST_UPLOAD_SIZE, test_upload_size) + self._tracker.track_round(metric.TEST_DOWNLOAD_SIZE, test_download_size) + + def _should_track(self): + """Check whether server should track metrics. + Server tracks metrics only when tracking is enabled and it is the primary server. + + Returns: + bool: A flag indicate whether server should track metrics. + """ + return self._tracker is not None and self.is_primary_server() + + def _should_gather_metrics(self): + """Check whether the server should gather metrics from GPUs. + Gather metrics only when testing all in `distributed` training. + + Testing all resets clients' training metrics, thus, + server needs to gather train metrics to construct full client metrics. + + Returns: + bool: A flag indicate whether server should gather metrics. + """ + return self.conf.is_distributed and self.conf.server.test_all and self._tracker + + def gather_client_train_metrics(self): + """Gather client train metrics from other ranks for distributed training, when testing all clients (test_all). + When testing all clients, the trained metrics may be override by the test metrics + because clients may be placed in different GPUs in training and testing, leading to losses of train metrics. + So we gather train metrics and set them in test metrics. + TODO: gather is not progressing. Need fix. + """ + world_size = self.conf.distributed.world_size + device = self.conf.device + uploads = self.get_client_uploads() + client_id_list = [] + train_accuracy_list = [] + train_loss_list = [] + train_time_list = [] + train_upload_time_list = [] + train_upload_size_list = [] + train_download_size_list = [] + + for m in uploads[CLIENT_METRICS]: + # client_id_list += gather_value(m.client_id, world_size, device).tolist() + train_accuracy_list += gather_value(m.train_accuracy, world_size, device) + train_loss_list += gather_value(m.train_loss, world_size, device) + train_time_list += gather_value(m.train_time, world_size, device) + train_upload_time_list += gather_value(m.train_upload_time, world_size, device) + train_upload_size_list += gather_value(m.train_upload_size, world_size, device) + train_download_size_list += gather_value(m.train_download_size, world_size, device) + metrics = [] + # Note: Client id may not match with its training stats because all_gather string is not supported. + client_id_list = [c.cid for c in self.selected_clients] + for i, client_id in enumerate(client_id_list): + m = metric.ClientMetric(self.conf.task_id, self._current_round, client_id) + m.add(metric.TRAIN_ACCURACY, train_accuracy_list[i]) + m.add(metric.TRAIN_LOSS, train_loss_list[i]) + m.add(metric.TRAIN_TIME, train_time_list[i]) + m.add(metric.TRAIN_UPLOAD_TIME, train_upload_time_list[i]) + m.add(metric.TRAIN_UPLOAD_SIZE, train_upload_size_list[i]) + m.add(metric.TRAIN_DOWNLOAD_SIZE, train_download_size_list[i]) + metrics.append(m) + return metrics + + # Functions for remote training. + + def condition(self): + return self._condition + + def notify_all(self): + self._condition.notify_all() + + # Functions for distributed training optimization. + + def profile_training_speed(self): + """Manage profiling of client training speeds for distributed training optimization.""" + profile_required = [] + for client in self.selected_clients: + if not client.profiled: + profile_required.append(client) + if len(profile_required) > 0: + original = torch.FloatTensor([c.round_time for c in profile_required]).to(self.conf.device) + time_update = torch.FloatTensor([c.train_time for c in profile_required]).to(self.conf.device) + dist.barrier() + dist.all_reduce(time_update) + for i in range(len(profile_required)): + old_round_time = original[i] + current_round_time = time_update[i] + if old_round_time == 0 or self._should_update_round_time(old_round_time, current_round_time): + profile_required[i].round_time = float(current_round_time) + profile_required[i].train_time = 0 + else: + profile_required[i].profiled = True + + def update_default_time(self): + """Update the estimated default training time of clients using actual training time from profiled clients.""" + default_momentum = self.conf.resource_heterogeneous.default_time_momentum + current_round_average = np.mean([float(c.round_time) for c in self.selected_clients]) + self.default_time = default_momentum * current_round_average + self.default_time * (1 - default_momentum) + + def _should_update_round_time(self, old_round_time, new_round_time, threshold=0.3): + """Check whether assign a new estimated round time to client or set it to profiled. + + Args: + old_round_time (float): previous estimated round time. + new_round_time (float): Currently profiled round time. + threshold (float): Tolerance threshold of difference between old and new times. + Returns: + bool: A flag to indicate whether should update round time. + """ + if new_round_time < old_round_time: + return ((old_round_time - new_round_time) / new_round_time) >= threshold + else: + return ((new_round_time - old_round_time) / old_round_time) >= threshold diff --git a/easyfl/server/service.py b/easyfl/server/service.py new file mode 100644 index 0000000..cedd203 --- /dev/null +++ b/easyfl/server/service.py @@ -0,0 +1,143 @@ +import logging +import threading + +from easyfl.pb import server_service_pb2_grpc as server_grpc, server_service_pb2 as server_pb, common_pb2 as common_pb +from easyfl.protocol import codec +from easyfl.tracking import metric + +logger = logging.getLogger(__name__) + + +class ServerService(server_grpc.ServerServiceServicer): + """"Remote gRPC server service. + + Args: + server (:obj:`BaseServer`): Federated learning server instance. + """ + def __init__(self, server): + self._base = server + + self._clients_per_round = 0 + + self._train_client_count = 0 + self._uploaded_models = {} + self._uploaded_weights = {} + self._uploaded_metrics = [] + + self._test_client_count = 0 + self._accuracies = [] + self._losses = [] + self._test_sizes = [] + + def Run(self, request, context): + """Trigger federated learning process.""" + response = server_pb.RunResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + + if self._base.is_training(): + response = server_pb.RunResponse( + status=common_pb.Status( + code=common_pb.SC_ALREADY_EXISTS, + message="Training in progress, please stop current training or wait for completion", + ), + ) + else: + model = codec.unmarshal(request.model) + self._base.start_remote_training(model, request.clients) + + return response + + def Stop(self, request, context): + """Stop federated learning process.""" + response = server_pb.StopResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + + if self._base.is_training(): + self._base.stop() + else: + response = server_pb.RunResponse( + status=common_pb.Status( + code=common_pb.SC_NOT_FOUND, + message="No existing training", + ), + ) + return response + + def Upload(self, request, context): + """Handle upload from clients.""" + # TODO: put train and test logic in a separate thread and add thread lock to ensure atomicity. + t = threading.Thread(target=self._handle_upload, args=[request, context]) + t.start() + response = server_pb.UploadResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + return response + + def _handle_upload(self, request, context): + # if not self._base.upload_event.is_set(): + data = codec.unmarshal(request.content.data) + data_size = request.content.data_size + client_metric = metric.ClientMetric.from_proto(request.content.metric) + + clients_per_round = self._base.conf.server.clients_per_round + num_of_clients = self._base.num_of_clients() + if num_of_clients < clients_per_round: + # TODO: use a more appropriate way to handle this situation + logger.warning( + "Available number of clients {} is smaller than clients per round {}".format(num_of_clients, + clients_per_round)) + self._clients_per_round = num_of_clients + else: + self._clients_per_round = clients_per_round + + if request.content.type == common_pb.DATA_TYPE_PARAMS: + self._handle_upload_train(request.client_id, data, data_size, client_metric) + elif request.content.type == common_pb.DATA_TYPE_PERFORMANCE: + self._handle_upload_test(data, data_size, client_metric) + + def _handle_upload_train(self, client_id, data, data_size, client_metric): + model = self._base.decompression(data) + self._uploaded_models[client_id] = model + self._uploaded_weights[client_id] = data_size + self._uploaded_metrics.append(client_metric) + self._train_client_count += 1 + self._trigger_aggregate_train() + + def _handle_upload_test(self, data, data_size, client_metric): + self._accuracies.append(data.accuracy) + self._losses.append(data.loss) + self._test_sizes.append(data_size) + self._uploaded_metrics.append(client_metric) + self._test_client_count += 1 + self._trigger_aggregate_test() + + def _trigger_aggregate_train(self): + logger.info("train_client_count: {}/{}".format(self._train_client_count, self._clients_per_round)) + if self._train_client_count == self._clients_per_round: + self._base.set_client_uploads_train(self._uploaded_models, self._uploaded_weights, self._uploaded_metrics) + self._train_client_count = 0 + self._reset_train_cache() + with self._base.condition(): + self._base.notify_all() + + def _trigger_aggregate_test(self): + # TODO: determine the testing clients not only by the selected number of clients + if self._test_client_count == self._clients_per_round: + self._base.set_client_uploads_test(self._accuracies, self._losses, self._test_sizes, self._uploaded_metrics) + self._test_client_count = 0 + self._reset_test_cache() + with self._base.condition(): + self._base.notify_all() + + def _reset_train_cache(self): + self._uploaded_models = {} + self._uploaded_weights = {} + self._uploaded_metrics = [] + + def _reset_test_cache(self): + self._accuracies = [] + self._losses = [] + self._test_sizes = [] + self._uploaded_metrics = [] diff --git a/easyfl/server/strategies.py b/easyfl/server/strategies.py new file mode 100644 index 0000000..a1898ec --- /dev/null +++ b/easyfl/server/strategies.py @@ -0,0 +1,119 @@ +import copy + +import torch + + +def federated_averaging(models, weights): + """Compute weighted average of model parameters and persistent buffers. + Using state_dict of model, including persistent buffers like BN stats. + + Args: + models (list[nn.Module]): List of models to average. + weights (list[float]): List of weights, corresponding to each model. + Weights are dataset size of clients by default. + Returns + nn.Module: Weighted averaged model. + """ + if models == [] or weights == []: + return None + + model, total_weights = weighted_sum(models, weights) + model_params = model.state_dict() + with torch.no_grad(): + for name, params in model_params.items(): + model_params[name] = torch.div(params, total_weights) + model.load_state_dict(model_params) + return model + + +def federated_averaging_only_params(models, weights): + """Compute weighted average of model parameters. Use model parameters only. + + Args: + models (list[nn.Module]): List of models to average. + weights (list[float]): List of weights, corresponding to each model. + Weights are dataset size of clients by default. + Returns + nn.Module: Weighted averaged model. + """ + if models == [] or weights == []: + return None + + model, total_weights = weighted_sum_only_params(models, weights) + model_params = dict(model.named_parameters()) + with torch.no_grad(): + for name, params in model_params.items(): + model_params[name].set_(model_params[name] / total_weights) + + return model + + +def weighted_sum(models, weights): + """Compute weighted sum of model parameters and persistent buffers. + Using state_dict of model, including persistent buffers like BN stats. + + Args: + models (list[nn.Module]): List of models to average. + weights (list[float]): List of weights, corresponding to each model. + Weights are dataset size of clients by default. + Returns + nn.Module: Weighted averaged model. + float: Sum of weights. + """ + if models == [] or weights == []: + return None + model = copy.deepcopy(models[0]) + model_sum_params = copy.deepcopy(models[0].state_dict()) + + with torch.no_grad(): + for name, params in model_sum_params.items(): + params *= weights[0] + for i in range(1, len(models)): + model_params = dict(models[i].state_dict()) + params += model_params[name] * weights[i] + model_sum_params[name] = params + model.load_state_dict(model_sum_params) + return model, sum(weights) + + +def weighted_sum_only_params(models, weights): + """Compute weighted sum of model parameters. Use model parameters only. + + Args: + models (list[nn.Module]): List of models to average. + weights (list[float]): List of weights, corresponding to each model. + Weights are dataset size of clients by default. + Returns + nn.Module: Weighted averaged model. + float: Sum of weights. + """ + if models == [] or weights == []: + return None + + model_sum = copy.deepcopy(models[0]) + model_sum_params = dict(model_sum.named_parameters()) + + with torch.no_grad(): + for name, params in model_sum_params.items(): + params *= weights[0] + for i in range(1, len(models)): + model_params = dict(models[i].named_parameters()) + params += model_params[name] * weights[i] + model_sum_params[name].set_(params) + return model_sum, sum(weights) + + +def equal_weight_averaging(models): + if models == []: + return None + + model_avg = copy.deepcopy(models[0]) + model_avg_params = dict(model_avg.named_parameters()) + + with torch.no_grad(): + for name, params in model_avg_params.items(): + for i in range(1, len(models)): + model_params = dict(models[i].named_parameters()) + params += model_params[name] + model_avg_params[name].set_(params / len(models)) + return model_avg diff --git a/easyfl/service.py b/easyfl/service.py new file mode 100644 index 0000000..edc528b --- /dev/null +++ b/easyfl/service.py @@ -0,0 +1,71 @@ +import os + +import easyfl +from easyfl.client import base as client_base +from easyfl.server import base as server_base + + +def start_remote_client(conf=None, train_data=None, test_data=None, model=None, client=None): + """Start a remote client. + + Args: + conf (dict): Configurations. optional, Use the configuration loaded from file if not provided. It overwrites the + configurations from file. + train_data (:obj:`FederatedDataset`): Training dataset. + test_data (:obj:`FederatedDataset`): Testing dataset. + model (nn.Module): Model used in client training. + client (:obj:`BaseClient`): Customized federated learning client class. + """ + parser = client_base.create_argument_parser() + parser.add_argument('--index', type=int, default=0, help='Client index for quick testing') + parser.add_argument('--config', type=str, default="client_config.yaml", help='Client config file') + args = parser.parse_args() + + if os.path.isfile(args.config): + conf = easyfl.load_config(args.config, conf) + + if train_data and test_data: + easyfl.register_dataset(train_data, test_data) + elif train_data: + easyfl.register_dataset(train_data, None) + elif test_data: + easyfl.register_dataset(None, test_data) + + if model: + easyfl.register_model(model) + + if client: + easyfl.register_client(client) + + easyfl.init(conf, init_all=False) + easyfl.start_client(args) + + +def start_remote_server(conf=None, test_data=None, model=None, server=None): + """Start a remote server. + + Args: + conf (dict): Configurations. optional, Use the configuration loaded from file if not provided. It overwrites the + configurations from file. + test_data (:obj:`FederatedDataset`): Test dataset for centralized testing on server. + model (nn.Module): Model used in client training. + server (:obj:`BaseServer`): Customized federated learning server class. + """ + parser = server_base.create_argument_parser() + parser.add_argument('--config', type=str, default="server_config.yaml", help='Server config file') + args = parser.parse_args() + + if os.path.isfile(args.config): + conf = easyfl.load_config(args.config, conf) + + if test_data: + easyfl.register_dataset(None, test_data) + + if model: + easyfl.register_model(model) + + if server: + easyfl.register_server(server) + + easyfl.init(conf, init_all=False) + easyfl.start_server(args) diff --git a/easyfl/simulation/__init__.py b/easyfl/simulation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/simulation/mobile_ratio.py b/easyfl/simulation/mobile_ratio.py new file mode 100644 index 0000000..476effb --- /dev/null +++ b/easyfl/simulation/mobile_ratio.py @@ -0,0 +1,6 @@ +""" +Speed of mobile to represent the ratio of waiting time. +Data is retrieved from http://ai-benchmark.com/ranking_detailed.html, using values from MobileNet-V2, NN-FP16. +""" + +MOBILE_RATIO = {"vivo iQOO Z1": "4.2", "Xiaomi Redmi Note 10": "8.1", "Huawei P40 Pro+": "4.4", "Honor 30 Pro+": "4.3", "Huawei P40": "4.3", "Huawei Mate 30 5G": "4.4", "Honor V30 Pro 5G": "4.3", "Realme X50 Pro 5G": "9.8", "Realme X2 Pro": "8.6", "Xiaomi Mi 10 Pro 5G": "8.1", "Oppo Find X2 Pro": "11", "Samsung Galaxy S20+ 5G": "9.7", "Samsung Galaxy S20": "12", "Samsung Galaxy S20 Ultra 5G": "9.9", "Samsung Galaxy S20+": "12", "Samsung Galaxy S20 5G": "9.9", "LG V60 ThinQ 5G": "8.1", "Motorola Edge+": "9.1", "Xiaomi Poco F2 Pro": "9.3", "OnePlus 8": "9", "Oppo Find X2": "9.6", "Xiaomi Redmi K30 Pro Zoom": "9.9", "vivo X50 Pro+": "9.9", "vivo iQOO 3 5G": "9.9", "Xiaomi Mi 10 5G": "9.5", "OnePlus 8 Pro": "9.8", "Asus ROG Phone II": "6.2", "vivo iQOO": "11", "Sony Xperia 1 II": "10", "OnePlus 7T Pro": "9.6", "Samsung Galaxy Note10+ 5G": "11", "OnePlus 7T": "11", "Lenovo Z6 Pro": "9.8", "Samsung Tab S6 WiFi": "10", "Samsung Galaxy S10": "11", "Samsung Galaxy Note10": "13", "Samsung Galaxy S10+": "12", "OnePlus 7": "11", "Samsung Galaxy Note10+": "12", "Xiaomi Mi 9": "11", "Samsung Galaxy S10e": "12", "Xiaomi Mi 9T Pro": "11", "OnePlus 7 Pro": "10", "Asus Zenfone 6": "11", "LG G8 ThinQ": "11", "Samsung Galaxy S10 Lite": "11", "ZTE Axon 10 Pro 5G": "11", "Oppo Reno 5G": "11", "LG V50 ThinQ 5G": "9.8", "Xiaomi Redmi K20 Pro": "12", "Samsung Galaxy A90 5G": "10", "Lenovo Z5 Pro GT": "10", "Sony Xperia 1": "8.5", "Oppo Reno 10x zoom": "9.8", "Sony Xperia 5": "11", "Xiaomi Mi Mix 3 5G": "13", "Meizu 16s": "15", "Samsung Galaxy Z Flip": "9.9", "LG G8S ThinQ": "13", "Huawei Mate 30 Pro": "4.8", "Huawei MatePad Pro": "5", "Honor 30": "5.5", "Honor X10 5G": "5.7", "Huawei nova 7 SE": "5.8", "Honor 30s": "6.1", "Xiaomi Redmi K30 5G Racing": "14", "LG V60 ThinQ (V600N)": "27", "Oppo Find X2 Lite": "12", "Oppo Reno3 Pro 5G": "13", "Asus Zenfone 5z": "7.5", "Google Pixel 4": "19", "Google Pixel 4 XL": "19", "Razer Phone 2": "9.7", "Oppo Find X2 Neo": "14", "Xiaomi Redmi K30 5G": "14", "vivo X50 Pro": "14", "Huawei P30 Pro": "18", "vivo S6 5G": "17", "Honor 20": "17", "Honor 20 Pro": "17", "Huawei Mate 20": "19", "OnePlus 6": "11", "OnePlus 6T": "11", "LG Velvet": "14", "Samsung Galaxy S9+": "59", "Huawei P30": "18", "Xiaomi Pocophone F1": "10", "Honor Magic 2": "19", "Huawei Mate 20 Pro": "19", "Sony Xperia XZ2": "9.8", "Huawei nova 5T": "17", "Sony Xperia XZ3": "11", "Honor View 20": "23", "Samsung Galaxy S9": "11", "Sony Xperia XZ2 Compact": "11", "Xiaomi Mi 10 Youth 5G": "15", "Xiaomi Mi Mix 3": "9.3", "Huawei Mate 20 X": "15", "Xiaomi Mi Mix 2S": "9.2", "Sony Xperia XZ2 Premium": "11", "Xiaomi Mi 8": "11", "Sharp Aquos R2": "12", "Honor 9X Pro": "9", "Huawei P40 lite": "9.2", "Honor 20S": "9.5", "Realme X2": "13", "Samsung Galaxy Note9": "62", "LG G7 ThinQ": "11", "Realme 6 Pro": "13", "Samsung Galaxy A71 5G": "22", "Meizu 16": "16", "LG V40 ThinQ": "13", "Xiaomi Mi Note 10 Pro": "14", "Xiaomi Redmi Note 9 Pro": "13", "Xiaomi Redmi Note 9S": "13", "Oppo Reno2": "14", "Samsung Galaxy A71": "14", "Samsung Galaxy A80": "14", "Xiaomi Mi Note 10": "15", "Oppo Reno2 Z": "28", "Xiaomi Mi Note 10 Lite": "14", "Xiaomi Redmi K30": "14", "Xiaomi Poco X2": "14", "Xiaomi Mi 9T": "15", "Oppo Reno Z": "23", "Motorola Z2 Force": "13", "Realme XT": "14", "Nokia 8": "14", "LG V30": "13", "Xiaomi Mi Mix 2": "15", "Realme X": "17", "Xiaomi Mi 6": "15", "Samsung Galaxy Note8": "170", "vivo V17": "21", "HTC U11": "18", "HTC U11 Plus": "18", "Samsung Galaxy S8": "172", "Motorola One Hyper": "20", "Huawei nova 4": "14", "Honor View 10": "14", "Samsung Galaxy S8+": "205", "Honor 10": "14", "Huawei P20 Pro": "14", "Huawei Mate 10 Pro": "14", "Xiaomi Redmi Note 7 Pro": "22", "vivo Z1 Pro": "20", "vivo U20": "22", "vivo V19": "18", "Meizu 16Xs": "23", "Xiaomi Mi 9 SE": "17", "Meizu Note 9": "22", "OnePlus 3T": "20", "HTC U12 Plus": "17", "Lenovo ZUK Z2": "21", "LG G6": "25", "Samsung Galaxy A70": "23", "Oppo RX17 Pro": "20", "vivo Z5x": "18", "Xiaomi Mi 9 Lite": "19", "Samsung Galaxy A60": "21", "Sony Xperia XZ": "23", "Blackview BV9900": "39", "Ulefone Armor 7": "42", "Xiaomi Mi CC9": "19", "Realme 3 Pro": "23", "vivo Z1x": "18", "Doogee S95 Pro": "44", "Motorola Z4": "24", "Oppo Reno A": "20", "Ulefone Armor 7E": "47", "Lenovo Z6 Youth": "19", "Samsung Galaxy S7": "199", "Lenovo Z5s": "19", "Xiaomi Mi 8 SE": "19", "Blackview BV9900 Pro": "48", "Motorola One Zoom": "24", "HTC 10": "19", "Samsung Galaxy S7 edge": "200", "OnePlus 5T": "18", "OnePlus 5": "20", "Google Pixel 3": "24", "Sony Xperia XZ1": "17", "Sony Xperia XZ1 Compact": "17", "Google Pixel 3 XL": "32", "Xiaomi Redmi Note 8 Pro": "25", "Oppo Reno3": "56", "Realme 6": "21", "Google Pixel 3a": "33", "Google Pixel 3a XL": "35", "Realme 5i": "26", "UMIDIGI S5 Pro": "38", "Oppo Reno": "24", "Realme 5": "27", "Xiaomi Mi Pad 4 Plus": "22", "TCL 10L": "28", "Xiaomi Mi Note 3": "22", "Sharp Aquos S2": "189", "Motorola G Pro": "29", "Motorola G8 Power": "29", "Motorola G Power": "29", "Oppo A5 (2020)": "28", "Xiaomi Mi A3": "29", "Sony Xperia 10 II": "32", "Oppo A92": "28", "Zebra TC57": "24", "Oppo A9 (2020)": "30", "vivo S1 Pro": "32", "Xiaomi Redmi Note 8": "29", "Xiaomi Redmi Note 8T": "29", "Motorola G8": "29", "vivo Y50": "28", "Motorola G8 Plus": "30", "Huawei P40 lite E": "37", "Huawei P Smart Z": "39", "Meizu X8": "22", "Meizu 16X": "23", "Honor Play 4T": "38", "Huawei P30 lite": "38", "Huawei P smart": "38", "Xiaomi Mi 8 Lite": "24", "Motorola Moto Z": "60", "Sony Xperia Z5 Compact": "27", "Realme 2 Pro": "21", "Samsung Galaxy A9": "26", "Xiaomi Mi A2": "26", "vivo V15": "195", "Xiaomi Redmi Note 7": "26", "LG G5": "62", "Ulefone Armor 6E": "81", "Xiaomi Redmi Note 7S": "25", "Essential Phone": "50", "Samsung Galaxy Note10 Lite": "63", "Asus Zenfone 5": "27", "Motorola G7 Plus": "30", "vivo S1": "50", "Xiaomi Mi Max 3": "31", "Sony Xperia 10 Plus": "32", "vivo V11i": "209", "vivo Y19": "60", "Oppo F11": "145", "Lenovo S5 Pro": "31", "OPPO F9 Pro": "137", "Motorola Z3 Play": "32", "T-Mobile Revvlry+": "33", "Umidigi Power 3": "49", "Samsung Galaxy A51": "101", "Xiaomi Redmi Note 5 Pro": "32", "Xiaomi Redmi Note 9": "59", "Google Pixel XL": "96", "Asus Zenfone Max Pro (M1)": "32", "Realme 3": "35", "Google Pixel": "100", "Google Pixel 2 XL": "125", "Google Pixel 2": "119", "Oppo F11 Pro": "60", "Google Pixel C": "101", "Honor 8": "172", "Motorola Moto G6 Plus": "31", "Motorola Moto X4": "31", "Realme 6i": "94", "Sony Xperia 10": "28", "Realme C3": "94", "Huawei P9": "168", "Motorola G7 Power": "122", "Huawei P10": "172", "Samsung Galaxy C9 Pro": "125", "Samsung Galaxy Note5": "146", "Sony Xperia X Compact": "121", "Huawei Mate 9": "227", "Fairphone 3": "143", "Huawei P10 Plus": "178", "Samsung Galaxy S6": "151", "Samsung Galaxy M30": "129", "Motorola One Action": "124", "Motorola One Vision": "124", "Samsung Galaxy A40": "127", "Samsung Galaxy A30s": "128", "Motorola One Macro": "172", "Samsung Galaxy M20": "130", "Samsung Galaxy A30": "129", "Xiaomi Redmi 7": "149", "Samsung Galaxy S6 edge": "153", "Samsung Galaxy A20": "134", "Samsung Galaxy S5": "216", "Samsung Galaxy Note FE": "87", "Sony Xperia Z3": "124", "Sony Xperia Z3 Compact": "132", "BlackBerry Keyone": "199", "Meizu Pro 7 Plus": "347", "Wiko WIM": "189", "Motorola G8 Play": "173", "Lenovo P2": "190", "Samsung Galaxy A21s": "132", "HTC One (M8)": "181", "LG Nexus 5": "111", "Xiaomi Redmi Note 4": "194", "Xiaomi Mi A1": "153", "Xiaomi Mi Max 2": "195", "Asus Zenfone 3": "202", "vivo V5 Plus": "198", "Huawei nova": "194", "Xiaomi Redmi 5 Plus": "195", "Motorola G7 Play": "172", "Sharp Aquos C10": "204", "Alcatel 1S": "172", "Motorola P30 Play": "199", "Honor 6X": "209", "Meizu M6 Note": "192", "Lenovo S5": "207", "Huawei P20 lite": "160", "Samsung Galaxy A8": "247", "Huawei P10 Lite": "202", "Huawei P8 Lite": "205", "Samsung Galaxy A10": "151", "Huawei Y7 Pro": "212", "Xiaomi Mi Play": "325", "ZTE Blade V10 Vita": "182", "Wiko Y80": "176", "Huawei Y7": "216", "LG G4": "258", "Huawei Y7 Prime": "218", "Xiaomi Mi A2 Lite": "203", "ZTE Blade V9": "222", "Huawei P9 lite": "205", "Samsung Galaxy A5": "243", "LG Stylo 5": "146", "Xiaomi Redmi 5": "216", "LG V10": "207", "Xiaomi Mi Pad": "134", "Lenovo K3 Note": "199", "Samsung Galaxy A20s": "217", "YU Yutopia": "164", "Samsung Galaxy A10e": "335", "Samsung Galaxy Tab A 8.0": "160", "Nokia 3 V": "212", "Motorola G8 Power Lite": "267", "Sony Xperia XA1": "259", "LG Stylo 6": "247", "Xiaomi Redmi 8": "262", "Xiaomi Mi Pad 2": "199", "ZTE Blade V9 Vita": "283", "UMIDIGI A3X": "234", "Samsung Galaxy A01": "244", "vivo Y15": "260", "Motorola E6 Plus": "265", "Xiaomi Redmi Y1": "286", "Lenovo K6 Note": "283", "Sony Xperia L4": "279", "Asus Zenfone 3 Max": "251", "HTC One": "194", "Wiko View2": "287", "Motorola G6": "180", "Xiaomi Redmi 4X": "282", "Asus Zenfone Max Plus": "330", "Sony Xperia Z": "215", "Samsung Galaxy A6+": "278", "Raspberry Pi 4": "173", "Xiaomi Redmi 6": "253", "Samsung Galaxy A10s": "280", "Samsung Galaxy A11": "280", "Samsung Galaxy M11": "280", "Samsung Galaxy J7 Prime": "317", "Samsung Galaxy J7": "318", "Xiaomi Redmi 5A": "242", "Xiaomi Mi 5c": "233", "Honor 8S": "230", "Samsung Galaxy A6": "286", "Samsung Galaxy J5": "376", "Xiaomi Redmi 6A": "153", "Huawei Y5": "236", "Samsung Galaxy J7 Pro": "733", "Xiaomi Redmi 3": "269", "Blackview A80 Pro": "300", "Alcatel 3x": "343", "Samsung Galaxy J6": "307", "Samsung Galaxy S5 Neo": "303", "Umidigi A5 Pro": "370", "Ulefone Armor X5": "356", "Sony Xperia L1": "237", "Samsung Galaxy Note 3": "202", "Meizu M2": "281", "Huawei Y5 Prime": "271", "Sony Xperia M4 Aqua": "334", "Xiaomi Redmi 7A": "485", "Xiaomi Redmi 8A": "516", "Motorola Moto X Play": "312", "Samsung Galaxy J3": "643", "Samsung Galaxy J5 Prime": "336", "Motorola G6 Play": "263", "Lenovo Vibe P1m": "328", "Motorola E4 Plus": "386", "ZTE Blade X3": "319", "ZTE Blade A452": "322", "Asus Zenfone 2": "548", "Asus Zenpad 10": "664", "Nokia 1": "331", "Samsung Galaxy Xcover 3": "669", "Asus Live G500TG": "668", "Asus Google Nexus 7": "622", "Samsung Galaxy S5 mini": "745", "Motorola Moto G2": "695", "Huawei Y560": "797", "Sony Xperia M2 Aqua": "807"} \ No newline at end of file diff --git a/easyfl/simulation/system_hetero.py b/easyfl/simulation/system_hetero.py new file mode 100644 index 0000000..4d72a88 --- /dev/null +++ b/easyfl/simulation/system_hetero.py @@ -0,0 +1,71 @@ +import logging + +import numpy as np + +from easyfl.simulation.mobile_ratio import MOBILE_RATIO +from easyfl.datasets.simulation import equal_division + +logger = logging.getLogger(__name__) + +SIMULATE_ISO = "iso" # isometric sleep time distribution among selected clients +SIMULATE_DIR = "dir" # use symmetric dirichlet process to sample sleep time heterogenous +SIMULATE_REAL = "real" # use real speed ratio of main stream smartphones to simulate sleep time heterogenous + + +def assign_value_to_group(groups, values): + assert len(groups) == len(values) + result = [] + for i in range(len(groups)): + result.extend([values[i]] * len(groups[i])) + return result + + +def sample_real_ratio(num_values): + value_pool = list(MOBILE_RATIO.values()) + idxs = np.random.randint(0, len(value_pool), size=num_values) + return np.array([value_pool[i] for i in idxs]).astype(float) + + +def resource_hetero_simulation(fraction, hetero_type, sleep_group_num, level, total_time, num_clients): + """Simulated resource heterogeneous by add sleeping time to clients. + + Args: + fraction (float): The fraction of clients attending heterogeneous simulation. + hetero_type (str): The type of heterogeneous simulation, options: iso, dir or real. + sleep_group_num (int): The number of groups with different sleep time. + level (int): The level of heterogeneous (0-5), 0 means no heterogeneous among clients. + total_time (float): The total sleep time of all clients. + num_clients (int): The total number of clients. + + Returns: + list[float]: A list of sleep time with distribution according to heterogeneous type. + """ + sleep_clients = int(fraction * num_clients) + unsleep_clients = [0] * (num_clients - sleep_clients) + sleep_group_num = sleep_group_num + if sleep_group_num > sleep_clients: + logger.warning("sleep_group_num {} is more than sleep_clients number {}, \ + so we set sleep_group_num to sleep_clients".format(sleep_group_num, sleep_clients)) + sleep_group_num = sleep_clients + groups, _ = equal_division(sleep_group_num, np.arange(sleep_clients)) + if level == 0: + distribution = np.array([1] * sleep_clients) + elif hetero_type == SIMULATE_DIR: + alpha = 1 / (level * level) + values = np.random.dirichlet(np.repeat(alpha, sleep_group_num)) + distribution = assign_value_to_group(groups, values) + elif hetero_type == SIMULATE_ISO: + if level > 5: + raise ValueError("level cannot be more than 5") + begin = 0.5 - level * 0.1 + end = 0.5 + level * 0.1 + values = np.arange(begin, end, (end - begin) / sleep_group_num) + distribution = assign_value_to_group(groups, values) + elif hetero_type == SIMULATE_REAL: + values = sample_real_ratio(sleep_group_num) + distribution = assign_value_to_group(groups, values) + else: + raise ValueError("sleep type not supported, please use either dir or iso") + distribution += unsleep_clients + np.random.shuffle(distribution) + return distribution / sum(distribution) * total_time diff --git a/easyfl/tracking/__init__.py b/easyfl/tracking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyfl/tracking/client.py b/easyfl/tracking/client.py new file mode 100644 index 0000000..9166136 --- /dev/null +++ b/easyfl/tracking/client.py @@ -0,0 +1,242 @@ +from easyfl.tracking.metric import TaskMetric, RoundMetric, ClientMetric +from easyfl.tracking.storage import get_store + + +class TrackingClient(object): + """Client for tracking task metrics, round metrics, and client metrics. + Task Tracking: + client.create_task(task_id, conf) + + Round Tracking: + client.track_round(name, value) + client.save_round() # auto increment to next round + client.track_round(name, value) + + Client Tracking: + client.set_client_context(task_id, round_id, client_id) + client.track_client(name, value) + """ + + def __init__(self, db_path=None, db_address=None, init_store=True): + """If storage is not initialized, the tracking client can only collect metrics but not save them. + """ + self._task_id = None + self._round_id = None + self._client_id = None + self._current_task = None + self._current_round = None + self._current_client = None + self._cached_task_metrics = {} + self._cached_round_metrics = {} + + if init_store: + self._storage = get_store(db_path, db_address) + + def get_task_metric(self, task_id): + """Get task from storage + """ + task_metric = self._storage.get_task_metric(task_id) + if task_metric is None: + return + return TaskMetric.from_sql(task_metric) + + def get_round_metric(self, round_id, task_id): + if task_id == self._task_id and round_id == self._round_id: + return self._current_round + return self._storage.get_round_metrics(task_id, [round_id]) + + def get_client_metric(self, client_id=None, round_id=None, task_id=None): + if (task_id == self._task_id and round_id == self._round_id and client_id == self._client_id) or \ + (client_id is None and round_id is None and task_id is None): + return self._current_client + return self._storage.get_client_metrics(task_id, round_id, [client_id]) + + def get_client_metrics(self, client_ids, round_id, task_id): + """Get list of client metrics. + :param client_ids: list of client ids. + :param round_id: round id. + :param task_id: task id. + """ + return self._storage.get_client_metrics(task_id, round_id, client_ids) + + def create_task(self, task_id, conf=None, save=True): + if task_id is None: + raise ValueError("task_id cannot be None to create task") + self._task_id = task_id + self._current_task = TaskMetric(task_id, conf) + if save: + self._storage.store_task_metric(self._current_task) + + def create_round(self, round_id, task_id=None): + if task_id is None: + task_id = self._task_id + + if round_id is None: + raise ValueError("round_id cannot be None to create round") + + if round_id != self._round_id: + self._round_id = round_id + self._current_round = RoundMetric(task_id, self._round_id) + + def create_client(self, client_id, reset=True): + """Create client under current round of task. + Current implementation requires round and task exist to create client. + """ + self._check_context() + + if client_id is None: + raise ValueError("client_id cannot be None to create client.") + + if reset or not self._current_client or client_id != self._client_id: + self._current_client = ClientMetric(self._task_id, self._round_id, client_id) + self._client_id = client_id + return + + self._current_client.task_id = self._task_id + self._current_client.round_id = self._round_id + + def track_task(self, name, value, task_id=None): + if self._diff_task_id(task_id): + self._cached_task_metrics[self._task_id] = self._current_task + self._task_id = task_id + self._current_task = TaskMetric(task_id) + + self._current_task.add(name, value) + self._storage.store_task_metric(self._current_task) + + def track_round(self, name, value, round_id=None, task_id=None): + if self._diff_task_id(task_id): + create_task(task_id) + + if self._diff_round_id(round_id): + # self._cached_round_metrics[self.unique_round_id] = self._current_round + self.create_round(round_id) + + if self._current_round is None: + self.create_round(0) + + self._current_round.add(name, value) + + def track_client(self, name, value, client_id=None): + """Track client under current round and task. + Current implementation requires round and task exist to track client. + """ + self._check_context() + + if self._diff_client_id(client_id) or self._current_client is None: + self.create_client(client_id) + + self._current_client.add(name, value) + + def save_round(self, increment=True, cache=False): + if self._current_round is None: + raise ValueError("Round metric is not initialized") + self._storage.store_round_metric(self._current_round) + if cache: + self._cached_round_metrics[self.unique_round_id] = self._current_round + if increment: + self.create_round(self._round_id + 1) + + def save_client(self): + if self._current_client is None: + raise ValueError("Client metric is not initialized") + + self._storage.store_client_metrics([self._current_client]) + + def save_clients(self, client_metrics): + self._storage.store_client_metrics(client_metrics) + + def set_task(self, task_id): + if self._current_task is None: + self.create_task(task_id, save=False) + + def set_round(self, round_id): + self.create_round(round_id) + + def set_client_context(self, task_id, round_id, client_id, reset_client=True): + """Set the client context for tracking. + :param task_id: task id, indicating current the training task + :param round_id: round id, indicating current round of training/testing + :param client_id: client id + :param reset_client: resets and creates a new client. + """ + self.set_task(task_id) + self.set_round(round_id) + self.create_client(client_id, reset=reset_client) + + @property + def unique_round_id(self): + return f"{self._task_id}_{self._round_id}" + + def _diff_task_id(self, task_id): + return task_id is not None and task_id != self._task_id + + def _diff_round_id(self, round_id): + return round_id is not None and round_id != self._round_id + + def _diff_client_id(self, client_id): + return client_id is not None and client_id != self._client_id + + def _check_context(self): + if self._task_id is None or self._round_id is None: + raise LookupError("task_id or round_id of the client is not set") + + +_client = TrackingClient(init_store=False) +"""easyfl.tracking.TrackingClient: The global tracking client object""" + + +def init_tracking(path=None, address=None, init_store=True): + """Initialize tracking client. This tracking client is isolated from the global tracking client. + This is useful when an application need to run multiple tasks. + :param path: database path + :param address: remote address of tracking service to connect to + :param init_store: whether initialize storage + """ + return TrackingClient(path, address, init_store) + + +# ------ following methods are not well tested yet ------ + + +def setup_tracking(path=None, address=None): + """Setup tracking with global tracking client. + """ + global _client + _client = init_tracking(path, address) + + +def get_task(task_id): + return _client.get_task_metric(task_id) + + +def get_round(round_id, task_id): + return _client.get_round_metric(round_id, task_id) + + +def create_task(task_id, conf=None): + _client.create_task(task_id, conf) + + +def track_task(name, value, task_id=None): + _client.track_task(name, value, task_id) + + +def track_round(name, value, round_id=None, task_id=None): + _client.track_round(name, value, round_id, task_id) + + +def track_client(name, value, client_id=None): + _client.track_client(name, value, client_id) + + +def set_task(task_id): + _client.set_task(task_id) + + +def set_round(round_id): + _client.set_round(round_id) + + +def save_round(): + _client.save_round() diff --git a/easyfl/tracking/client_test.py b/easyfl/tracking/client_test.py new file mode 100644 index 0000000..932552b --- /dev/null +++ b/easyfl/tracking/client_test.py @@ -0,0 +1,136 @@ +import os +import unittest + +from easyfl.tracking import client +from easyfl.tracking import metric +from easyfl.tracking import storage + + +class TrackingClientTest(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super(TrackingClientTest, self).__init__(*args, **kwargs) + self._database = os.path.join(os.getcwd(), "tracker", "easyfl_test.db") + self._store = storage.get_store(self._database) + self._store.truncate_task_metric() + self._store.truncate_round_metric() + self._store.truncate_client_metric() + + def test_task(self): + task_id = "test_task" + conf = {"task_id": task_id} + tracker = client.init_tracking(self._database) + tracker.create_task(task_id, conf) + m = tracker.get_task_metric(task_id) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.configuration, conf) + m = tracker.get_task_metric("not_exist_task") + self.assertEqual(m, None) + + def test_round(self): + task_id = "test_round" + round_id = 0 + want_accuracy = 0.9 + want_loss = 0.1 + want_train_upload_size = 10 + want_train_time = 20 + want_extra = {"extra": "information"} + tracker = client.init_tracking(self._database) + tracker.create_task(task_id) + tracker.track_round(metric.TEST_ACCURACY, want_accuracy) + tracker.track_round(metric.TEST_LOSS, want_loss) + m = tracker.get_round_metric(round_id, task_id) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.round_id, round_id) + self.assertEqual(m.test_accuracy, want_accuracy) + self.assertEqual(m.test_loss, want_loss) + + tracker.save_round(increment=False) + round_metrics = self._store.get_round_metrics(task_id, [round_id]) + m = metric.RoundMetric.from_sql(next(round_metrics)) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.round_id, round_id) + self.assertEqual(m.test_accuracy, want_accuracy) + self.assertEqual(m.test_loss, want_loss) + + # round 1 + tracker.set_round(round_id + 1) + tracker.track_round(metric.TRAIN_UPLOAD_SIZE, want_train_upload_size) + tracker.track_round(metric.EXTRA, want_extra) + tracker.save_round() + round_metrics = self._store.get_round_metrics(task_id, [round_id + 1]) + m = metric.RoundMetric.from_sql(next(round_metrics)) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.round_id, round_id + 1) + self.assertEqual(m.test_accuracy, 0) + self.assertEqual(m.test_loss, 0) + self.assertEqual(m.train_upload_size, want_train_upload_size) + self.assertEqual(m.extra["extra"], want_extra["extra"]) + + # round 2 + tracker.track_round(metric.TRAIN_TIME, want_train_time) + m = tracker.get_round_metric(round_id + 2, task_id) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.round_id, round_id + 2) + self.assertEqual(m.train_time, want_train_time) + self.assertEqual(m.test_accuracy, 0) + self.assertEqual(m.test_loss, 0) + self.assertEqual(m.extra, {}) + + def test_client(self): + task_id = "test_client" + round_id = 1 + client_id = "client_id_test" + client_id_2 = "client_id_test_2" + want_accuracy = 0.9123456789 + want_mAP = 0.8 + want_rank1 = 0.7 + want_loss = 0.1 + want_extra = {"extra": "information"} + + # test error + tracker = client.init_tracking(self._database) + self.assertRaises(LookupError, tracker.create_client, client_id) + self.assertRaises(LookupError, tracker.track_client, metric.TEST_ACCURACY, [want_accuracy]) + + # test track and get client + tracker.set_client_context(task_id, round_id, client_id) + tracker.track_client(metric.TRAIN_ACCURACY, [want_accuracy]) + tracker.track_client("mAP", want_mAP) + m = tracker.get_client_metric(client_id, round_id, task_id) + self.assertEqual(m.train_accuracy, [round(want_accuracy, 4)]) + self.assertEqual(m.extra["mAP"], want_mAP) + + # test save client + tracker.save_client() + client_metrics = self._store.get_client_metrics(task_id, round_id, [client_id]) + m = metric.ClientMetric.from_sql(next(client_metrics)) + self.assertEqual(m.task_id, task_id) + self.assertEqual(m.round_id, round_id) + self.assertEqual(m.client_id, client_id) + self.assertEqual(m.train_accuracy, [round(want_accuracy, 4)]) + self.assertEqual(m.extra["mAP"], want_mAP) + self._store.truncate_client_metric() + + # test save multiple clients + tracker2 = client.init_tracking(self._database) + tracker2.set_client_context(task_id, round_id, client_id_2) + tracker2.track_client(metric.TRAIN_LOSS, [want_loss]) + tracker2.track_client("rank1", want_rank1) + tracker2.track_client(metric.EXTRA, want_extra) + + client_metrics = [tracker.get_client_metric(), tracker2.get_client_metric()] + tracker.save_clients(client_metrics) + results = self._store.get_client_metrics(task_id, round_id, [client_id, client_id_2]) + metrics = [metric.ClientMetric.from_sql(r) for r in results] + self.assertEqual(len(metrics), 2) + self.assertEqual(len(metrics[1].extra), 2) + self.assertEqual(metrics[1].task_id, task_id) + self.assertEqual(metrics[1].round_id, round_id) + self.assertEqual(metrics[1].client_id, client_id_2) + self.assertEqual(metrics[1].train_loss, [want_loss]) + self.assertEqual(metrics[1].extra["rank1"], want_rank1) + + +if __name__ == '__main__': + unittest.main() diff --git a/easyfl/tracking/evaluation.py b/easyfl/tracking/evaluation.py new file mode 100644 index 0000000..f66d667 --- /dev/null +++ b/easyfl/tracking/evaluation.py @@ -0,0 +1,17 @@ +def model_size(model, param_size=32): + """Calculate the model parameter sizes, including non-trainable parameters + + Args: + model (nn.Module): A PyTorch model. + param_size (int): The size of a parameter, default using float32. + + Returns: + float: The model size in MB. + """ + # sum(p.numel() for p in model.parameters() if p.requires_grad) for only trainable parameters + params = sum(p.numel() for p in model.parameters()) + return bit_to_megabyte(params * param_size) + + +def bit_to_megabyte(bits): + return bits / (8 * 1024 * 1024) diff --git a/easyfl/tracking/metric.py b/easyfl/tracking/metric.py new file mode 100644 index 0000000..30e3002 --- /dev/null +++ b/easyfl/tracking/metric.py @@ -0,0 +1,486 @@ +import json +import logging +import random +import string +import time + +import numpy as np + +from easyfl.pb import common_pb2 as common_pb +from easyfl.utils.float import rounding + +PREFIX_TASK_ID = "task" + +CONFIGURATION = "configuration" + +# clients +SELECTED_CLIENTS = 'selected_clients' +GROUPED_CLIENTS = 'grouped_clients' + +# communication cost +DOWNLOAD_SIZE = 'download_size' +TRAIN_DOWNLOAD_SIZE = 'train_download_size' +TRAIN_UPLOAD_SIZE = 'train_upload_size' +TEST_DOWNLOAD_SIZE = 'test_download_size' +TEST_UPLOAD_SIZE = 'test_upload_size' + +# distribute time +UPLOAD_TIME = "upload_time" +TRAIN_UPLOAD_TIME = "train_upload_time" +TEST_UPLOAD_TIME = "test_upload_time" +TRAIN_DISTRIBUTE_TIME = "train_distribute_time" +TEST_DISTRIBUTE_TIME = "test_distribute_time" + +# time +ROUND_TIME = "round_time" +TRAIN_TIME = 'train_time' +TEST_TIME = 'test_time' +TRAIN_EPOCH_TIME = 'train_epoch_time' + +# performance +TRAIN_ACCURACY = 'train_accuracy' +TRAIN_LOSS = 'train_loss' +AVG_TRAIN_LOSS = 'avg_train_loss' + +TEST_ACCURACY = 'test_accuracy' +TEST_LOSS = 'test_loss' + +TEST_LOCAL_ACCURACY = 'test_local_accuracy' +TEST_LOCAL_LOSS = 'test_local_loss' + +# general +EXTRA = "extra" # for not specifically defined metrics +DEFAULT_FOLDER = "metrics" +PREFIX_METRIC_ID = "metric" + +logger = logging.getLogger(__name__) + + +class Metric(object): + def __init__(self): + self.metrics = { + EXTRA: {} + } + + def add(self, metric_name, metric_value, convert=True): + """Add metrics. Add to "extra" if the metric is not predefined. + """ + if self.predefined_metrics() and metric_name in self.predefined_metrics(): + if convert: + metric_value = self._value_conversion(metric_value) + self.metrics[metric_name] = metric_value + elif metric_name == EXTRA: + self.metrics[EXTRA].update(metric_value) + else: + self.metrics[EXTRA][metric_name] = metric_value + + def get(self, metric_name, default=0): + if metric_name in self.metrics: + return self.metrics[metric_name] + return default + + @classmethod + def predefined_metrics(cls): + return [] + + @property + def extra(self): + """Retrieve extra information, not specifically defined metric, stored in the metrics. + :return dictionary of metrics, return {} if extra stored. + """ + return self.metrics[EXTRA] + + @staticmethod + def _value_conversion(value): + """Convert float to keep only 4 decimal points + """ + if isinstance(value, float): + value = np.around(value, 4) + elif isinstance(value, list) and len(value) > 0 and isinstance(value[0], float): + value = rounding(value, 4) + return value + + +class TaskMetric(object): + def __init__(self, task_id, conf=None): + self._task_id = task_id + self._conf = conf + + def add(self, name, value): + if name == CONFIGURATION: + self.add_configuration(value) + + def add_configuration(self, conf): + self._conf = conf + + @classmethod + def from_sql(cls, sql_result): + task_id, conf = sql_result + conf = {} if conf == "" else json.loads(conf) + return cls(task_id, conf) + + def to_sql_param(self): + conf = json.dumps(self.configuration) if self.configuration is not None else "" + return self.task_id, conf + + @property + def task_id(self): + return self._task_id + + @property + def configuration(self): + return self._conf + + def to_proto(self): + return common_pb.TaskMetric( + task_id=self.task_id, + configuration=json.dumps(self.configuration) + ) + + @classmethod + def from_proto(cls, proto): + return cls(proto.task_id, json.loads(proto.configuration)) + + +class RoundMetric(Metric): + """Metrics of a training round + Note: testing related metrics may not be available in every round. + """ + + def __init__(self, task_id, round_id): + super().__init__() + self.task_id = task_id + self.round_id = round_id + + @property + def test_accuracy(self): + return self.get(TEST_ACCURACY) + + @property + def test_loss(self): + return self.get(TEST_LOSS) + + @property + def train_time(self): + return self.get(TRAIN_TIME) + + @property + def test_time(self): + return self.get(TEST_TIME) + + @property + def round_time(self): + return self.get(ROUND_TIME) + + @property + def train_distribute_time(self): + return self.get(TRAIN_DISTRIBUTE_TIME, 0) + + @property + def test_distribute_time(self): + return self.get(TEST_DISTRIBUTE_TIME, 0) + + @property + def train_upload_size(self): + """Communication cost of uploading content from client to server + """ + return self.get(TRAIN_UPLOAD_SIZE) + + @property + def train_download_size(self): + """Communication cost of distributing content from server to client + """ + return self.get(TRAIN_DOWNLOAD_SIZE) + + @property + def test_upload_size(self): + """Communication cost of uploading content from client to server + """ + return self.get(TEST_UPLOAD_SIZE) + + @property + def test_download_size(self): + """Communication cost of distributing content from server to client + """ + return self.get(TEST_DOWNLOAD_SIZE) + + @property + def communication_cost(self): + return self.train_upload_size + self.train_download_size + self.test_upload_size + self.test_download_size + + @classmethod + def predefined_metrics(cls): + return [TEST_ACCURACY, + TEST_LOSS, + ROUND_TIME, + TRAIN_TIME, + TEST_TIME, + TRAIN_DISTRIBUTE_TIME, + TEST_DISTRIBUTE_TIME, + TRAIN_UPLOAD_SIZE, + TRAIN_DOWNLOAD_SIZE, + TEST_UPLOAD_SIZE, + TEST_DOWNLOAD_SIZE] + + @classmethod + def from_sql(cls, sql_result): + task_id = sql_result[0] + round_id = sql_result[1] + m = cls(task_id, round_id) + metrics = cls.predefined_metrics() + for name, value in zip(metrics, sql_result[2:-1]): + m.add(name, value) + m.add(EXTRA, json.loads(sql_result[-1])) + return m + + def to_sql_param(self): + return (self.task_id, + self.round_id, + self.test_accuracy, + self.test_loss, + self.round_time, + self.train_time, + self.test_time, + self.train_distribute_time, + self.test_distribute_time, + self.train_upload_size, + self.train_download_size, + self.test_upload_size, + self.test_download_size, + json.dumps(self.extra)) + + def to_proto(self): + return common_pb.RoundMetric( + task_id=self.task_id, + round_id=self.round_id, + test_accuracy=self.test_accuracy, + test_loss=self.test_loss, + round_time=self.round_time, + train_time=self.train_time, + test_time=self.test_time, + train_distribute_time=self.train_distribute_time, + test_distribute_time=self.test_distribute_time, + train_upload_size=self.train_upload_size, + train_download_size=self.train_download_size, + test_upload_size=self.test_upload_size, + test_download_size=self.test_download_size, + extra=json.dumps(self.extra) + ) + + @classmethod + def from_proto(cls, proto): + m = cls(proto.task_id, proto.round_id) + metrics = cls.predefined_metrics() + values = [proto.test_accuracy, + proto.test_loss, + proto.round_time, + proto.train_time, + proto.test_time, + proto.train_distribute_time, + proto.test_distribute_time, + proto.train_upload_size, + proto.train_download_size, + proto.test_upload_size, + proto.test_download_size] + for name, value in zip(metrics, values): + m.add(name, value) + + if proto.extra: + m.add(EXTRA, json.loads(proto.extra)) + + return m + + +class ClientMetric(Metric): + """Metrics for a client in a round of training. + """ + + def __init__(self, task_id, round_id, client_id): + super().__init__() + self.task_id = task_id + self.round_id = round_id + self.client_id = client_id + + @property + def train_accuracy(self): + return self.get(TRAIN_ACCURACY) + + @property + def test_accuracy(self): + return self.get(TEST_ACCURACY) + + @property + def train_loss(self): + return self.get(TRAIN_LOSS) + + @property + def test_loss(self): + return self.get(TEST_LOSS) + + @property + def train_time(self): + return self.get(TRAIN_TIME) + + @property + def test_time(self): + return self.get(TEST_TIME) + + @property + def train_upload_time(self): + return self.get(TRAIN_UPLOAD_TIME) + + @property + def test_upload_time(self): + return self.get(TEST_UPLOAD_TIME) + + @property + def train_upload_size(self): + return self.get(TRAIN_UPLOAD_SIZE) + + @property + def train_download_size(self): + return self.get(TRAIN_DOWNLOAD_SIZE) + + @property + def test_upload_size(self): + return self.get(TEST_UPLOAD_SIZE) + + @property + def test_download_size(self): + return self.get(TEST_DOWNLOAD_SIZE) + + @property + def communication_cost(self): + return self.train_upload_size + self.train_download_size + self.test_upload_size + self.test_download_size + + @classmethod + def predefined_metrics(cls): + return [TRAIN_ACCURACY, + TRAIN_LOSS, + TEST_ACCURACY, + TEST_LOSS, + TRAIN_TIME, + TEST_TIME, + TRAIN_UPLOAD_TIME, + TEST_UPLOAD_TIME, + TRAIN_UPLOAD_SIZE, + TRAIN_DOWNLOAD_SIZE, + TEST_UPLOAD_SIZE, + TEST_DOWNLOAD_SIZE] + + @classmethod + def from_sql(cls, sql_result): + task_id = sql_result[0] + round_id = sql_result[1] + client_id = sql_result[2] + m = cls(task_id, round_id, client_id) + metrics = cls.predefined_metrics() + [EXTRA] + for name, value in zip(metrics, sql_result[3:]): + if name in [TRAIN_ACCURACY, TRAIN_LOSS, EXTRA]: + value = json.loads(value) + m.add(name, value) + return m + + def to_sql_param(self): + return (self.task_id, + self.round_id, + self.client_id, + json.dumps(self.train_accuracy), + json.dumps(self.train_loss), + self.test_accuracy, + self.test_loss, + self.train_time, + self.test_time, + self.train_upload_time, + self.test_upload_time, + self.train_upload_size, + self.train_download_size, + self.test_upload_size, + self.test_download_size, + json.dumps(self.extra)) + + def to_proto(self): + return common_pb.ClientMetric( + task_id=self.task_id, + round_id=self.round_id, + client_id=self.client_id, + train_accuracy=self.train_accuracy, + train_loss=self.train_loss, + test_accuracy=self.test_accuracy, + test_loss=self.test_loss, + train_time=self.train_time, + test_time=self.test_time, + train_upload_time=self.train_upload_time, + test_upload_time=self.test_upload_time, + train_upload_size=self.train_upload_size, + train_download_size=self.train_download_size, + test_upload_size=self.test_upload_size, + test_download_size=self.test_download_size, + extra=json.dumps(self.extra) + ) + + @classmethod + def from_proto(cls, proto): + m = cls(proto.task_id, proto.round_id, proto.client_id) + train_accuracy = [x for x in proto.train_accuracy] + train_loss = [x for x in proto.train_loss] + metrics = cls.predefined_metrics() + values = [train_accuracy, + train_loss, + proto.test_accuracy, + proto.test_loss, + proto.train_time, + proto.test_time, + proto.train_upload_time, + proto.test_upload_time, + proto.train_upload_size, + proto.train_download_size, + proto.test_upload_size, + proto.test_download_size] + for name, value in zip(metrics, values): + m.add(name, value) + + if proto.extra: + m.add(EXTRA, json.loads(proto.extra)) + + return m + + def set_train_metrics(self, m): + if self.is_same_metric(m): + self.metrics[TRAIN_ACCURACY] = m.train_accuracy + self.metrics[TRAIN_LOSS] = m.train_loss + self.metrics[TRAIN_TIME] = m.train_time + self.metrics[TRAIN_UPLOAD_TIME] = m.train_upload_time + self.metrics[TRAIN_UPLOAD_SIZE] = m.train_upload_size + self.metrics[TRAIN_DOWNLOAD_SIZE] = m.train_download_size + + def set_test_metrics(self, m): + if self.is_same_metric(m): + self.metrics[TEST_ACCURACY] = m.test_accuracy + self.metrics[TEST_LOSS] = m.test_loss + self.metrics[TEST_TIME] = m.test_time + self.metrics[TEST_UPLOAD_TIME] = m.test_upload_time + self.metrics[TEST_UPLOAD_SIZE] = m.test_upload_size + self.metrics[TEST_DOWNLOAD_SIZE] = m.test_download_size + + def is_same_metric(self, m): + return self.task_id == m.task_id and self.round_id == m.round_id and self.client_id == m.client_id + + @classmethod + def merge_train_to_test_metrics(cls, train_metrics, test_metrics): + """Merge train metrics to test_metrics + """ + train_metrics_ = {m.client_id: m for m in train_metrics} + for test_metric in test_metrics: + client_id = test_metric.client_id + if client_id in train_metrics_: + test_metric.set_train_metrics(train_metrics_[client_id]) + return test_metrics + + +def generate_tid(): + length = 6 + letters = string.ascii_lowercase + random.seed(time.time()) + result = "".join(random.choice(letters) for i in range(length)) + return "{}_{}".format(PREFIX_TASK_ID, result) diff --git a/easyfl/tracking/service.py b/easyfl/tracking/service.py new file mode 100644 index 0000000..0229344 --- /dev/null +++ b/easyfl/tracking/service.py @@ -0,0 +1,157 @@ +import argparse +import logging + +from easyfl.communication import grpc_wrapper +from easyfl.pb import common_pb2 as common_pb +from easyfl.pb import tracking_service_pb2 as tracking_pb +from easyfl.pb import tracking_service_pb2_grpc as tracking_grpc +from easyfl.tracking import metric +from easyfl.tracking.storage import SqliteStorage + +logger = logging.getLogger(__name__) + + +def create_argument_parser(): + parser = argparse.ArgumentParser(description='Federated Tracker') + parser.add_argument('--local-port', + type=int, + default=12666, + help='Listen port of the client') + return parser + + +class TrackingService(tracking_grpc.TrackingServiceServicer): + def __init__(self, storage=SqliteStorage): + self._storage = storage() + logger.info("Tracking service is online") + self._storage.setup() + + def TrackTaskMetric(self, request, context): + response = tracking_pb.TrackTaskMetricResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + + try: + self._storage.store_task_metric(metric.TaskMetric.from_proto(request.task_metric)) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = f"Failed to track task metric, err: {e}" + logger.error(response.status.message) + + return response + + def TrackRoundMetric(self, request, context): + response = tracking_pb.TrackRoundMetricResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + self._storage.store_round_metric(metric.RoundMetric.from_proto(request.round_metric)) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = f"Failed to track round metric, err: {e}" + logger.error(response.status.message) + + return response + + def TrackClientMetric(self, request, context): + response = tracking_pb.TrackClientMetricResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + metrics = [metric.ClientMetric.from_proto(m) for m in request.client_metrics] + self._storage.store_client_metrics(metrics) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = f"Failed to track client metric, err: {e}" + logger.error(response.status.message) + + return response + + def TrackClientTrainMetric(self, request, context): + response = tracking_pb.TrackClientTrainMetricResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + self._storage.store_client_train_metric(request.task_id, + request.round_id, + request.client_id, + request.train_loss, + request.train_time, + request.train_upload_time, + request.train_download_size, + request.train_upload_size) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = "Tracking client train failed, err: {}".format(e) + logger.error("Tracking client train failed, err: {}".format(e)) + + return response + + def TrackClientTestMetric(self, request, context): + response = tracking_pb.TrackClientTestMetricResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + + try: + self._storage.store_client_test_metric(request.task_id, + request.round_id, + request.client_id, + request.test_accuracy, + request.test_loss, + request.test_time, + request.test_upload_time, + request.test_download_size) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = "Tracking client test failed, err: {}".format(e) + logger.error("Tracking client test failed, err: {}".format(e)) + + return response + + def GetRoundTrainTestTime(self, request, context): + response = tracking_pb.GetRoundTrainTestTimeResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + resp = self._storage.get_round_train_test_time(request.task_id, + request.rounds, + request.interval) + for i in resp: + train_test_time = tracking_pb.TrainTestTime(round_id=i[0], time=i[1]) + response.train_test_times.append(train_test_time) + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = "get round train_test time failed, err: {}".format(e) + logger.error("get round train_test time failed, err: {}".format(e)) + return response + + def GetRoundMetrics(self, request, context): + response = tracking_pb.GetRoundMetricsResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + resp = self._storage.get_round_metrics(request.task_id, request.rounds) + response.metrics = [metric.RoundMetric.from_sql(r) for r in resp] + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = f"Failed to get round metrics, err: {e}" + logger.error(response.status.message) + return response + + def GetClientMetrics(self, request, context): + response = tracking_pb.GetClientMetricsResponse( + status=common_pb.Status(code=common_pb.SC_OK), + ) + try: + resp = self._storage.get_client_metrics(request.task_id, request.round_id, request.client_ids) + response.metrics = [metric.ClientMetric.from_sql(r) for r in resp] + except Exception as e: + response.status.code = common_pb.SC_UNKNOWN + response.status.message = f"Failed to get client metrics failed, err: {e}" + logger.error(response.status.message) + return response + + +def start_tracking_service(local_port=12666): + logger.info("Tracking GRPC server started at :{}".format(local_port)) + grpc_wrapper.start_service(grpc_wrapper.TYPE_TRACKING, TrackingService(), local_port) diff --git a/easyfl/tracking/storage.py b/easyfl/tracking/storage.py new file mode 100644 index 0000000..a781b94 --- /dev/null +++ b/easyfl/tracking/storage.py @@ -0,0 +1,340 @@ +import logging +import os +import random +import sqlite3 +import time + +from easyfl.communication import grpc_wrapper +from easyfl.pb import common_pb2 as common_pb +from easyfl.pb import tracking_service_pb2 as tracking_pb +from easyfl.protocol.codec import marshal + +logger = logging.getLogger(__name__) + +DEFAULT_SQLITE_DB = "easyfl.db" + +STORAGE_SQLITE = "sqlite" +STORAGE_REMOTE = "remote" + +TYPE_ROUND = "round" +TYPE_CLIENT = "client" + +DEFAULT_TIMEOUT = 10 + +CREATE_TASK_METRIC_SQL = ''' +CREATE TABLE IF NOT EXISTS task_metric +(task_id CHAR(50) NOT NULL PRIMARY KEY, +config TEXT);''' + +CREATE_ROUND_METRIC_SQL = ''' +CREATE TABLE IF NOT EXISTS round_metric +(task_id CHAR(50) NOT NULL, +round_id INT NOT NULL, +accuracy REAL NOT NULL, +loss REAL NOT NULL, +round_time REAL NOT NULL, +train_time REAL NOT NULL, +test_time REAL NOT NULL, +train_distribute_time REAL, +test_distribute_time REAL, +train_upload_size REAL, +train_download_size REAL, +test_upload_size REAL, +test_download_size REAL, +extra TEXT, +PRIMARY KEY (task_id, round_id));''' + +CREATE_CLIENT_METRIC_SQL = ''' +CREATE TABLE IF NOT EXISTS client_metric +(task_id CHAR(50) NOT NULL, +round_id INT NOT NULL, +client_id CHAR(20) NOT NULL, +train_accuracy TEXT , +train_loss TEXT , +test_accuracy REAL , +test_loss REAL , +train_time REAL , +test_time REAL , +train_upload_time REAL , +test_upload_time REAL , +train_upload_size REAL , +train_download_size REAL , +test_upload_size REAL , +test_download_size REAL , +extra TEXT , +PRIMARY KEY (task_id, round_id, client_id));''' + + +def get_store(path=None, address=None): + if address: + return RemoteStorage(address) + else: + return SqliteStorage(path) + + +def get_storage_type(is_remote=True): + if is_remote: + return STORAGE_REMOTE + else: + return STORAGE_SQLITE + + +class SqliteStorage(object): + """SqliteStorage uses sqlite to save tracking metrics + + """ + + def __init__(self, database=None): + if database is None: + database = os.path.join(os.getcwd(), "tracker", DEFAULT_SQLITE_DB) + self._conn = sqlite3.connect(database, check_same_thread=False) + self.setup() + + def __del__(self): + self._conn.close() + + def setup(self): + with self._conn: + try: + self._retry_execute(CREATE_TASK_METRIC_SQL) + logger.info("Setup task metric table") + self._retry_execute(CREATE_ROUND_METRIC_SQL) + logger.info("Setup round metric table") + self._retry_execute(CREATE_CLIENT_METRIC_SQL) + logger.info("Setup client metric table") + except sqlite3.OperationalError as e: + logger.error(f"Failed to setup table, error: {e}") + + # ------------------ store metrics ------------------ + + def store_task_metric(self, metric): + sql = "INSERT INTO task_metric(task_id, config) VALUES (?, ?)" + try: + self._retry_execute(sql, metric.to_sql_param()) + logger.debug("Task metric saved successfully") + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + logger.error(f"Failed to store round metric, error: {e}") + + def store_round_metric(self, metric): + sql = ''' + INSERT INTO round_metric ( + task_id, + round_id, + accuracy, + loss, + round_time, + train_time, + test_time, + train_distribute_time, + test_distribute_time, + train_upload_size, + train_download_size, + test_upload_size, + test_download_size, + extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);''' + + try: + self._retry_execute(sql, metric.to_sql_param()) + logger.debug("Round metric saved successfully") + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + logger.error(f"Failed to store round metric {metric.task_id} {metric.round_id}, error: {e}") + + def store_client_metrics(self, metrics): + """Store a list of client metrics. If the client exists, replace the values. + :param metrics, list of client metrics to store, []. + """ + sql = ''' + INSERT INTO client_metric ( + task_id, + round_id, + client_id, + train_accuracy, + train_loss, + test_accuracy, + test_loss, + train_time, + test_time, + train_upload_time, + test_upload_time, + train_upload_size, + train_download_size, + test_upload_size, + test_download_size, + extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);''' + + params = [metric.to_sql_param() for metric in metrics] + + try: + with self._conn: + self._conn.executemany(sql, params) + logger.debug("Client metrics saved successfully") + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + logger.error(f"Failed to store client metrics, error: {e}") + + def store_client_train_metric(self, tid, rid, cid, train_loss, train_time, train_upload_time, + train_download_size, train_upload_size): + + sql = "INSERT INTO client_metric (task_id, round_id, client_id, train_loss, train_time, " \ + "train_upload_size, train_download_size, train_upload_size) VALUES (?, ? ,? ,?, ?, ?, ?, ?);" + + param = (tid, rid, cid, train_loss, train_time, train_upload_time, train_download_size, train_upload_size) + try: + self._retry_execute(sql, param) + except sqlite3.OperationalError as e: + logger.error("Failed to store client train metric, error: {}".format(e)) + + def store_client_test_metric(self, tid, rid, cid, test_acc, test_loss, test_time, + test_upload_time, test_download_size): + sql = "UPDATE client_metric SET test_accuracy=?, test_loss=?, test_time=? ,test_upload_size=?, " \ + "test_download_size=? WHERE task_id=? AND round_id=? AND client_id=?;" + param = (test_acc, test_loss, test_time, test_upload_time, test_download_size, tid, rid, cid) + try: + self._retry_execute(sql, param) + except sqlite3.OperationalError as e: + logger.error("Failed to store client test metric, error: {}".format(e)) + + # ------------------ get metrics ------------------ + + def get_task_metric(self, task_id): + sql = "SELECT * FROM task_metric WHERE task_id=?" + with self._conn: + result = self._conn.execute(sql, (task_id,)) + for r in result: + return r + + def get_round_metrics(self, task_id, rounds): + if rounds: + sql = "SELECT * FROM round_metric WHERE task_id=? AND round_id IN (%s)" % ("?," * len(rounds))[:-1] + param = [task_id] + rounds + else: + sql = "SELECT * FROM round_metric WHERE task_id=?" + param = (task_id,) + with self._conn: + result = self._conn.execute(sql, param) + return result + + def get_client_metrics(self, task_id, round_id, client_ids=None): + if client_ids: + sql = "SELECT * FROM client_metric WHERE task_id=? AND round_id=? \ + AND client_id IN (%s)" % ("?," * len(client_ids))[:-1] + param = [task_id, round_id] + client_ids + else: + sql = "SELECT * FROM client_metric WHERE task_id=? AND round_id=?" + param = (task_id, round_id) + with self._conn: + result = self._conn.execute(sql, param) + return result + + def get_round_train_test_time(self, tid, rounds, interval=1): + sql = "SELECT SUM(train_time+test_time) FROM round_metric WHERE task_id=? AND round_id=', '==', '>']) + ')' + parts = re.split(pat, line, maxsplit=1) + parts = [p.strip() for p in parts] + + info['package'] = parts[0] + if len(parts) > 1: + op, rest = parts[1:] + if ';' in rest: + # Handle platform specific dependencies + # http://setuptools.readthedocs.io/en/latest/setuptools.html#declaring-platform-specific-dependencies + version, platform_deps = map(str.strip, + rest.split(';')) + info['platform_deps'] = platform_deps + else: + version = rest # NOQA + info['version'] = (op, version) + yield info + + def parse_require_file(fpath): + with open(fpath, 'r') as f: + for line in f.readlines(): + line = line.strip() + if line and not line.startswith('#'): + for info in parse_line(line): + yield info + + def gen_packages_items(): + if exists(require_fpath): + for info in parse_require_file(require_fpath): + parts = [info['package']] + if with_version and 'version' in info: + parts.extend(info['version']) + if not sys.version.startswith('3.4'): + # apparently package_deps are broken in 3.4 + platform_deps = info.get('platform_deps') + if platform_deps is not None: + parts.append(';' + platform_deps) + item = ''.join(parts) + yield item + + packages = list(gen_packages_items()) + return packages + + +if __name__ == '__main__': + setup( + name='easyfl', + version=get_version(), + description='A low-code federated learning platform for dummies', + long_description=readme(), + long_description_content_type='text/markdown', + author='EasyFL Contributors', + author_email='easyfl.ai@gmail.com', + keywords=['federated learning', 'machine learning', 'distributed machine learning', 'computer vision'], + url='https://github.com/EasyFL-AI/EasyFL', + download_url='https://github.com/EasyFL-AI/EasyFL/archive/refs/tags/v0.1.0.tar.gz', + packages=find_packages(), + data_files=[('requirements', ['requirements/runtime.txt']), ('easyfl', ['easyfl/config.yaml'])], + include_package_data=True, + classifiers=[ + 'Development Status :: 3 - Alpha', + # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package + 'License :: OSI Approved :: Apache Software License', + 'Topic :: Software Development :: Build Tools', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], + license='Apache License 2.0', + install_requires=parse_requirements('requirements/runtime.txt'), + extras_require={ + 'all': parse_requirements('requirements.txt'), + }, + ext_modules=[], + zip_safe=False)