Skip to content

Commit 05fd877

Browse files
authored
Merge pull request #49 from simvue-io/pickled_objects
Support Python objects as artifacts
2 parents 951958f + 97bf378 commit 05fd877

File tree

15 files changed

+612
-68
lines changed

15 files changed

+612
-68
lines changed

.github/workflows/python-app.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
python -m pip install --upgrade pip
2929
pip install flake8 pytest
3030
pip install -e .
31-
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
31+
if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi
3232
- name: Lint with flake8
3333
run: |
3434
# stop the build if there are Python syntax errors or undefined names

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Change log
22

3+
## v0.8.0
4+
5+
* Support NumPy arrays, PyTorch tensors, Matplotlib and Plotly plots and picklable Python objects as artifacts.
6+
* (Bug fix) Events in offline mode didn't work.
7+
38
## v0.7.2
49

510
* Pydantic model is used for input validation.

examples/PyTorch/main.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Taken from https://github.com/pytorch/examples/blob/main/mnist/main.py
2+
from __future__ import print_function
3+
import argparse
4+
import torch
5+
import torch.nn as nn
6+
import torch.nn.functional as F
7+
import torch.optim as optim
8+
from torchvision import datasets, transforms
9+
from torch.optim.lr_scheduler import StepLR
10+
from simvue import Run
11+
12+
13+
class Net(nn.Module):
14+
def __init__(self):
15+
super(Net, self).__init__()
16+
self.conv1 = nn.Conv2d(1, 32, 3, 1)
17+
self.conv2 = nn.Conv2d(32, 64, 3, 1)
18+
self.dropout1 = nn.Dropout(0.25)
19+
self.dropout2 = nn.Dropout(0.5)
20+
self.fc1 = nn.Linear(9216, 128)
21+
self.fc2 = nn.Linear(128, 10)
22+
23+
def forward(self, x):
24+
x = self.conv1(x)
25+
x = F.relu(x)
26+
x = self.conv2(x)
27+
x = F.relu(x)
28+
x = F.max_pool2d(x, 2)
29+
x = self.dropout1(x)
30+
x = torch.flatten(x, 1)
31+
x = self.fc1(x)
32+
x = F.relu(x)
33+
x = self.dropout2(x)
34+
x = self.fc2(x)
35+
output = F.log_softmax(x, dim=1)
36+
return output
37+
38+
39+
def train(args, model, device, train_loader, optimizer, epoch, run):
40+
model.train()
41+
for batch_idx, (data, target) in enumerate(train_loader):
42+
data, target = data.to(device), target.to(device)
43+
optimizer.zero_grad()
44+
output = model(data)
45+
loss = F.nll_loss(output, target)
46+
loss.backward()
47+
optimizer.step()
48+
if batch_idx % args.log_interval == 0:
49+
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
50+
epoch, batch_idx * len(data), len(train_loader.dataset),
51+
100. * batch_idx / len(train_loader), loss.item()))
52+
run.log_metrics({"train.loss.%d" % epoch: float(loss.item())}, step=batch_idx)
53+
if args.dry_run:
54+
break
55+
56+
57+
def test(model, device, test_loader, epoch, run):
58+
model.eval()
59+
test_loss = 0
60+
correct = 0
61+
with torch.no_grad():
62+
for data, target in test_loader:
63+
data, target = data.to(device), target.to(device)
64+
output = model(data)
65+
test_loss += F.nll_loss(output, target, reduction='sum').item() # sum up batch loss
66+
pred = output.argmax(dim=1, keepdim=True) # get the index of the max log-probability
67+
correct += pred.eq(target.view_as(pred)).sum().item()
68+
69+
test_loss /= len(test_loader.dataset)
70+
test_accuracy = 100. * correct / len(test_loader.dataset)
71+
72+
print('\nTest set: Average loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format(
73+
test_loss, correct, len(test_loader.dataset),
74+
test_accuracy))
75+
run.log_metrics({'test.loss': test_loss,
76+
'test.accuracy': test_accuracy}, step=epoch)
77+
78+
79+
def main():
80+
# Training settings
81+
parser = argparse.ArgumentParser(description='PyTorch MNIST Example')
82+
parser.add_argument('--batch-size', type=int, default=64, metavar='N',
83+
help='input batch size for training (default: 64)')
84+
parser.add_argument('--test-batch-size', type=int, default=1000, metavar='N',
85+
help='input batch size for testing (default: 1000)')
86+
parser.add_argument('--epochs', type=int, default=14, metavar='N',
87+
help='number of epochs to train (default: 14)')
88+
parser.add_argument('--lr', type=float, default=1.0, metavar='LR',
89+
help='learning rate (default: 1.0)')
90+
parser.add_argument('--gamma', type=float, default=0.7, metavar='M',
91+
help='Learning rate step gamma (default: 0.7)')
92+
parser.add_argument('--no-cuda', action='store_true', default=False,
93+
help='disables CUDA training')
94+
parser.add_argument('--no-mps', action='store_true', default=False,
95+
help='disables macOS GPU training')
96+
parser.add_argument('--dry-run', action='store_true', default=False,
97+
help='quickly check a single pass')
98+
parser.add_argument('--seed', type=int, default=1, metavar='S',
99+
help='random seed (default: 1)')
100+
parser.add_argument('--log-interval', type=int, default=10, metavar='N',
101+
help='how many batches to wait before logging training status')
102+
parser.add_argument('--save-model', action='store_true', default=False,
103+
help='For Saving the current Model')
104+
args = parser.parse_args()
105+
use_cuda = not args.no_cuda and torch.cuda.is_available()
106+
use_mps = not args.no_mps and torch.backends.mps.is_available()
107+
108+
torch.manual_seed(args.seed)
109+
110+
if use_cuda:
111+
device = torch.device("cuda")
112+
elif use_mps:
113+
device = torch.device("mps")
114+
else:
115+
device = torch.device("cpu")
116+
117+
train_kwargs = {'batch_size': args.batch_size}
118+
test_kwargs = {'batch_size': args.test_batch_size}
119+
if use_cuda:
120+
cuda_kwargs = {'num_workers': 1,
121+
'pin_memory': True,
122+
'shuffle': True}
123+
train_kwargs.update(cuda_kwargs)
124+
test_kwargs.update(cuda_kwargs)
125+
126+
transform=transforms.Compose([
127+
transforms.ToTensor(),
128+
transforms.Normalize((0.1307,), (0.3081,))
129+
])
130+
dataset1 = datasets.MNIST('../data', train=True, download=True,
131+
transform=transform)
132+
dataset2 = datasets.MNIST('../data', train=False,
133+
transform=transform)
134+
train_loader = torch.utils.data.DataLoader(dataset1,**train_kwargs)
135+
test_loader = torch.utils.data.DataLoader(dataset2, **test_kwargs)
136+
137+
model = Net().to(device)
138+
optimizer = optim.Adadelta(model.parameters(), lr=args.lr)
139+
140+
scheduler = StepLR(optimizer, step_size=1, gamma=args.gamma)
141+
142+
run = Run()
143+
run.init(tags=['PyTorch'])
144+
145+
for epoch in range(1, args.epochs + 1):
146+
train(args, model, device, train_loader, optimizer, epoch, run)
147+
test(model, device, test_loader, epoch, run)
148+
scheduler.step()
149+
150+
if args.save_model:
151+
run.save(model.state_dict(), "output", name="mnist_cnn.pt")
152+
153+
run.close()
154+
155+
156+
if __name__ == '__main__':
157+
main()
158+

examples/PyTorch/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
torch
2+
torchvision
3+
simvue

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
long_description_content_type="text/markdown",
1717
url="https://simvue.io",
1818
platforms=["any"],
19-
install_requires=["requests", "msgpack", "tenacity", "pyjwt", "psutil", "pydantic"],
19+
install_requires=["dill", "requests", "msgpack", "tenacity", "pyjwt", "psutil", "pydantic", "plotly"],
2020
package_dir={'': '.'},
2121
packages=["simvue"],
2222
package_data={"": ["README.md"]},

simvue/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
from simvue.client import Client
33
from simvue.handler import Handler
44
from simvue.models import RunInput
5-
__version__ = '0.7.2'
5+
__version__ = '0.8.0'

simvue/client.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pickle
44
import requests
55

6+
from .serialization import Deserializer
67
from .utilities import get_auth
78

89
CONCURRENT_DOWNLOADS = 10
@@ -51,7 +52,7 @@ def list_artifacts(self, run, category=None):
5152

5253
return None
5354

54-
def get_artifact(self, run, name):
55+
def get_artifact(self, run, name, allow_pickle=False):
5556
"""
5657
Return the contents of the specified artifact
5758
"""
@@ -62,23 +63,23 @@ def get_artifact(self, run, name):
6263
except requests.exceptions.RequestException:
6364
return None
6465

65-
if response.status_code == 200 and response.json():
66-
url = response.json()[0]['url']
67-
68-
try:
69-
response = requests.get(url, timeout=DOWNLOAD_TIMEOUT)
70-
except requests.exceptions.RequestException:
71-
return None
72-
else:
66+
if response.status_code != 200:
7367
return None
7468

69+
url = response.json()[0]['url']
70+
mimetype = response.json()[0]['type']
71+
7572
try:
76-
content = pickle.loads(response.content)
77-
except:
78-
return response.content
79-
else:
73+
response = requests.get(url, timeout=DOWNLOAD_TIMEOUT)
74+
except requests.exceptions.RequestException:
75+
return None
76+
77+
content = Deserializer().deserialize(response.content, mimetype, allow_pickle)
78+
if content is not None:
8079
return content
8180

81+
return response.content
82+
8283
def get_artifact_as_file(self, run, name, path='./'):
8384
"""
8485
Download an artifact

simvue/offline.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import codecs
12
import json
23
import logging
34
import os
45
import time
6+
import uuid
57

6-
from .utilities import get_offline_directory, create_file
8+
from .utilities import get_offline_directory, create_file, prepare_for_api
79

810
logger = logging.getLogger(__name__)
911

@@ -90,9 +92,14 @@ def save_file(self, data):
9092
"""
9193
Save file
9294
"""
95+
if 'pickled' in data:
96+
temp_file = f"{self._directory}/temp-{str(uuid.uuid4())}.pickle"
97+
with open(temp_file, 'wb') as fh:
98+
fh.write(data['pickled'])
99+
data['pickledFile'] = temp_file
93100
unique_id = time.time()
94101
filename = f"{self._directory}/file-{unique_id}.json"
95-
self._write_json(filename, data)
102+
self._write_json(filename, prepare_for_api(data, False))
96103
return True
97104

98105
def add_alert(self, data):

simvue/remote.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import logging
22
import time
3-
import requests
43

54
from .api import post, put
6-
from .utilities import get_auth, get_expiry
5+
from .utilities import get_auth, get_expiry, prepare_for_api
76

87
logger = logging.getLogger(__name__)
98

@@ -53,10 +52,13 @@ def create_run(self, data):
5352

5453
return self._name
5554

56-
def update(self, data):
55+
def update(self, data, run=None):
5756
"""
5857
Update metadata, tags or status
5958
"""
59+
if run is not None:
60+
data['name'] = run
61+
6062
try:
6163
response = put(f"{self._url}/api/runs", self._headers, data)
6264
except Exception as err:
@@ -69,10 +71,13 @@ def update(self, data):
6971
self._error(f"Got status code {response.status_code} when updating run")
7072
return False
7173

72-
def set_folder_details(self, data):
74+
def set_folder_details(self, data, run=None):
7375
"""
7476
Set folder details
7577
"""
78+
if run is not None:
79+
data['run'] = run
80+
7681
try:
7782
response = put(f"{self._url}/api/folders", self._headers, data)
7883
except Exception as err:
@@ -85,13 +90,16 @@ def set_folder_details(self, data):
8590
self._error(f"Got status code {response.status_code} when updating folder details")
8691
return False
8792

88-
def save_file(self, data):
93+
def save_file(self, data, run=None):
8994
"""
9095
Save file
9196
"""
97+
if run is not None:
98+
data['run'] = run
99+
92100
# Get presigned URL
93101
try:
94-
response = post(f"{self._url}/api/data", self._headers, data)
102+
response = post(f"{self._url}/api/data", self._headers, prepare_for_api(data))
95103
except Exception as err:
96104
self._error(f"Got exception when preparing to upload file {data['name']} to object storage: {str(err)}")
97105
return False
@@ -105,22 +113,40 @@ def save_file(self, data):
105113

106114
if 'url' in response.json():
107115
url = response.json()['url']
108-
try:
109-
with open(data['originalPath'], 'rb') as fh:
110-
response = put(url, {}, fh, is_json=False, timeout=UPLOAD_TIMEOUT)
116+
if 'pickled' in data and 'pickledFile' not in data:
117+
try:
118+
response = put(url, {}, data['pickled'], is_json=False, timeout=UPLOAD_TIMEOUT)
111119
if response.status_code != 200:
112-
self._error(f"Got status code {response.status_code} when uploading file {data['name']} to object storage")
120+
self._error(f"Got status code {response.status_code} when uploading object {data['name']} to object storage")
113121
return None
114-
except Exception as err:
115-
self._error(f"Got exception when uploading file {data['name']} to object storage: {str(err)}")
116-
return None
122+
except Exception as err:
123+
self._error(f"Got exception when uploading object {data['name']} to object storage: {str(err)}")
124+
return None
125+
else:
126+
if 'pickledFile' in data:
127+
use_filename = data['pickledFile']
128+
else:
129+
use_filename = data['originalPath']
130+
131+
try:
132+
with open(use_filename, 'rb') as fh:
133+
response = put(url, {}, fh, is_json=False, timeout=UPLOAD_TIMEOUT)
134+
if response.status_code != 200:
135+
self._error(f"Got status code {response.status_code} when uploading file {data['name']} to object storage")
136+
return None
137+
except Exception as err:
138+
self._error(f"Got exception when uploading file {data['name']} to object storage: {str(err)}")
139+
return None
117140

118141
return True
119142

120-
def add_alert(self, data):
143+
def add_alert(self, data, run=None):
121144
"""
122145
Add an alert
123146
"""
147+
if run is not None:
148+
data['run'] = run
149+
124150
try:
125151
response = post(f"{self._url}/api/alerts", self._headers, data)
126152
except Exception as err:

0 commit comments

Comments
 (0)