Schema: a simple data tool#
Note
Although excelbird’s Frame integrates with the xb.Schema class, Schema on its own is not related to or dependent on excelbird, and in the future will be its own separate installable package.
xb.Schema is a simple tool for data wrangling pipelines that helps you stay organized, improves readability and helps avoid bugs.
Where traditional dataframe schema classes define what should go inside a dataframe’s columns, Schema defines when those columns should be, and how they got there. It defines a dataframe’s state at a given point in time, and provides the tools/methods needed to help you arrive at that state, change it, and move to new states seamlessly.
It’s a simple class, designed for ease of use and exceptional readability.
We’ll cover functionality shortly, but first, here’s what we mean by ‘readability’:
[ ]:
sch_person = Schema(
first_name=("FName", "First Name"),
last_name=("LName", "Last Name"),
favorite_food="Favorite Food",
)
sch_company = Schema(
comp_name=("Companyname", "Company Name"),
market_cap="Market Capitalization",
favorite_food="Preferred Employee Favorite Food",
)
sch_output = Schema(
sch_person[[
'last_name',
'age',
]],
sch_company[[
'comp_name',
]],
is_executive="Person is Executive"
)
Without touching any data, the reader already knows exactly what’s supposed to happen in the script that follows:
There are two input sources, person and company. We know what their columns are called in the input data, what we’d like to call them when they’re eventually used to generate a final output of some kind, and given them short, python-friendly names to use during our workflow.
The output schema indicaets that the script must join person and company to get its output, and where each field is coming from.
The script needs to add a new custom column,
is_executive
Creating a Schema#
Schema is a subclass of dictionary, where
Positional args: existing Schemas, or slices from them
Keyword args:
Keys: python-friendly variable names
Values: str, or tuple. If tuple:
Item 0: Input column name
Item 1: Output column name (optional)
Schema Examples#
[1]:
from excelbird import Schema
import pandas as pd
First we’ll create some sample data. Assume df_employee_raw was read in from somewhere else
[60]:
df_employee_raw = pd.DataFrame( [["Jared", "Richards", 45, 80, "Marketing"], ["Emily", "Seitz", 87, 60, "Analytics"], ["Nick", "Smarts", 23, 50, "Marketing"]], columns=["Emp FName", "Emp LName", "Hours Worked", "Emp Hourly Rate", "Domain"],)
df_employee_raw
[60]:
| Emp FName | Emp LName | Hours Worked | Emp Hourly Rate | Domain | |
|---|---|---|---|---|---|
| 0 | Jared | Richards | 45 | 80 | Marketing |
| 1 | Emily | Seitz | 87 | 60 | Analytics |
| 2 | Nick | Smarts | 23 | 50 | Marketing |
Elsewhere, we’ve defined the following
[61]:
schema = Schema(
# var_name = ("Input Col Name", "Output Col Name")
last_name=("Emp LName", "Last Name"),
first_name=("Emp FName", "First Name"),
hours="Hours Worked", # will be applied to both input and output
rate=("Emp Hourly Rate", "Hourly Rate"),
)
schema_partial = schema[["rate", "last_name"]]
schema
[61]:
| Input | Output | |
|---|---|---|
| last_name | Emp LName | Last Name |
| first_name | Emp FName | First Name |
| hours | Hours Worked | Hours Worked |
| rate | Emp Hourly Rate | Hourly Rate |
Now we can use the schema after reading in new data
[62]:
df = schema.select_inputs(df_employee_raw)
df
[62]:
| last_name | first_name | hours | rate | |
|---|---|---|---|---|
| 0 | Richards | Jared | 45 | 80 |
| 1 | Seitz | Emily | 87 | 60 |
| 2 | Smarts | Nick | 23 | 50 |
Calling .select_inputs() did the following
Check if all desired columns are present in input data, and throw an error if any are missing, informing you of all the missing columns at once
Select only the desired columns
Rename them to your desired standardized variable names
Re-order them to the order they follow in the schema
In the real world, your code will probably combine things from multiple datasets, and create new columns. Creating a composite schema is easy
[63]:
schema_composite = Schema(
schema[[
"hours",
"first_name"
]],
schema_partial, # include all from schema_partial
daily_rate="Daily Rate",
bonus="Bonus",
)
schema_composite
[63]:
| Input | Output | |
|---|---|---|
| hours | Hours Worked | Hours Worked |
| first_name | Emp FName | First Name |
| rate | Emp Hourly Rate | Hourly Rate |
| last_name | Emp LName | Last Name |
| daily_rate | Daily Rate | Daily Rate |
| bonus | Bonus | Bonus |
We’ll be notified with a descriptive error if we’re missing anything at output time
[64]:
df_out = schema_composite.select_outputs(df)
df_out
---------------------------------------------------------------------------
SchemaError Traceback (most recent call last)
Cell In[64], line 1
----> 1 df_out = schema_composite.select_outputs(df)
2 df_out
File ~/Library/CloudStorage/OneDrive-EY/repo/python-packages/excelbird/excelbird/schema.py:427, in Schema.select_outputs(self, df)
425 missing = [col for col in self.keys() if col not in df.columns]
426 if len(missing) > 0:
--> 427 raise SchemaError(f"Please add columns, {missing} before outputting.")
428 df = self.rename_vars_to_outputs(df)
429 return df[[k for k in self.outputs()]]
SchemaError: Please add columns, ['daily_rate', 'bonus'] before outputting.
[65]:
df["daily_rate"] = df.rate * 8
df["bonus"] = 100
df_out = schema_composite.select_outputs(df)
df_out
[65]:
| Hours Worked | First Name | Hourly Rate | Last Name | Daily Rate | Bonus | |
|---|---|---|---|---|---|---|
| 0 | 45 | Jared | 80 | Richards | 640 | 100 |
| 1 | 87 | Emily | 60 | Seitz | 480 | 100 |
| 2 | 23 | Nick | 50 | Smarts | 400 | 100 |
A more realistic use-case#
Assume we’re reading the following input data from files
[66]:
df_emp_raw = pd.DataFrame( [["Jared", "Richards", 24, "red"], ["Emily", "Seitz", 55, "green"], ["Nick", "Smarts", 33, "blue"]], columns=["Employee First Name", "Employee Last Name", "Age", "Favorite Color"],)
df_roster_raw = pd.DataFrame( [["Jared", "Richards", 45, 80, "Marketing"], ["Emily", "Seitz", 87, 60, "Analytics"], ["Nick", "Smarts", 23, 50, "Marketing"]], columns=["Emp FName", "Emp LName", "Hours Worked", "Emp Hourly Rate", "Domain"],)
display(df_emp_raw, df_roster_raw)
| Employee First Name | Employee Last Name | Age | Favorite Color | |
|---|---|---|---|---|
| 0 | Jared | Richards | 24 | red |
| 1 | Emily | Seitz | 55 | green |
| 2 | Nick | Smarts | 33 | blue |
| Emp FName | Emp LName | Hours Worked | Emp Hourly Rate | Domain | |
|---|---|---|---|---|---|
| 0 | Jared | Richards | 45 | 80 | Marketing |
| 1 | Emily | Seitz | 87 | 60 | Analytics |
| 2 | Nick | Smarts | 23 | 50 | Marketing |
We’ve got two input data sources: employee data, and roster data (see above).
They have shared columns (first and last name) which we want to join on, but are named differently.
In our code, we would need to rename these columns before joining. We would also need to reference the exact names of the input columns, which is not only annoying (because they’re long) but also inconvenient if the input column names change.
We also might write our finished data to a file that might later be read by another script in the pipeline. What if we change the column names of our outputted data? Then all other scripts who read our outputted data will need to be changed. That’s also inconvenient.
Solution: Declare ahead of time:
A schema for each of our input datasets
A schema for our final output. Then, another script in the pipeline who reads our output can reference this schema when reading data.
[67]:
sch_employee = Schema(
last_name=("Employee Last Name", "Last Name"),
first_name=("Employee First Name", "First Name"),
age="Age",
)
sch_roster = Schema(
last_name="Emp LName",
first_name="Emp FName",
hours="Hours Worked",
rate=("Emp Hourly Rate", "Hourly Rate"),
)
sch_output = Schema(
sch_employee[[
"first_name",
"last_name",
"age"
]],
sch_roster[["rate"]],
pay="Total Pay",
notes="Notes",
)
Here’s another look at the source data
[68]:
display(df_emp_raw, df_roster_raw)
| Employee First Name | Employee Last Name | Age | Favorite Color | |
|---|---|---|---|---|
| 0 | Jared | Richards | 24 | red |
| 1 | Emily | Seitz | 55 | green |
| 2 | Nick | Smarts | 33 | blue |
| Emp FName | Emp LName | Hours Worked | Emp Hourly Rate | Domain | |
|---|---|---|---|---|---|
| 0 | Jared | Richards | 45 | 80 | Marketing |
| 1 | Emily | Seitz | 87 | 60 | Analytics |
| 2 | Nick | Smarts | 23 | 50 | Marketing |
Now let’s apply the schemas to make selections and format our input data
[69]:
df_emp = sch_employee.select_inputs(df_emp_raw)
df_roster = sch_roster.select_inputs(df_roster_raw)
display(df_emp, df_roster)
| last_name | first_name | age | |
|---|---|---|---|
| 0 | Richards | Jared | 24 |
| 1 | Seitz | Emily | 55 |
| 2 | Smarts | Nick | 33 |
| last_name | first_name | hours | rate | |
|---|---|---|---|---|
| 0 | Richards | Jared | 45 | 80 |
| 1 | Seitz | Emily | 87 | 60 |
| 2 | Smarts | Nick | 23 | 50 |
[70]:
df = df_emp.merge(df_roster, on=["first_name", "last_name"])
df
[70]:
| last_name | first_name | age | hours | rate | |
|---|---|---|---|---|---|
| 0 | Richards | Jared | 24 | 45 | 80 |
| 1 | Seitz | Emily | 55 | 87 | 60 |
| 2 | Smarts | Nick | 33 | 23 | 50 |
[71]:
df["pay"] = df.hours * df.rate
df["notes"] = ""
# Let's try that again
df_output = sch_output.select_outputs(df)
df_output
[71]:
| First Name | Last Name | Age | Hourly Rate | Total Pay | Notes | |
|---|---|---|---|---|---|---|
| 0 | Jared | Richards | 24 | 80 | 3600 | |
| 1 | Emily | Seitz | 55 | 60 | 5220 | |
| 2 | Nick | Smarts | 33 | 50 | 1150 |
Now, how can we read in the output of the previous operations, and select from the new output names without creating a new schema?
Call .reset_inputs()
[72]:
# Pretend we're reading in the previous output from a file here
df_raw = df_output.copy()
df_raw
[72]:
| First Name | Last Name | Age | Hourly Rate | Total Pay | Notes | |
|---|---|---|---|---|---|---|
| 0 | Jared | Richards | 24 | 80 | 3600 | |
| 1 | Emily | Seitz | 55 | 60 | 5220 | |
| 2 | Nick | Smarts | 33 | 50 | 1150 |
[73]:
schema = sch_output.reset_inputs()
df = schema.select_inputs(df_raw)
df
[73]:
| first_name | last_name | age | rate | pay | notes | |
|---|---|---|---|---|---|---|
| 0 | Jared | Richards | 24 | 80 | 3600 | |
| 1 | Emily | Seitz | 55 | 60 | 5220 | |
| 2 | Nick | Smarts | 33 | 50 | 1150 |
Create a schema that knows how to read its input data#
If you have some prep-work that’s always required after reading a certain dataset, it would be quite a luxury if the schema could handle this itself.
Or, what if you have multiple input sources that need to be joined immediately after being read? Instead of creating a separate schema for each, just create a single schema with the shared fields from each, and tell it how to populate each field.
Example: Concat first and last name into full name on read
Let’s go back to the first example. Instead of declaring first_name and last_name, we’ll just declare name, and tell the schema how to populate it
[74]:
# Input source
df_employee_raw
[74]:
| Emp FName | Emp LName | Hours Worked | Emp Hourly Rate | Domain | |
|---|---|---|---|---|---|
| 0 | Jared | Richards | 45 | 80 | Marketing |
| 1 | Emily | Seitz | 87 | 60 | Analytics |
| 2 | Nick | Smarts | 23 | 50 | Marketing |
[75]:
# Create a subclass of Schema with a read() function
class EmployeeSchema(Schema):
def read(self):
# pretend to read in data from a source
df = df_employee_raw
# Create name column
df["Employee Name"] = df["Emp FName"] + " " + df["Emp LName"]
return df
schema = EmployeeSchema(
name="Employee Name",
hours="Hours Worked",
rate=("Emp Hourly Rate", "Hourly Rate"),
)
df = schema.read()
df = schema.select_inputs(df)
df
[75]:
| name | hours | rate | |
|---|---|---|---|
| 0 | Jared Richards | 45 | 80 |
| 1 | Emily Seitz | 87 | 60 |
| 2 | Nick Smarts | 23 | 50 |