patimus-prime
disable autoplay for embed in main site
3408623
Raw
History Blame Contribute Delete
25.4 kB
"""
Streamlit version of the initial notebook here:
https://github.com/patimus-prime/ML_notebooks/blob/master/high-throughput-analysis.ipynb
"""
import streamlit as st
import pandas as pd
import matplotlib as plt
import seaborn as sns
import statsmodels.api as sm
import statsmodels.formula.api as smf
from datasets import load_dataset
import plotly.express as px
# https://docs.streamlit.io/library/components/components-api
import streamlit.components.v1 as components
# And some extras to have fun with this go:
# I got weird errors, 28 June 2023
# pip install streamlit-extras
# pip install streamlit-elements
#
st.image(
"memes/sneakpeek.png",
)
colA, colB = st.columns(2)
with colA:
st.write(
"""
# Welcome!
Objective: In an experiment with multiple machines running
separate but similar instruments, prone to inefficiency, or with
unstable samples, how can you identify statistically significant
samples? In this case study, we are searching for samples, strains,
that are producing the most amount of target molecule.
We'll use some fun statistics to identify prime candidates, and
Streamlit to make everything beautiful and relatively interactive.
"""
)
with colB:
components.iframe(
"https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/playlists/1644215929&color=%23ff5500\
&auto_play=false\
&hide_related=false\
&show_comments=false\
&show_user=false\
&show_reposts=false\
&show_teaser=false\
&visual=false\
&buying=false",
width=int(166 * 2),
height=int(166 * 1.5),
)
st.write(
"""
Load in our data from Huggingface, available here: https://huggingface.co/patimus-prime
---
"""
)
# datasets from huggingface assume train data
# https://huggingface.co/docs/datasets/load_hub
with st.echo():
dataset = load_dataset("patimus-prime/strain_selection", split="train")
df = pd.DataFrame(dataset)
# if you don't specify split="train" above, must do the coupe following lines
# view dataset, from wher which we can se we must grab one of hte indeces for data
# dataset["train"]
# df = pd.DataFrame(dataset['train'])
# Modify stacker_postion to something less nebulous
df["time_mins"] = df["stacker_position"].apply(lambda x: 3 * x - 3)
# st.dataframe(df)
st.write(
"Let's take a look at the first few rows...",
df.head(),
"...and the 'describe' method, which has FORESHADOWING",
df.describe(),
"And, plotting the data of course:",
)
fig = px.scatter(
df,
x="time_mins",
y="value",
# size="pop",
color="well_type",
hover_name="strain",
hover_data="value",
symbol="robot",
# log_x=True,
# size_max=60,
)
st.plotly_chart(fig, use_container_width=True)
st.write(
"""
A couple things become apparent:
1. We're going to need to resolve the data being from multiple machines.
2. Values are trending downward with time/stacker position. Therefore, options:
time-series analysis, analyze per t-point, or REMOVE THE INFLUENCE OF TIME 👹
3. Plotly + Streamlit = Awesome
Our process control is also decreasing with time; this, as a control, should
remain constant. Suggests that overtime, either the control and
samples are degrading in step or the instrument is affected.
So: detrend the data, prove robots comparable with ANOVA, profit.
# Detrend the data
Assumption: The robots are comparable.
Let's detrend first to be able to pool all day per time point,
then we can have more statistical OOMPH to do ANOVA and
demonstrate the robots may be pooled also and back up our assumption.
Among the options to detrend, e.g. subtracting a rolling average/statistic,
I choose subtracting a line of best fit. The math is more transparent. And
we can fit a line of best fit per well type; in case, perhaps,
the different strain types do have some ongoing, unique phenomenon decaying their value.
"""
)
# note streamlit will use width to also adjust height
st.image("memes/zapp.jpg", width=300)
st.write(
"""
Big bonus for Streamlit:
Big improvement over using matplotlib/seaborn:
plotly express allows one to use OLS fits and also report the
stats. Based on what's coming out I'd guess they use statsmodels.
https://plotly.com/python/linear-fits/
Oh, maybe you'd like to see how LUXURIOUS the code is for this
"""
)
with st.echo():
fig2 = px.scatter(
df,
x="time_mins",
y="value",
color="well_type",
hover_name="strain",
hover_data="value",
symbol="robot",
trendline="ols",
)
st.plotly_chart(fig2)
stats = px.get_trendline_results(fig2)
st.write(stats.px_fit_results.iloc[0].summary())
st.write(
"""
---
Hit the Home button in the graph if you lose the trendlines.
Also you can hit labels in legend to toggle on/off on graph.
Pretty cool. However, the trendline summary
produced below the graph is not really helpful in this case,
the trendlines produced are at least visually similar,
but what would be helpful for our math
is if the trendlines individually per well type
are similar enough to subtract and not miss out on some
phenomenon occurring per well.
"Just because you're paranoid, doesn't mean they aren't after you."
-- Joseph Heller
"""
)
# This next section in the code is to get the OLS trendlines
# for each well. Maybe with some trickery you could do it with the plotly stuff.
pc_data = df[df.well_type == "Process Control"]
pc_ols = smf.ols("value ~ time_mins", data=pc_data).fit()
pcs = pc_ols.summary()
ps_data = df[df.well_type == "Parent Strain"]
ps_ols = smf.ols("value ~ time_mins", data=ps_data).fit()
pss = ps_ols.summary()
x_data = df[df.well_type == "Standard Well"]
x_ols = smf.ols("value ~ time_mins", data=x_data).fit()
xs = x_ols.summary()
# A chance to see these summares side by side
# using streamlit columns, make everything pretty pretty prettcol1,
st.write(
"Feel free to skip past the tables.\
What we're looking for is the second row for coef, the slope of the lines."
)
if "button" not in st.session_state:
st.session_state.button = False
def click_button():
st.session_state.button = not st.session_state.button
st.button("Show delicious statistics", on_click=click_button)
if st.session_state.button:
st.header("Process Control Summary")
pcs
st.header("Parent Strain Summary")
pss
st.header("Candidate Strains Summary")
xs
else:
st.write("")
st.write(
"""
So: PC, -26, PS, -33, Candidates, -33.
Not perfect, and not much we can do but assume we can go on
in this analysis, while NOTING!! that this process control and our
strains, control and candidates, are being treated differently.
It may well be there's some actual degradation occurring over the course
of the experiment, maybe target molecule is being measured while being enzymatically
degraded, or... yeah, just something to investigate in future.
# ANYWAY
Now we'll begin detrending; that is, subtracting the predicted, trendline value,
from the actual measured value. Result: our means go to 0 and all detrended values
become a measure of distance from the mean.
Let's also see the exact code going on:
"""
)
# DETRENDING!! ---------
# PC (and note lambda x is per entry, agnostic, not to be confused with x_data below)
pc_data["predicted_value"] = pc_ols.predict(pc_data.time_mins)
pc_data["detrended_value"] = pc_data.apply(
lambda x: x["value"] - x["predicted_value"], axis=1
)
# same thing for the other dataframes. hope the math is clear atm!
# PS:
ps_data["predicted_value"] = ps_ols.predict(ps_data.time_mins)
ps_data["detrended_value"] = ps_data.apply(
lambda x: x["value"] - x["predicted_value"], axis=1
)
with st.echo():
# X:
x_data["predicted_value"] = x_ols.predict(x_data.time_mins)
x_data["detrended_value"] = x_data.apply(
lambda x: x["value"] - x["predicted_value"], axis=1
)
st.write(
"And, so, behold! Scrolling over to detrended value, mean is 0:",
x_data.describe(),
"# SO! Now pooling our data and re-plotting...",
)
# get the band back together
# d for detrended
# Aloso pandas has updated:
# https://pandas.pydata.org/pandas-docs/stable/user_guide/merging.html
framesToMerge = [pc_data, ps_data, x_data]
d_df = pd.concat(framesToMerge)
fig3 = px.scatter(
d_df,
x="time_mins",
y="detrended_value",
color="well_type",
hover_name="strain",
hover_data="detrended_value",
symbol="robot",
# trendline="ols",
opacity=0.3,
)
st.plotly_chart(fig3)
# For only superior seaborn plot
# maybe come back and try
# s = sns.lineplot(
# data=d_df,
# # literally x and y axes
# x="time_mins",
# y="detrended_value",
# # grouping variables:
# hue="well_type",
# style="robot",
# # legend = 'auto', # auto by default
# )
# st.pyplot(s)
st.write(
"""
This is one case where there's a superior plot in seaborn, lineplot,
that doesn't let the Standard Wells overwhelm everything. I've
tried to make it transparent, but one can still
check, just toggle off the Standard Wells in the legend.
Anyway, the mean is 0, artificially. Yay! We have eliminated time. Yet we still
are slicing by robot. We will now resolve this and validate our assumption
that we could compare these things.
Once that's done, we'll be able to consider just 3 populations,
not sliced by time or machine:
Process Control, Parent Strain and the Candidate Strains.
"""
)
st.image("memes/farnsworthquantum.jpg", width=300)
st.write(
"""
# ANOVA on Process Controls
Here we'll do ANOVA on the process controls, now liberated of time.
By comparing them across machines, and proving there is no statistical difference
of the populations, we can treat them as one, our method is valid, and assume the rest of the
sample types may also be pooled.
A problem, though (which has a solution): ANOVA assumes homoscedasticity,
It demands the stdev of all groups are the same. The mean is artifically 0 for
all populations, but stdev is not likely to be similar and we'll look at it in a second.
An alternative would be repeated measure ANOVA, and do ANOVA per well type
per time point; but this will have much less statistical power splitting up the data in 20 parts.
Therefore: note the stdevs and go with the lesser evil of regular ANOVA.
"""
)
with st.echo():
d_pc_df = d_df[d_df.well_type == "Process Control"]
overall_std = d_pc_df.detrended_value.std()
bender_std = d_pc_df[d_pc_df.robot == "Bender"].detrended_value.std()
c3p0_std = d_pc_df[d_pc_df.robot == "C3P0"].detrended_value.std()
term_std = d_pc_df[d_pc_df.robot == "Terminator"].detrended_value.std()
st.write(
"stdevs: \n",
"overall",
overall_std,
"bender:",
bender_std,
"c3p0:",
c3p0_std,
"terminator:",
term_std,
)
st.write(
"""
Numbers are fairly similar, although the terminator robot isn't as close as the others.
We'll assume homoscedasticity.
The overall stdev of 212 for the process control can be used to define
the outlier threshold: 3*212 = 636, so anything outside +/- 630 can be considered outliers.
Let's peek and see if any exist in the process control population:
""",
d_pc_df[d_pc_df.well_type == "Process Control"].describe(),
"""
Min and max are -556 and 601; seemingly no outliers, nothing beyond the cutoff.
So, we can consider this as som analagous measure of the instrument error for all data:
therefore all other outliers' threshold, for the parent and candidate strains, should be >= 630.
Therefore, let's now confirm the null hypothesis, that all populations are equivalent, for the process
controls via ANOVA. """,
)
# THere appears to be an alternative method to do this via:
# https://www.statsmodels.org/stable/anova.html
# BUt for now I'll do the scipy option to be lazy
# SCIPY method
# ANOVA!!
from scipy.stats import f_oneway
# the scipy implementation takes in a 1D vector of values... soooo... let's see how to get that out
# d_df[d_df.robot == 'Terminator'].detrended_value.values # this should work i
st.write(
f_oneway(
d_pc_df[d_pc_df.robot == "Terminator"].detrended_value.values,
d_pc_df[d_pc_df.robot == "Bender"].detrended_value.values,
d_pc_df[d_pc_df.robot == "C3P0"].detrended_value.values,
),
"""
Pvalue is above 0.05; without even examining the F-statistic we can say
the null hyothesis cannot be rejected, process controls are not statsitically
significant, not meaningfully differente from each other.
It is however surprising that the p-value isn't much higher given how similar
we've seen the process controls look. LIVIN' ON A PRAYER
So, moving forward with assumption that process controls are equivalent, and
thus we may also compare the other sample types, parent strain and candidate strain.
... Also, the parent strain may serve as almost a secondary control, so we'll also be looking
at that in a second.
""",
)
# https://plotly.com/python/histograms/
# https://plotly.com/python-api-reference/generated/plotly.express.histogram.html?highlight=histogram
fig4a = px.histogram(
d_df[d_df.well_type == "Process Control"],
x="detrended_value",
color="robot",
hover_name="robot",
hover_data="detrended_value",
marginal="violin", # in margin, visual of dist/data
opacity=0.5,
barmode="overlay",
histfunc="count",
histnorm="density",
nbins=50,
)
fig4b = sns.displot(
data=d_df[d_df.well_type == "Process Control"],
x="detrended_value",
hue="robot",
kind="kde",
)
st.write(
"""
Nonideal in plotly express. Cannot see density plots that
make it a bit easier, informative to compare; so density plot in second
tab. (Warning: seaborn density plot has no dark mode.)
"""
)
tab1, tab2 = st.tabs(["Plotly Histogram", "SNS Density Plot"])
with tab1:
st.plotly_chart(fig4a)
with tab2:
st.pyplot(fig4b)
st.write(
"""
We can see that the Terminator robot is the most consistent, least stdev/
most populous around the mean. I hope the new movie is good.
Similarly, let's compare the Parent Strain and Candidate strain sample populations
per robot. Same tabs etc.
"""
)
fig5a = px.histogram(
d_df[d_df.well_type == "Parent Strain"],
x="detrended_value",
color="robot",
hover_name="robot",
hover_data="detrended_value",
marginal="violin", # in margin, visual of dist/data
opacity=0.5,
barmode="overlay",
histfunc="count",
histnorm="density",
nbins=50,
)
fig5b = sns.displot(
data=d_df[d_df.well_type == "Parent Strain"],
x="detrended_value",
hue="robot",
kind="kde",
)
fig5a = px.histogram(
d_df[d_df.well_type == "Standard Well"],
x="detrended_value",
color="robot",
hover_name="robot",
hover_data="detrended_value",
marginal="violin", # in margin, visual of dist/data
opacity=0.5,
barmode="overlay",
histfunc="count",
histnorm="density",
nbins=50,
)
fig5b = sns.displot(
data=d_df[d_df.well_type == "Standard Well"],
x="detrended_value",
hue="robot",
kind="kde",
)
# PARENT STRAIN TABS ------
st.write("Parent Strain (~ second control):")
tab1, tab2 = st.tabs(["Plotly Histogram", "SNS Density Plot"])
with tab1:
st.plotly_chart(fig4a)
with tab2:
st.pyplot(fig4b)
# CANDIDATE STAIN TABS ----
st.write("Standard Well/Candidate Strains:")
tab1, tab2 = st.tabs(["Plotly Histogram", "SNS Density Plot"])
with tab1:
st.plotly_chart(fig5a)
with tab2:
st.pyplot(fig5b)
st.write(
"""
# ALRIGHTY THEN, CONCLUDING POOLING:
Alrighty, from these two above we see, *looking at the axes*,
that the Standard Wells have far more data spread and variabiltiy. The
process control and parent strain samples fall mostly within +/- 500.
Versus the Standard Wells going out to like +/- 1500.
And I'll say now these are ~ absorbance units ~ molecule expression in the strain.
So, I think it's reasonable to proceed saying our method is fairly effective, with an
accuracy +/- 500 units. Our next objective will then be to remove outliers, or note them,
and FINALLY identify the prime candidate strains that are statistically significant
improvements over the parent strain; these will move on to the next stage of
selection.
---
Let's now, for the sake of closure, finality, look at all these populations now
grouped together, split by well type rather than robot.
"""
)
fig6a = px.histogram(
d_df,
x="detrended_value",
color="well_type",
hover_name="well_type",
hover_data="detrended_value",
marginal="violin", # in margin, visual of dist/data
opacity=0.5,
barmode="overlay",
histfunc="count",
histnorm="density",
nbins=50,
)
fig6b = sns.displot(
data=d_df,
x="detrended_value",
hue="well_type",
kind="kde",
)
tab1, tab2 = st.tabs(["Plotly Histogram", "SNS Density Plot"])
with tab1:
st.plotly_chart(fig6a)
with tab2:
st.pyplot(fig6b)
st.write(
"""
Yeah so, that's awesome. Can see 'em all in one place,
the product of all our labor and attention. The violin
plots above the histogram are fairly helpful to see the
distributions/spreads of data for the different well types/strains.
So, let's finally get to the exciting, productive part!!
One last step: remove, or note outliers. Maybe they could be wicked productive
strains, don't wanna just discard 'em.
# The Penultimate Step
---
Removing outliers from parent and candidate strains. Basically,
remove the values +/- 3 STDEVs outside each population. BUT, note
the +3 STDEV candidate strains just in case those ones are legit awesome strains.
"""
)
with st.echo():
from scipy import stats
import numpy as np
# get just the parent strain data
ps_detrended = d_df[d_df.well_type == "Parent Strain"]
# find outliers in the data; outside zscore of 3, 3 stdev; note gets absolute value so anything +/-
ps_outliers = ps_detrended[(np.abs(stats.zscore(ps_detrended.detrended_value)) > 3)]
# redefine data to NOT have those outliers
ps_detrended = ps_detrended[
(np.abs(stats.zscore(ps_detrended.detrended_value)) < 3)
]
# show outliers found and removed, just 3 of em lol
ps_outliers
st.write(
"Now do for the candidate strains. Code is slightly different \
to keep track of the positive outliers."
)
with st.echo():
# get just the CANDIDATE strain data
X_detrended = d_df[d_df.well_type == "Standard Well"]
# find outliers in the data;
# THIS LINE and next get the negative and positive ID'd
# outside zscore of 3, 3 stdev; note gets absolute value so anything +/-
X_outliers_negative = X_detrended[(stats.zscore(X_detrended.detrended_value)) < -3]
X_outliers_positive = X_detrended[(stats.zscore(X_detrended.detrended_value)) >= 3]
# and finally, get the cleaned up X data, stuff within 3 stdev/z-score of 3
X_detrended = X_detrended[(np.abs(stats.zscore(X_detrended.detrended_value)) < 3)]
st.write(
"Amount of negative outliers: ",
len(X_outliers_negative),
"Amount of positive outliers: ",
len(X_outliers_positive),
"Remaining non-outlier samples: ",
len(X_detrended),
"""
# FINALLY. We can now identify the best candidate(s) (such as myself).
---
One could do a t-test, sure; or more simply we could just set a
threshold at > 99%% of the parent strains' expression.
""",
)
with st.echo():
# 99% cutoff:
final_cutoff = ps_detrended.detrended_value.quantile(0.99)
# get the candidates that have a value above this cutoff
super_candidates = X_detrended[(X_detrended.detrended_value > final_cutoff)]
st.write(
"Number of excellent candidates: ",
len(super_candidates),
"YAAAAAAAAAAAASSSSSSSSSSSSSSSSSSSSSSS!!!!!!!!!!!!!!!!",
)
st.image("memes/tuco2.jpg", width=300)
st.write(
"Ok, let's plot these, plus the positive outliers, and see what it looks like."
)
super_candidates["sus"] = False
X_outliers_positive["sus"] = True
# all_candidates = super_candidates.append(X_outliers_positive)
all_candidates = pd.concat([super_candidates, X_outliers_positive])
# framesToMerge = [pc_data, ps_data, x_data]
# d_df = pd.concat(framesToMerge)
fig7 = px.histogram(
super_candidates,
x="detrended_value",
color="sus",
hover_name="sus",
hover_data="detrended_value",
marginal="violin", # in margin, visual of dist/data
opacity=0.5,
barmode="overlay",
histfunc="count",
histnorm="density",
nbins=50,
)
st.plotly_chart(fig7)
col1, col2 = st.columns(2)
with col1:
st.write(
"Excluding the outliers",
super_candidates.detrended_value.describe(),
)
with col2:
st.write(
"Including the outliers",
all_candidates.detrended_value.describe(),
)
st.write(
"""
Alrighty then! So we see that the strains of interest start above around 600 greater
than the mean of the parent strain*, and basically become less numerous.
The max is about 1600 above that mean, but may be an outlier.
(*Mathematically, greater than the mean of the candidate strains, but the detrending renders this at 0 for both the candidate strains and parent strain, for better or worse given the line of best fit predicted for either dataset.)
There's 133 candidates we have here. I'll leave it as is, one could slice this different ways depending on
inclusion of the potential outliers or not. But I'll output a 1D vector of these 3 dataframes below.
So, basically, for 100/5280 candidates, we see ~ 800 improvement in absorbance ~ molecule expression.
Evolution in action.
Let's see the exact math so we can have some readout for our overall selection/evolution process:
""",
)
f = df[df.well_type == "Parent Strain"]
st.write(
"Original mean of parent strain, before detrending: ",
f.value.mean(),
"\n",
"Mean of our results including outliers, representing a delta over the parent strain: ",
all_candidates.detrended_value.mean(),
"Percent improvement in these samples, including outliers: ",
all_candidates.detrended_value.mean() / f.value.mean(),
"Percent improvement without outliers: ",
super_candidates.detrended_value.mean() / f.value.mean(),
"So, these numbers suggest somewhere around 7-9%% improvement in these strains; in 99 or 133/5280 strains. Neat!",
"Ok, and to get the data out, one would just come down and save one of these dfs to csv etc.",
)
with st.echo():
all_candidate_strains_counter = all_candidates.counter.values
# super candidates, doesn't include outliers
super_candidate_strains_counter = super_candidates.counter.values
# potential outlier candidates
outlier_counters = X_outliers_positive.counter.values
# Sample output
st.write(outlier_counters)
st.write(
"""
And... THAT'S FINALLY IT! We've identified ~133 candidates based on a cutoff
defined as being superior to 99%% of parent strain samples. Our methodology has included:
- Detrending by ordinary least squares, line of best fit, in lieu of time-series analysis or repeated-measure ANOVA, in order
to obtain a greater statsitical power and compare as many samples as possible
- Confirmation of null hypothesis to pool samples from different machines
- Thresholding, effectively p value <= 0.01 samples to select for next run
- Did this all in a pretty, interactive way using several libraries and memes.
- Takes about a day to get this all together in Streamlit/Plotly, far superior and with
very readable syntax etc. using this than my experience with Shiny, Dash... PowerBI may
be close in production speed but is not as extensible of course.
- Deployment and embedding into my main website is then trivial with Github/Streamlit/Huggingface.
- 10/10 recommend
"""
)