Skip to content

Commit c5a9468

Browse files
authored
Introducing whylogs integration to flytekit (#1104)
* Introducing whylogs integration to flytekit * Details: It creates a schema for "transforming" whylogs' DatasetProfileView objects to and from the FlyteSchema. This PR also creates two renderers with FlyteDeck, one for the Constraints report and the other for the SummaryDriftReport. Signed-off-by: murilommen <murilommen@gmail.com> * refactoring tests changes: breaking down single test_schema.py unit test into test_schema + test_renderer, each with their own concerns Signed-off-by: murilommen <murilommen@gmail.com> * styleguide fixes details: removing extra lines later caught by flake8 Signed-off-by: murilommen <murilommen@gmail.com> * add plugin to ci checks Signed-off-by: murilommen <murilommen@gmail.com> * adding requirements files + lint fixes Signed-off-by: murilommen <murilommen@gmail.com> * removing entrypoints from setup.py Signed-off-by: murilommen <murilommen@gmail.com> * adding a python 3.10 build restriction Signed-off-by: murilommen <murilommen@gmail.com> * fixing protobuf's version to < 4 details: whylogs require protobuf > 3.15, but flyteidl can't handle protobuf < 4.0, so adding it as a restriction to setup.py Signed-off-by: murilommen <murilommen@gmail.com> * lint setup.py details: ran black command on the plugin implementation and caught something with black Signed-off-by: murilommen <murilommen@gmail.com> * fixing comma on setup.py Signed-off-by: murilommen <murilommen@gmail.com>
1 parent ee9976d commit c5a9468

11 files changed

Lines changed: 362 additions & 0 deletions

File tree

.github/workflows/pythonbuild.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ jobs:
8686
- flytekit-snowflake
8787
- flytekit-spark
8888
- flytekit-sqlalchemy
89+
- flytekit-whylogs
8990
exclude:
9091
# flytekit-modin depends on ray which does not have a 3.10 wheel yet.
9192
# Issue tracked in https://github.com/ray-project/ray/issues/19116.
@@ -103,6 +104,12 @@ jobs:
103104
plugin-names: "flytekit-onnx-scikitlearn"
104105
- python-version: 3.10
105106
plugin-names: "flytekit-onnx-tensorflow"
107+
# whylogs-sketching library does not have a 3.10 build yet
108+
# Issue tracked: https://github.com/whylabs/whylogs/issues/697
109+
- python-version: 3.10
110+
plugin-names: "flytekit-whylogs"
111+
112+
106113
steps:
107114
- uses: actions/checkout@v2
108115
- name: Set up Python ${{ matrix.python-version }}

plugins/flytekit-whylogs/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Flytekit whylogs Plugin
2+
3+
whylogs is an open source library for logging any kind of data. With whylogs,
4+
you are able to generate summaries of datasets (called whylogs profiles) which
5+
can be used to:
6+
7+
- Create data constraints to know whether your data looks the way it should
8+
- Quickly visualize key summary statistics about a dataset
9+
- Track changes in a dataset over time
10+
11+
```bash
12+
pip install flytekitplugins-whylogs
13+
```
14+
15+
To generate profiles, you can add a task like the following:
16+
17+
```python
18+
from whylogs.core import DatasetProfileView
19+
import whylogs as ylog
20+
21+
import pandas as pd
22+
23+
@task
24+
def profile(df: pd.DataFrame) -> DatasetProfileView:
25+
result = ylog.log(df) # Various overloads for different common data types exist
26+
profile = result.view()
27+
return profile
28+
```
29+
30+
>**NOTE:** You'll be passing around `DatasetProfileView` from tasks, not `DatasetProfile`.
31+
32+
## Validating Data
33+
34+
A common step in data pipelines is data validation. This can be done in
35+
`whylogs` through the constraint feature. You'll be able to create failure tasks
36+
if the data in the workflow doesn't conform to some configured constraints, like
37+
min/max values on features, data types on features, etc.
38+
39+
```python
40+
@task
41+
def validate_data(profile: DatasetProfileView):
42+
column = profile.get_column("my_column")
43+
print(column.to_summary_dict()) # To see available things you can validate against
44+
builder = ConstraintsBuilder(profile)
45+
numConstraint = MetricConstraint(
46+
name='numbers between 0 and 4 only',
47+
condition=lambda x: x.min > 0 and x.max < 4,
48+
metric_selector=MetricsSelector(metric_name='distribution', column_name='my_column'))
49+
builder.add_constraint(numConstraint)
50+
constraint = builder.build()
51+
valid = constraint.validate()
52+
53+
if(not valid):
54+
raise Exception("Invalid data found")
55+
```
56+
57+
Check out our [constraints notebook](https://github.com/whylabs/whylogs/blob/1.0.x/python/examples/basic/MetricConstraints.ipynb) for more examples.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .renderer import WhylogsConstraintsRenderer, WhylogsSummaryDriftRenderer
2+
from .schema import WhylogsDatasetProfileTransformer
3+
4+
__all__ = ["WhylogsDatasetProfileTransformer", "WhylogsConstraintsRenderer", "WhylogsSummaryDriftRenderer"]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import whylogs as why
2+
from pandas import DataFrame
3+
from whylogs.core.constraints import Constraints
4+
from whylogs.viz import NotebookProfileVisualizer
5+
6+
7+
class WhylogsSummaryDriftRenderer:
8+
"""
9+
Creates a whylogs' Summary Drift report from two pandas DataFrames. One of them
10+
is the reference and the other one is the target data, meaning that this is what
11+
the report will compare it against.
12+
"""
13+
14+
@staticmethod
15+
def to_html(reference_data: DataFrame, target_data: DataFrame) -> str:
16+
"""
17+
This static method will profile the input data and then generate an HTML report
18+
with the Summary Drift calculations for all the dataframe's columns
19+
20+
:param reference_data: The DataFrame that will be the reference for the drift report
21+
:type: pandas.DataFrame
22+
23+
:param target_data: The data to compare against and create the Summary Drift report
24+
:type target_data: pandas.DataFrame
25+
"""
26+
27+
target_view = why.log(target_data).view()
28+
reference_view = why.log(reference_data).view()
29+
viz = NotebookProfileVisualizer()
30+
viz.set_profiles(target_profile_view=target_view, reference_profile_view=reference_view)
31+
return viz.summary_drift_report().data
32+
33+
34+
class WhylogsConstraintsRenderer:
35+
"""
36+
Creates a whylogs' Constraints report from a `Constraints` object. Currently our API
37+
requires the user to have a profiled DataFrame in place to be able to use it. Then the report
38+
will render a nice HTML that will let users check which constraints passed or failed their
39+
logic. An example constraints object definition can be written as follows:
40+
41+
.. code-block:: python
42+
43+
profile_view = why.log(df).view()
44+
builder = ConstraintsBuilder(profile_view)
45+
num_constraint = MetricConstraint(
46+
name=f'numbers between {min_value} and {max_value} only',
47+
condition=lambda x: x.min > min_value and x.max < max_value,
48+
metric_selector=MetricsSelector(
49+
metric_name='distribution',
50+
column_name='sepal_length'
51+
)
52+
)
53+
54+
builder.add_constraint(num_constraint)
55+
constraints = builder.build()
56+
57+
Each Constraints object (builder.build() in the former example) can have as many constraints as
58+
desired. If you want to learn more, check out our docs and examples at https://whylogs.readthedocs.io/
59+
"""
60+
61+
@staticmethod
62+
def to_html(constraints: Constraints) -> str:
63+
viz = NotebookProfileVisualizer()
64+
report = viz.constraints_report(constraints=constraints)
65+
return report.data
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import Type
2+
3+
from whylogs.core import DatasetProfileView
4+
5+
from flytekit import BlobType, FlyteContext
6+
from flytekit.extend import T, TypeEngine, TypeTransformer
7+
from flytekit.models.literals import Blob, BlobMetadata, Literal, Scalar
8+
from flytekit.models.types import LiteralType
9+
10+
11+
class WhylogsDatasetProfileTransformer(TypeTransformer[DatasetProfileView]):
12+
"""
13+
Transforms whylogs Dataset Profile Views to and from a Schema (typed/untyped)
14+
"""
15+
16+
_TYPE_INFO = BlobType(format="binary", dimensionality=BlobType.BlobDimensionality.SINGLE)
17+
18+
def __init__(self):
19+
super(WhylogsDatasetProfileTransformer, self).__init__("whylogs-profile-transformer", t=DatasetProfileView)
20+
21+
def get_literal_type(self, t: Type[DatasetProfileView]) -> LiteralType:
22+
return LiteralType(blob=self._TYPE_INFO)
23+
24+
def to_literal(
25+
self,
26+
ctx: FlyteContext,
27+
python_val: DatasetProfileView,
28+
python_type: Type[DatasetProfileView],
29+
expected: LiteralType,
30+
) -> Literal:
31+
remote_path = ctx.file_access.get_random_remote_directory()
32+
local_dir = ctx.file_access.get_random_local_path()
33+
python_val.write(local_dir)
34+
ctx.file_access.upload(local_dir, remote_path)
35+
return Literal(scalar=Scalar(blob=Blob(uri=remote_path, metadata=BlobMetadata(type=self._TYPE_INFO))))
36+
37+
def to_python_value(self, ctx: FlyteContext, lv: Literal, expected_python_type: Type[DatasetProfileView]) -> T:
38+
local_dir = ctx.file_access.get_random_local_path()
39+
ctx.file_access.download(lv.scalar.blob.uri, local_dir)
40+
return DatasetProfileView.read(local_dir)
41+
42+
def to_html(
43+
self, ctx: FlyteContext, python_val: DatasetProfileView, expected_python_type: Type[DatasetProfileView]
44+
) -> str:
45+
pandas_profile = str(python_val.to_pandas().to_html())
46+
header = str("<h1>Profile View</h1> \n")
47+
return header + pandas_profile
48+
49+
50+
TypeEngine.register(WhylogsDatasetProfileTransformer())
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.
2+
-e file:.#egg=flytekitplugins-whylogs
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#
2+
# This file is autogenerated by pip-compile with python 3.9
3+
# To update, run:
4+
#
5+
# pip-compile requirements.in
6+
#
7+
-e file:.#egg=flytekitplugins-whylogs
8+
# via -r requirements.in
9+
flake8==4.0.1
10+
# via whylogs
11+
mccabe==0.6.1
12+
# via flake8
13+
protobuf==3.20.1
14+
# via
15+
# flytekitplugins-whylogs
16+
# whylogs
17+
pycodestyle==2.8.0
18+
# via flake8
19+
pyflakes==2.4.0
20+
# via flake8
21+
typing-extensions==4.3.0
22+
# via whylogs
23+
whylogs==1.0.6
24+
# via flytekitplugins-whylogs
25+
whylogs-sketching==3.4.1.dev2
26+
# via whylogs

plugins/flytekit-whylogs/setup.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from setuptools import setup
2+
3+
PLUGIN_NAME = "whylogs"
4+
5+
microlib_name = f"flytekitplugins-{PLUGIN_NAME}"
6+
7+
plugin_requires = ["protobuf>=3.15,<4.0.0", "whylogs", "whylogs[viz]"]
8+
9+
__version__ = "0.0.0+develop"
10+
11+
setup(
12+
name=microlib_name,
13+
version=__version__,
14+
author="whylabs",
15+
author_email="support@whylabs.ai",
16+
description="Enable the use of whylogs profiles to be used in flyte tasks to get aggregate statistics about data.",
17+
url="https://github.com/flyteorg/flytekit/tree/master/plugins/flytekit-whylogs",
18+
long_description=open("README.md").read(),
19+
long_description_content_type="text/markdown",
20+
namespace_packages=["flytekitplugins"],
21+
packages=[f"flytekitplugins.{PLUGIN_NAME}"],
22+
install_requires=plugin_requires,
23+
license="apache2",
24+
python_requires=">=3.7",
25+
classifiers=[
26+
"Intended Audience :: Science/Research",
27+
"Intended Audience :: Developers",
28+
"License :: OSI Approved :: Apache Software License",
29+
"Programming Language :: Python :: 3.8",
30+
"Topic :: Scientific/Engineering",
31+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
32+
"Topic :: Software Development",
33+
"Topic :: Software Development :: Libraries",
34+
"Topic :: Software Development :: Libraries :: Python Modules",
35+
],
36+
)

plugins/flytekit-whylogs/tests/__init__.py

Whitespace-only changes.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Optional
2+
3+
import numpy as np
4+
import pandas as pd
5+
import whylogs as why
6+
from flytekitplugins.whylogs.renderer import WhylogsConstraintsRenderer, WhylogsSummaryDriftRenderer
7+
from whylogs.core.constraints import ConstraintsBuilder, MetricConstraint, MetricsSelector
8+
9+
import flytekit
10+
from flytekit import task, workflow
11+
12+
13+
@task
14+
def make_data(n_rows: int) -> pd.DataFrame:
15+
data = {
16+
"sepal_length": np.random.random_sample(n_rows),
17+
"sepal_width": np.random.random_sample(n_rows),
18+
"petal_length": np.random.random_sample(n_rows),
19+
"petal_width": np.random.random_sample(n_rows),
20+
"species": np.random.choice(["virginica", "setosa", "versicolor"], n_rows),
21+
"species_id": np.random.choice([1, 2, 3], n_rows),
22+
}
23+
return pd.DataFrame(data)
24+
25+
26+
@task
27+
def run_constraints(df: pd.DataFrame, min_value: Optional[float] = 0.0, max_value: Optional[float] = 4.0) -> bool:
28+
# This API constraints workflow is very flexible but a bit cumbersome.
29+
# It will be simplified in the future, so for now we'll stick with injecting
30+
# a Constraints object to the renderer.
31+
profile_view = why.log(df).view()
32+
builder = ConstraintsBuilder(profile_view)
33+
num_constraint = MetricConstraint(
34+
name=f"numbers between {min_value} and {max_value} only",
35+
condition=lambda x: x.min > min_value and x.max < max_value,
36+
metric_selector=MetricsSelector(metric_name="distribution", column_name="sepal_length"),
37+
)
38+
39+
builder.add_constraint(num_constraint)
40+
constraints = builder.build()
41+
42+
renderer = WhylogsConstraintsRenderer()
43+
flytekit.Deck("constraints", renderer.to_html(constraints=constraints))
44+
45+
return constraints.validate()
46+
47+
48+
@workflow
49+
def whylogs_renderers_workflow(min_value: float, max_value: float) -> bool:
50+
df = make_data(n_rows=10)
51+
validated = run_constraints(df=df, min_value=min_value, max_value=max_value)
52+
return validated
53+
54+
55+
def test_constraints_passing():
56+
validated = whylogs_renderers_workflow(min_value=0.0, max_value=1.0)
57+
assert validated is True
58+
59+
60+
def test_constraints_failing():
61+
validated = whylogs_renderers_workflow(min_value=-1.0, max_value=0.0)
62+
assert validated is False
63+
64+
65+
def test_summary_drift_report_is_written():
66+
renderer = WhylogsSummaryDriftRenderer()
67+
new_data = make_data(n_rows=10)
68+
reference_data = make_data(n_rows=100)
69+
70+
report = renderer.to_html(target_data=new_data, reference_data=reference_data)
71+
assert report is not None
72+
assert isinstance(report, str)
73+
assert "Profile Summary" in report

0 commit comments

Comments
 (0)