getting a bit further with the frontend and demoing the analysis to matti
This commit is contained in:
@@ -0,0 +1,239 @@
|
|||||||
|
# %% [markdown]
|
||||||
|
# # Visualize Most Prepared Users
|
||||||
|
# This workflow loads user embeddings, generates a prompt embedding, computes similarity, and visualizes the most prepared users.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
|
from sklearn.decomposition import PCA
|
||||||
|
from sklearn.manifold import TSNE
|
||||||
|
import plotly.express as px
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
import umap
|
||||||
|
import umap.plot
|
||||||
|
from utils import get_ollama_embedding
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Read in the file with user vectors
|
||||||
|
# Update the path/format as needed.
|
||||||
|
# %%
|
||||||
|
df = (
|
||||||
|
pd.read_csv("./data/eurobarometer_preparedness_model_data_v3.csv")
|
||||||
|
.reset_index()
|
||||||
|
.assign(
|
||||||
|
**{
|
||||||
|
"user_id": lambda x: x["index"].astype(str)
|
||||||
|
+ "_"
|
||||||
|
+ x["country_code_iso_3166"]
|
||||||
|
+ "_"
|
||||||
|
+ x["age_recoded_6_categories"].astype(str)
|
||||||
|
+ "_"
|
||||||
|
+ x["gender"].astype(str)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# select only relevant countries for visualization
|
||||||
|
.loc[
|
||||||
|
lambda x: x["country_code_iso_3166"].isin(
|
||||||
|
["FI", "DE-E", "DE-W", "FR", "ES", "PT"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
df.shape
|
||||||
|
|
||||||
|
# relabel None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say to other
|
||||||
|
df["gender"] = df["gender"].replace({
|
||||||
|
"None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say": "Other",
|
||||||
|
})
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Convert string embeddings to lists if needed
|
||||||
|
# %%
|
||||||
|
def parse_embedding(x):
|
||||||
|
if isinstance(x, str):
|
||||||
|
return [float(i) for i in x.strip("[]").split(",")]
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
df["embedding"] = df["embedding"].apply(parse_embedding)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# Select best user:
|
||||||
|
# disaster_measures_in_hh_* == 1, and how_many_days_meet_* == (4 | 5)
|
||||||
|
# Following relabeling in data_preparation_raw.py:
|
||||||
|
# disaster_measures_in_hh_* != "Not mentioned"
|
||||||
|
# how_many_days_meet_* == "More than 7 days"
|
||||||
|
disaster_measures_in_hh_columns = [
|
||||||
|
col for col in df.columns if col.startswith("disaster_measures_in_hh_")
|
||||||
|
]
|
||||||
|
how_many_days_meet_columns = [
|
||||||
|
col for col in df.columns if col.startswith("how_many_days_meet_")
|
||||||
|
]
|
||||||
|
best_users = df[
|
||||||
|
(df[disaster_measures_in_hh_columns] != "Not mentioned").all(axis=1)
|
||||||
|
& (df[how_many_days_meet_columns] == "More than 7 days").all(axis=1)
|
||||||
|
]
|
||||||
|
print(f"Number of best users: {len(best_users)}")
|
||||||
|
|
||||||
|
best_users.head(5)[["user_id", "user_text"]].to_dict(orient="records")
|
||||||
|
|
||||||
|
# %%
|
||||||
|
best_users.columns.tolist()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Write your prompt and generate its embedding
|
||||||
|
# %%
|
||||||
|
# prompt = "The user is highly prepared for disasters, with emergency supplies and a clear plan."
|
||||||
|
prompt = best_users.head(1)["user_text"].values[
|
||||||
|
0
|
||||||
|
] # Example: use the first user's text as the prompt
|
||||||
|
prompt_embedding = get_ollama_embedding(prompt)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Compute similarity between each user and the prompt
|
||||||
|
# %%
|
||||||
|
user_embeddings = np.vstack(df["embedding"].values)
|
||||||
|
prompt_vec = np.array(prompt_embedding).reshape(1, -1)
|
||||||
|
similarities = cosine_similarity(user_embeddings, prompt_vec).flatten()
|
||||||
|
df["similarity"] = similarities
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Visualize the users by similarity
|
||||||
|
# %%
|
||||||
|
_ = plt.figure(figsize=(5, 7))
|
||||||
|
_ = sns.violinplot(
|
||||||
|
x="similarity", y="country_code_iso_3166", hue="country_code_iso_3166", data=df
|
||||||
|
)
|
||||||
|
_ = sns.stripplot(
|
||||||
|
x="similarity",
|
||||||
|
y="country_code_iso_3166",
|
||||||
|
data=df,
|
||||||
|
hue="country_code_iso_3166",
|
||||||
|
alpha=0.8,
|
||||||
|
jitter=True,
|
||||||
|
linewidth=0.5,
|
||||||
|
edgecolor="white",
|
||||||
|
)
|
||||||
|
_ = plt.title("Distribution of User Similarities to Preparedness Prompt")
|
||||||
|
_ = plt.xlabel("Cosine Similarity")
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# np.savetxt("./data/embeddings.tsv", np.vstack(df["embedding"].values), delimiter="\t")
|
||||||
|
|
||||||
|
# # %%
|
||||||
|
# df[["user_id"]].to_csv(
|
||||||
|
# "./data/metadata.tsv", sep="\t", index=False
|
||||||
|
# )
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# Reduce embeddings to 3D with PCA
|
||||||
|
pca = PCA(n_components=3)
|
||||||
|
embeddings_3d = pca.fit_transform(np.vstack(df["embedding"].values))
|
||||||
|
|
||||||
|
# Add PCA components to dataframe
|
||||||
|
df["pca1"] = embeddings_3d[:, 0]
|
||||||
|
df["pca2"] = embeddings_3d[:, 1]
|
||||||
|
df["pca3"] = embeddings_3d[:, 2]
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# Interactive 3D scatter plot
|
||||||
|
fig = px.scatter_3d(
|
||||||
|
df,
|
||||||
|
x="pca1",
|
||||||
|
y="pca2",
|
||||||
|
z="pca3",
|
||||||
|
color="age_recoded_6_categories",
|
||||||
|
hover_data=["user_id", "gender", "age_recoded_6_categories"],
|
||||||
|
title="User Embeddings (PCA 3D)"
|
||||||
|
)
|
||||||
|
fig.write_html("./output/user_embeddings_pca_3d.html")
|
||||||
|
# %%
|
||||||
|
# Reduce embeddings to 3D with t-SNE
|
||||||
|
tsne = TSNE(n_components=3, random_state=42, perplexity=30)
|
||||||
|
embeddings_3d = tsne.fit_transform(np.vstack(df["embedding"].values))
|
||||||
|
|
||||||
|
# Add t-SNE components to dataframe
|
||||||
|
df["tsne1"] = embeddings_3d[:, 0]
|
||||||
|
df["tsne2"] = embeddings_3d[:, 1]
|
||||||
|
df["tsne3"] = embeddings_3d[:, 2]
|
||||||
|
|
||||||
|
# Interactive 3D scatter plot
|
||||||
|
fig = px.scatter_3d(
|
||||||
|
df,
|
||||||
|
x="tsne1",
|
||||||
|
y="tsne2",
|
||||||
|
z="tsne3",
|
||||||
|
color="age_recoded_6_categories",
|
||||||
|
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||||
|
title="User Embeddings (t-SNE 3D)"
|
||||||
|
)
|
||||||
|
fig.write_html("./output/user_embeddings_tsne_3d.html")
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# example comparison based on t-sne projection
|
||||||
|
index_1 = 9561
|
||||||
|
index_2 = 5096
|
||||||
|
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||||
|
# %%
|
||||||
|
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||||
|
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||||
|
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||||
|
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||||
|
|
||||||
|
diffs = {}
|
||||||
|
for col in column_list:
|
||||||
|
val1 = row1[col]
|
||||||
|
val2 = row2[col]
|
||||||
|
if pd.isnull(val1) and pd.isnull(val2):
|
||||||
|
continue
|
||||||
|
if val1 != val2:
|
||||||
|
diffs[col] = (val1, val2)
|
||||||
|
|
||||||
|
# Print or display the differing columns and their values
|
||||||
|
for col, (v1, v2) in diffs.items():
|
||||||
|
print(f"{col}: {v1} | {v2}")
|
||||||
|
# %%
|
||||||
|
# Reduce embeddings to 3D with UMAP
|
||||||
|
umap_3d = umap.UMAP(n_components=3, random_state=42, metric="cosine").fit_transform(np.vstack(df["embedding"].values))
|
||||||
|
|
||||||
|
# Add UMAP components to dataframe
|
||||||
|
df["umap1"] = umap_3d[:, 0]
|
||||||
|
df["umap2"] = umap_3d[:, 1]
|
||||||
|
df["umap3"] = umap_3d[:, 2]
|
||||||
|
|
||||||
|
# Interactive 3D scatter plot
|
||||||
|
fig = px.scatter_3d(
|
||||||
|
df,
|
||||||
|
x="umap1",
|
||||||
|
y="umap2",
|
||||||
|
z="umap3",
|
||||||
|
color="country_code_iso_3166",
|
||||||
|
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||||
|
title="User Embeddings (UMAP 3D)"
|
||||||
|
)
|
||||||
|
fig.write_html("./output/user_embeddings_umap_3d.html")
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# example comparison based on UMAP projection
|
||||||
|
index_1 = 21396
|
||||||
|
index_2 = 10444
|
||||||
|
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||||
|
# %%
|
||||||
|
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||||
|
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||||
|
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||||
|
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||||
|
diffs = {}
|
||||||
|
for col in column_list:
|
||||||
|
val1 = row1[col]
|
||||||
|
val2 = row2[col]
|
||||||
|
if pd.isnull(val1) and pd.isnull(val2):
|
||||||
|
continue
|
||||||
|
if val1 != val2:
|
||||||
|
diffs[col] = (val1, val2)
|
||||||
|
|
||||||
|
# Print or display the differing columns and their values
|
||||||
|
for col, (v1, v2) in diffs.items():
|
||||||
|
print(f"{col}: {v1} | {v2}")
|
||||||
|
# %%
|
||||||
File diff suppressed because one or more lines are too long
+15
-231
@@ -1,236 +1,20 @@
|
|||||||
# %% [markdown]
|
import logging
|
||||||
# # Visualize Most Prepared Users
|
import uvicorn
|
||||||
# This workflow loads user embeddings, generates a prompt embedding, computes similarity, and visualizes the most prepared users.
|
import os
|
||||||
|
from api import app
|
||||||
|
|
||||||
# %%
|
logging.basicConfig(level=logging.INFO)
|
||||||
import pandas as pd
|
logger = logging.getLogger(__name__)
|
||||||
import numpy as np
|
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
|
||||||
from sklearn.decomposition import PCA
|
|
||||||
from sklearn.manifold import TSNE
|
|
||||||
import plotly.express as px
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import seaborn as sns
|
|
||||||
import umap
|
|
||||||
import umap.plot
|
|
||||||
from utils import get_ollama_embedding
|
|
||||||
|
|
||||||
# %% [markdown]
|
|
||||||
# ## Read in the file with user vectors
|
|
||||||
# Update the path/format as needed.
|
|
||||||
# %%
|
|
||||||
df = (
|
|
||||||
pd.read_csv("./data/eurobarometer_preparedness_model_data_v3.csv")
|
|
||||||
.reset_index()
|
|
||||||
.assign(
|
|
||||||
**{
|
|
||||||
"user_id": lambda x: x["index"].astype(str)
|
|
||||||
+ "_"
|
|
||||||
+ x["country_code_iso_3166"]
|
|
||||||
+ "_"
|
|
||||||
+ x["age_recoded_6_categories"].astype(str)
|
|
||||||
+ "_"
|
|
||||||
+ x["gender"].astype(str)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# select only relevant countries for visualization
|
|
||||||
.loc[
|
|
||||||
lambda x: x["country_code_iso_3166"].isin(
|
|
||||||
["FI", "DE-E", "DE-W", "FR", "ES", "PT"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
df.shape
|
|
||||||
|
|
||||||
# relabel None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say to other
|
|
||||||
df["gender"] = df["gender"].replace({
|
|
||||||
"None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say": "Other",
|
|
||||||
})
|
|
||||||
|
|
||||||
# %% [markdown]
|
|
||||||
# ## Convert string embeddings to lists if needed
|
|
||||||
# %%
|
|
||||||
def parse_embedding(x):
|
|
||||||
if isinstance(x, str):
|
|
||||||
return [float(i) for i in x.strip("[]").split(",")]
|
|
||||||
return x
|
|
||||||
|
|
||||||
|
|
||||||
df["embedding"] = df["embedding"].apply(parse_embedding)
|
def run_api() -> None:
|
||||||
|
"""Run the FastAPI application."""
|
||||||
|
if "james" in os.environ.get("USER", ""):
|
||||||
|
logger.info("Running in James's environment")
|
||||||
|
uvicorn.run("api:app", host="0.0.0.0", port=8080, reload=True)
|
||||||
|
else:
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||||
|
|
||||||
# %%
|
|
||||||
# Select best user:
|
|
||||||
# disaster_measures_in_hh_* == 1, and how_many_days_meet_* == (4 | 5)
|
|
||||||
# Following relabeling in data_preparation_raw.py:
|
|
||||||
# disaster_measures_in_hh_* != "Not mentioned"
|
|
||||||
# how_many_days_meet_* == "More than 7 days"
|
|
||||||
disaster_measures_in_hh_columns = [
|
|
||||||
col for col in df.columns if col.startswith("disaster_measures_in_hh_")
|
|
||||||
]
|
|
||||||
how_many_days_meet_columns = [
|
|
||||||
col for col in df.columns if col.startswith("how_many_days_meet_")
|
|
||||||
]
|
|
||||||
best_users = df[
|
|
||||||
(df[disaster_measures_in_hh_columns] != "Not mentioned").all(axis=1)
|
|
||||||
& (df[how_many_days_meet_columns] == "More than 7 days").all(axis=1)
|
|
||||||
]
|
|
||||||
print(f"Number of best users: {len(best_users)}")
|
|
||||||
|
|
||||||
best_users.head(5)[["user_id", "user_text"]].to_dict(orient="records")
|
if __name__ == "__main__":
|
||||||
|
run_api()
|
||||||
# %% [markdown]
|
|
||||||
# ## Write your prompt and generate its embedding
|
|
||||||
# %%
|
|
||||||
# prompt = "The user is highly prepared for disasters, with emergency supplies and a clear plan."
|
|
||||||
prompt = best_users.head(1)["user_text"].values[
|
|
||||||
0
|
|
||||||
] # Example: use the first user's text as the prompt
|
|
||||||
prompt_embedding = get_ollama_embedding(prompt)
|
|
||||||
|
|
||||||
# %% [markdown]
|
|
||||||
# ## Compute similarity between each user and the prompt
|
|
||||||
# %%
|
|
||||||
user_embeddings = np.vstack(df["embedding"].values)
|
|
||||||
prompt_vec = np.array(prompt_embedding).reshape(1, -1)
|
|
||||||
similarities = cosine_similarity(user_embeddings, prompt_vec).flatten()
|
|
||||||
df["similarity"] = similarities
|
|
||||||
|
|
||||||
# %% [markdown]
|
|
||||||
# ## Visualize the users by similarity
|
|
||||||
# %%
|
|
||||||
_ = plt.figure(figsize=(5, 7))
|
|
||||||
_ = sns.violinplot(
|
|
||||||
x="similarity", y="country_code_iso_3166", hue="country_code_iso_3166", data=df
|
|
||||||
)
|
|
||||||
_ = sns.stripplot(
|
|
||||||
x="similarity",
|
|
||||||
y="country_code_iso_3166",
|
|
||||||
data=df,
|
|
||||||
hue="country_code_iso_3166",
|
|
||||||
alpha=0.8,
|
|
||||||
jitter=True,
|
|
||||||
linewidth=0.5,
|
|
||||||
edgecolor="white",
|
|
||||||
)
|
|
||||||
_ = plt.title("Distribution of User Similarities to Preparedness Prompt")
|
|
||||||
_ = plt.xlabel("Cosine Similarity")
|
|
||||||
|
|
||||||
# %%
|
|
||||||
# np.savetxt("./data/embeddings.tsv", np.vstack(df["embedding"].values), delimiter="\t")
|
|
||||||
|
|
||||||
# # %%
|
|
||||||
# df[["user_id"]].to_csv(
|
|
||||||
# "./data/metadata.tsv", sep="\t", index=False
|
|
||||||
# )
|
|
||||||
|
|
||||||
# %%
|
|
||||||
# Reduce embeddings to 3D with PCA
|
|
||||||
pca = PCA(n_components=3)
|
|
||||||
embeddings_3d = pca.fit_transform(np.vstack(df["embedding"].values))
|
|
||||||
|
|
||||||
# Add PCA components to dataframe
|
|
||||||
df["pca1"] = embeddings_3d[:, 0]
|
|
||||||
df["pca2"] = embeddings_3d[:, 1]
|
|
||||||
df["pca3"] = embeddings_3d[:, 2]
|
|
||||||
|
|
||||||
# %%
|
|
||||||
# Interactive 3D scatter plot
|
|
||||||
fig = px.scatter_3d(
|
|
||||||
df,
|
|
||||||
x="pca1",
|
|
||||||
y="pca2",
|
|
||||||
z="pca3",
|
|
||||||
color="age_recoded_6_categories",
|
|
||||||
hover_data=["user_id", "gender", "age_recoded_6_categories"],
|
|
||||||
title="User Embeddings (PCA 3D)"
|
|
||||||
)
|
|
||||||
fig.write_html("./output/user_embeddings_pca_3d.html")
|
|
||||||
# %%
|
|
||||||
# Reduce embeddings to 3D with t-SNE
|
|
||||||
tsne = TSNE(n_components=3, random_state=42, perplexity=30)
|
|
||||||
embeddings_3d = tsne.fit_transform(np.vstack(df["embedding"].values))
|
|
||||||
|
|
||||||
# Add t-SNE components to dataframe
|
|
||||||
df["tsne1"] = embeddings_3d[:, 0]
|
|
||||||
df["tsne2"] = embeddings_3d[:, 1]
|
|
||||||
df["tsne3"] = embeddings_3d[:, 2]
|
|
||||||
|
|
||||||
# Interactive 3D scatter plot
|
|
||||||
fig = px.scatter_3d(
|
|
||||||
df,
|
|
||||||
x="tsne1",
|
|
||||||
y="tsne2",
|
|
||||||
z="tsne3",
|
|
||||||
color="age_recoded_6_categories",
|
|
||||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
|
||||||
title="User Embeddings (t-SNE 3D)"
|
|
||||||
)
|
|
||||||
fig.write_html("./output/user_embeddings_tsne_3d.html")
|
|
||||||
|
|
||||||
# %%
|
|
||||||
# example comparison based on t-sne projection
|
|
||||||
index_1 = 9561
|
|
||||||
index_2 = 5096
|
|
||||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
|
||||||
# %%
|
|
||||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
|
||||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
|
||||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
|
||||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
|
||||||
|
|
||||||
diffs = {}
|
|
||||||
for col in column_list:
|
|
||||||
val1 = row1[col]
|
|
||||||
val2 = row2[col]
|
|
||||||
if pd.isnull(val1) and pd.isnull(val2):
|
|
||||||
continue
|
|
||||||
if val1 != val2:
|
|
||||||
diffs[col] = (val1, val2)
|
|
||||||
|
|
||||||
# Print or display the differing columns and their values
|
|
||||||
for col, (v1, v2) in diffs.items():
|
|
||||||
print(f"{col}: {v1} | {v2}")
|
|
||||||
# %%
|
|
||||||
# Reduce embeddings to 3D with UMAP
|
|
||||||
umap_3d = umap.UMAP(n_components=3, random_state=42, metric="cosine").fit_transform(np.vstack(df["embedding"].values))
|
|
||||||
|
|
||||||
# Add UMAP components to dataframe
|
|
||||||
df["umap1"] = umap_3d[:, 0]
|
|
||||||
df["umap2"] = umap_3d[:, 1]
|
|
||||||
df["umap3"] = umap_3d[:, 2]
|
|
||||||
|
|
||||||
# Interactive 3D scatter plot
|
|
||||||
fig = px.scatter_3d(
|
|
||||||
df,
|
|
||||||
x="umap1",
|
|
||||||
y="umap2",
|
|
||||||
z="umap3",
|
|
||||||
color="country_code_iso_3166",
|
|
||||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
|
||||||
title="User Embeddings (UMAP 3D)"
|
|
||||||
)
|
|
||||||
fig.write_html("./output/user_embeddings_umap_3d.html")
|
|
||||||
|
|
||||||
# %%
|
|
||||||
# example comparison based on UMAP projection
|
|
||||||
index_1 = 21396
|
|
||||||
index_2 = 10444
|
|
||||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
|
||||||
# %%
|
|
||||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
|
||||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
|
||||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
|
||||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
|
||||||
diffs = {}
|
|
||||||
for col in column_list:
|
|
||||||
val1 = row1[col]
|
|
||||||
val2 = row2[col]
|
|
||||||
if pd.isnull(val1) and pd.isnull(val2):
|
|
||||||
continue
|
|
||||||
if val1 != val2:
|
|
||||||
diffs[col] = (val1, val2)
|
|
||||||
|
|
||||||
# Print or display the differing columns and their values
|
|
||||||
for col, (v1, v2) in diffs.items():
|
|
||||||
print(f"{col}: {v1} | {v2}")
|
|
||||||
# %%
|
|
||||||
@@ -8,3 +8,5 @@ pyreadstat
|
|||||||
plotly
|
plotly
|
||||||
nbformat>=4.2.0
|
nbformat>=4.2.0
|
||||||
umap-learn[plot]
|
umap-learn[plot]
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class PredictionRequest(BaseModel):
|
||||||
|
text: str
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Preparedness Survey — Quick Form</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 28px;
|
||||||
|
background: #f7fafc
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 6px 20px rgba(2, 6, 23, .08)
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text],
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
input[type=number] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: block;
|
||||||
|
margin-top: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* form rows: labels above inputs for cleaner reading and to avoid overlap */
|
||||||
|
.form-row {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #eef2f6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* label shown above the input area */
|
||||||
|
.form-row>label,
|
||||||
|
.label-top {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .input-wrap {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep Likert rows visually compact with the question label above */
|
||||||
|
.form-row.likert-row {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.likert-row .input-wrap.likert {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:700px) {
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row label {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .input-wrap {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.likert {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
/* each option is an inline radio + text so the options form a horizontal row */
|
||||||
|
.likert label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px
|
||||||
|
}
|
||||||
|
|
||||||
|
.likert input[type=radio] {
|
||||||
|
margin: 0 6px 0 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normalize likert groups so all questions display consistently */
|
||||||
|
.input-wrap.likert {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.likert .likert,
|
||||||
|
.input-wrap.likert {
|
||||||
|
width: 100%;
|
||||||
|
align-items: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.likert .likert {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.likert label {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.likert label:hover {
|
||||||
|
background: rgba(2, 6, 23, 0.03)
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.likert input[type=radio] {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toggle switch for binary yes/no selects */
|
||||||
|
.toggle-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch .slider {
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 999px;
|
||||||
|
position: relative;
|
||||||
|
transition: background .18s ease
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch .slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform .18s ease
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked+.slider {
|
||||||
|
background: #10b981
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked+.slider::after {
|
||||||
|
transform: translateX(22px)
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: #0069ff;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e6eef8;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #0f172a
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #374151
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #6b7280
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Preparedness survey — quick form</h1>
|
||||||
|
<p>Fill the fields you have, then use <strong>Predict</strong> to send the form as JSON to the server's
|
||||||
|
<code>/predict</code> endpoint and see the model response below.
|
||||||
|
</p>
|
||||||
|
<form id="survey">
|
||||||
|
<!-- dynamic form will be rendered here from metadata -->
|
||||||
|
<div id="dynamic-grid" class="grid"></div>
|
||||||
|
|
||||||
|
<!-- All static questions removed. The form is fully metadata-driven and rendered into #dynamic-grid -->
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="predict">Predict</button>
|
||||||
|
<button type="button" id="selftest">Self-test</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>JSON preview</h3>
|
||||||
|
<pre id="output">{ }</pre>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
// Note: selects were converted to static markup; label/likert styling is handled in the CSS above.
|
||||||
|
const form = document.getElementById('survey');
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
|
||||||
|
function collectForm() {
|
||||||
|
const data = {};
|
||||||
|
// include all inputs/selects
|
||||||
|
const elements = Array.from(form.elements).filter(e => e.name);
|
||||||
|
// handle radios by grouping
|
||||||
|
const handled = new Set();
|
||||||
|
elements.forEach(el => {
|
||||||
|
const name = el.name;
|
||||||
|
if (handled.has(name)) return;
|
||||||
|
if (el.type === 'radio') {
|
||||||
|
const radios = form.querySelectorAll(`input[name="${name}"]`);
|
||||||
|
const checked = Array.from(radios).find(r => r.checked);
|
||||||
|
data[name] = checked ? checked.value : null;
|
||||||
|
handled.add(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
// if this checkbox was created as a toggle with data-yes/data-no, return the string
|
||||||
|
const yes = el.dataset && el.dataset.yes;
|
||||||
|
const no = el.dataset && el.dataset.no;
|
||||||
|
if (yes !== undefined && no !== undefined) {
|
||||||
|
data[name] = el.checked ? yes : no;
|
||||||
|
} else {
|
||||||
|
data[name] = el.checked ? 1 : 0;
|
||||||
|
}
|
||||||
|
handled.add(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el.type === 'number' || el.type === 'range') {
|
||||||
|
const v = el.value;
|
||||||
|
data[name] = v === '' ? null : Number(v);
|
||||||
|
handled.add(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// select, text, textarea, etc.
|
||||||
|
data[name] = el.value === '' ? null : el.value;
|
||||||
|
handled.add(name);
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map compact option values to human-friendly labels used in the canonical example
|
||||||
|
const VALUE_LABELS = {
|
||||||
|
// general likert / frequency
|
||||||
|
'very_much': 'A great deal',
|
||||||
|
'somewhat': 'Somewhat',
|
||||||
|
'not_much': 'Not much',
|
||||||
|
'not_at_all': 'Not at all',
|
||||||
|
|
||||||
|
'strongly_agree': 'Strongly agree',
|
||||||
|
'agree': 'Agree',
|
||||||
|
'neutral': 'Neutral',
|
||||||
|
'disagree': 'Disagree',
|
||||||
|
'strongly_disagree': 'Strongly disagree',
|
||||||
|
|
||||||
|
'very_trust': 'Very trust',
|
||||||
|
'somewhat_trust': 'Somewhat trust',
|
||||||
|
'somewhat_distrust': 'Somewhat distrust',
|
||||||
|
'very_distrust': 'Very distrust',
|
||||||
|
|
||||||
|
'daily': 'Daily',
|
||||||
|
'weekly': 'Weekly',
|
||||||
|
'monthly': 'Monthly',
|
||||||
|
'rarely': 'Rarely',
|
||||||
|
'never': 'Never',
|
||||||
|
|
||||||
|
'female': 'Female',
|
||||||
|
'male': 'Male',
|
||||||
|
'other': 'Other',
|
||||||
|
'prefer_not': 'Prefer not to say',
|
||||||
|
|
||||||
|
'getting_better': 'Getting better',
|
||||||
|
'staying_same': 'Staying the same',
|
||||||
|
'getting_worse': 'Getting worse',
|
||||||
|
|
||||||
|
'often': 'Often',
|
||||||
|
'sometimes': 'Sometimes',
|
||||||
|
|
||||||
|
'improved': 'Improved',
|
||||||
|
'same': 'Same',
|
||||||
|
'worse': 'Worse',
|
||||||
|
'better': 'Better',
|
||||||
|
|
||||||
|
'rural': 'Rural',
|
||||||
|
'small_town': 'Small town',
|
||||||
|
'suburb': 'Suburb',
|
||||||
|
'city': 'City',
|
||||||
|
|
||||||
|
// toggles / yes-no
|
||||||
|
'yes': 'Yes',
|
||||||
|
'no': 'No'
|
||||||
|
};
|
||||||
|
|
||||||
|
// SERVER_VALUE_LABELS will hold the variable-level mappings returned from the server
|
||||||
|
// structure: { varName: { codeStr: label, ... }, ... }
|
||||||
|
let SERVER_VALUE_LABELS = {};
|
||||||
|
// FIELD_LABELS maps our stable field name (input.name) -> human-friendly label
|
||||||
|
// FIELD_INTERNALS maps stable field name -> server-provided internal_label (snake_case id)
|
||||||
|
// populated when we render the variables list so we can use internal_label when sending
|
||||||
|
// the combined text (and still show human-friendly labels in the UI).
|
||||||
|
let FIELD_LABELS = {};
|
||||||
|
let FIELD_INTERNALS = {};
|
||||||
|
// FIELD_VARNAMES maps stable field name -> canonical variable id (as returned in variables[].id)
|
||||||
|
// we need this so we can look up SERVER_VALUE_LABELS by the canonical id when resolving labels
|
||||||
|
let FIELD_VARNAMES = {};
|
||||||
|
// VAR_VALUES stores the values map returned in /variables for each canonical var id
|
||||||
|
// structure: { varId: { code: label, ... }, ... }
|
||||||
|
let VAR_VALUES = {};
|
||||||
|
|
||||||
|
// Load server-provided variable metadata and render the entire form from it.
|
||||||
|
(async function loadVariablesAndRender() {
|
||||||
|
try {
|
||||||
|
// Load variable metadata (ordered list)
|
||||||
|
const varsResp = await fetch('/variables');
|
||||||
|
if (!varsResp.ok) throw new Error('Could not load /variables');
|
||||||
|
const variables = await varsResp.json();
|
||||||
|
|
||||||
|
// load server-side value labels for quick lookup too
|
||||||
|
const vlResp = await fetch('/value_labels');
|
||||||
|
if (vlResp.ok) {
|
||||||
|
SERVER_VALUE_LABELS = await vlResp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.getElementById('dynamic-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
console.log('[survey] Loaded', variables.length, 'variables from /variables');
|
||||||
|
|
||||||
|
// heuristic: certain variable name prefixes indicate a range/numeric field
|
||||||
|
function looksLikeRange(varId) {
|
||||||
|
return varId.startsWith('how_many_days') || varId.startsWith('age_') || varId.startsWith('age') || varId.startsWith('age_recoded');
|
||||||
|
}
|
||||||
|
|
||||||
|
variables.forEach(v => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'form-row';
|
||||||
|
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = v.label || v.id;
|
||||||
|
row.appendChild(lbl);
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'input-wrap';
|
||||||
|
// stable field name for submission: use snake_case of id
|
||||||
|
const fieldName = String(v.id).toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||||
|
wrap.setAttribute('data-field', fieldName);
|
||||||
|
wrap.setAttribute('data-var', v.id);
|
||||||
|
// remember the human label and internal_label for display and submission
|
||||||
|
FIELD_LABELS[fieldName] = v.label || v.id;
|
||||||
|
FIELD_INTERNALS[fieldName] = v.internal_label || fieldName;
|
||||||
|
FIELD_VARNAMES[fieldName] = v.id;
|
||||||
|
VAR_VALUES[v.id] = v.values || {};
|
||||||
|
console.log('[survey] FIELD_LABELS set:', fieldName, '=>', FIELD_LABELS[fieldName]);
|
||||||
|
console.log('[survey] FIELD_INTERNALS set:', fieldName, '=>', FIELD_INTERNALS[fieldName]);
|
||||||
|
|
||||||
|
// if we have value labels for this var, render radios or toggles
|
||||||
|
const vals = v.values || {};
|
||||||
|
const codes = Object.keys(vals || {});
|
||||||
|
if (codes.length === 0) {
|
||||||
|
// no canonical values: choose text or range based on heuristics
|
||||||
|
if (looksLikeRange(v.id)) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'range';
|
||||||
|
inp.name = fieldName;
|
||||||
|
inp.id = fieldName;
|
||||||
|
inp.min = 0;
|
||||||
|
inp.max = 30;
|
||||||
|
inp.step = 1;
|
||||||
|
wrap.appendChild(inp);
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'range-value';
|
||||||
|
span.setAttribute('data-for', fieldName);
|
||||||
|
span.textContent = '0';
|
||||||
|
wrap.appendChild(span);
|
||||||
|
} else {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'text';
|
||||||
|
inp.name = fieldName;
|
||||||
|
inp.id = fieldName;
|
||||||
|
wrap.appendChild(inp);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// render canonical codes -> labels; detect binary numeric yes/no
|
||||||
|
const numericCodes = codes.map(c => Number(String(c))).filter(n => !Number.isNaN(n));
|
||||||
|
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
||||||
|
if (isBinary) {
|
||||||
|
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
||||||
|
const noCode = codes.find(c => c !== yesCode) || null;
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'toggle-switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.name = fieldName;
|
||||||
|
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
||||||
|
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
||||||
|
const slider = document.createElement('span');
|
||||||
|
slider.className = 'slider';
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(slider);
|
||||||
|
wrapper.appendChild(label);
|
||||||
|
const small = document.createElement('small');
|
||||||
|
small.style.marginLeft = '10px';
|
||||||
|
small.textContent = vals[yesCode] || 'Yes';
|
||||||
|
wrapper.appendChild(small);
|
||||||
|
wrap.appendChild(wrapper);
|
||||||
|
} else {
|
||||||
|
const likert = document.createElement('div');
|
||||||
|
likert.className = 'likert';
|
||||||
|
codes.slice().sort((a,b) => {
|
||||||
|
const na = Number(a), nb = Number(b);
|
||||||
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
}).forEach(code => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'radio';
|
||||||
|
input.name = fieldName;
|
||||||
|
input.value = String(code).replace(/\.0$/, '');
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(document.createTextNode(' ' + (vals[code] || code)));
|
||||||
|
likert.appendChild(label);
|
||||||
|
});
|
||||||
|
wrap.appendChild(likert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(wrap);
|
||||||
|
grid.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// init sliders to show values
|
||||||
|
initSliders();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load variables:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Render inputs for elements with data-var attribute using SERVER_VALUE_LABELS.
|
||||||
|
// For each container with data-var="<varname>", we create controls based on the mapping:
|
||||||
|
// - If the mapping looks binary (codes like 0/1) we render a toggle checkbox (data-yes/data-no)
|
||||||
|
// - Otherwise we render radio inputs for each code->label pair (values are code without .0)
|
||||||
|
function renderVariableControls() {
|
||||||
|
console.log('[survey] renderVariableControls start, SERVER_VALUE_LABELS keys:', Object.keys(SERVER_VALUE_LABELS).length);
|
||||||
|
const containers = Array.from(document.querySelectorAll('[data-var]'));
|
||||||
|
containers.forEach(container => {
|
||||||
|
const varName = container.getAttribute('data-var');
|
||||||
|
if (!varName) return;
|
||||||
|
const map = SERVER_VALUE_LABELS[varName];
|
||||||
|
if (!map || typeof map !== 'object') return;
|
||||||
|
// clear existing content
|
||||||
|
container.innerHTML = '';
|
||||||
|
const codes = Object.keys(map);
|
||||||
|
// detect binary mapping (common pattern: 0.0/1.0 or 1.0/2.0 for yes/no)
|
||||||
|
const numericCodes = codes.map(c => Number(String(c)) ).filter(n => !Number.isNaN(n));
|
||||||
|
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
||||||
|
const fieldName = container.getAttribute('data-field') || varName;
|
||||||
|
|
||||||
|
if (isBinary) {
|
||||||
|
// determine the 'yes' code (prefer 1, then 2)
|
||||||
|
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
||||||
|
const noCode = codes.find(c => c !== yesCode) || null;
|
||||||
|
const yesLabel = map[yesCode] || 'Yes';
|
||||||
|
const noLabel = noCode ? map[noCode] : 'No';
|
||||||
|
// checkbox: when checked -> yes (1), unchecked -> no (0)
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'toggle-switch';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.name = fieldName;
|
||||||
|
// encode yes/no as data attributes so collectForm interprets correctly
|
||||||
|
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
||||||
|
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
||||||
|
const slider = document.createElement('span');
|
||||||
|
slider.className = 'slider';
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(slider);
|
||||||
|
wrapper.appendChild(label);
|
||||||
|
const small = document.createElement('small');
|
||||||
|
small.style.marginLeft = '10px';
|
||||||
|
small.textContent = yesLabel;
|
||||||
|
wrapper.appendChild(small);
|
||||||
|
container.appendChild(wrapper);
|
||||||
|
} else {
|
||||||
|
// render radios; sort codes numerically when possible for stable ordering
|
||||||
|
const sorted = codes.slice().sort((a, b) => {
|
||||||
|
const na = Number(a), nb = Number(b);
|
||||||
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
||||||
|
return String(a).localeCompare(String(b));
|
||||||
|
});
|
||||||
|
sorted.forEach(code => {
|
||||||
|
const labelText = map[code];
|
||||||
|
const valueToken = String(code).replace(/\.0$/, '');
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'radio';
|
||||||
|
input.name = fieldName;
|
||||||
|
input.value = valueToken;
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(document.createTextNode(' ' + labelText));
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: return the human-friendly label for a given field and token.
|
||||||
|
// Lookup order:
|
||||||
|
// 1) If SERVER_VALUE_LABELS has an entry for fieldName and a matching code -> return label.
|
||||||
|
// We attempt exact match on token, token+'.0', and token with trailing '.0' removed.
|
||||||
|
// 2) If VALUE_LABELS has an entry for token -> return that.
|
||||||
|
// 3) Else return null to indicate no mapping.
|
||||||
|
function labelFor(fieldName, token) {
|
||||||
|
if (!token || token === 'nan') return null;
|
||||||
|
// server-side per-variable lookup
|
||||||
|
// determine canonical var id for this field so we can resolve per-variable value labels
|
||||||
|
const varKey = FIELD_VARNAMES[fieldName] || fieldName;
|
||||||
|
// Prefer VAR_VALUES (values shipped with /variables) as it contains the recoded labels
|
||||||
|
if (VAR_VALUES && VAR_VALUES[varKey]) {
|
||||||
|
const map = VAR_VALUES[varKey];
|
||||||
|
// 1) If the token directly matches a code key, return the server label
|
||||||
|
if (map.hasOwnProperty(token)) return map[token];
|
||||||
|
// try token + '.0' and stripped
|
||||||
|
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
||||||
|
const strippedToken = token.replace(/\.0$/, '');
|
||||||
|
if (map.hasOwnProperty(strippedToken)) return map[strippedToken];
|
||||||
|
// 2) If the token maps locally to a human label, see if the server map contains that label
|
||||||
|
// (this lets us map semantic tokens like 'very_much' -> 'A great deal' if the server
|
||||||
|
// contains that exact human label for the field)
|
||||||
|
if (VALUE_LABELS.hasOwnProperty(token)) {
|
||||||
|
const human = VALUE_LABELS[token];
|
||||||
|
// try exact value match (case-sensitive); also try trimmed/case-insensitive
|
||||||
|
for (const k of Object.keys(map)) {
|
||||||
|
const serverLabel = map[k];
|
||||||
|
if (!serverLabel) continue;
|
||||||
|
if (serverLabel === human) return serverLabel;
|
||||||
|
if (serverLabel.trim().toLowerCase() === human.trim().toLowerCase()) return serverLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) additional numeric equivalence handled above
|
||||||
|
// 4) numeric equivalence match
|
||||||
|
try {
|
||||||
|
const numToken = Number(token);
|
||||||
|
if (!Number.isNaN(numToken)) {
|
||||||
|
for (const k of Object.keys(map)) {
|
||||||
|
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// fallback to server-provided per-variable mapping if present
|
||||||
|
if (SERVER_VALUE_LABELS && SERVER_VALUE_LABELS[varKey]) {
|
||||||
|
const map = SERVER_VALUE_LABELS[varKey];
|
||||||
|
if (map.hasOwnProperty(token)) return map[token];
|
||||||
|
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
||||||
|
const stripped2 = token.replace(/\.0$/, '');
|
||||||
|
if (map.hasOwnProperty(stripped2)) return map[stripped2];
|
||||||
|
try {
|
||||||
|
const numToken = Number(token);
|
||||||
|
if (!Number.isNaN(numToken)) {
|
||||||
|
for (const k of Object.keys(map)) {
|
||||||
|
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// fallback to global VALUE_LABELS exact match
|
||||||
|
if (VALUE_LABELS.hasOwnProperty(token)) {
|
||||||
|
console.log('[survey] labelFor fallback global VALUE_LABELS for', token, '->', VALUE_LABELS[token]);
|
||||||
|
return VALUE_LABELS[token];
|
||||||
|
}
|
||||||
|
console.log('[survey] labelFor no mapping for', fieldName, token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('predict').addEventListener('click', async () => {
|
||||||
|
const d = collectForm();
|
||||||
|
// Build a combined string of key:value for all form fields in a stable order
|
||||||
|
// Missing values become 'nan' to mirror downstream expectations
|
||||||
|
const parts = [];
|
||||||
|
Object.keys(d).forEach(k => {
|
||||||
|
let v = d[k];
|
||||||
|
if (v === null || v === undefined || v === '') {
|
||||||
|
v = 'nan';
|
||||||
|
} else if (typeof v === 'object') {
|
||||||
|
v = JSON.stringify(v);
|
||||||
|
} else {
|
||||||
|
v = String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to map token to friendly label using per-field labels first,
|
||||||
|
// then global fallback.
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const fieldLabelVal = labelFor(k, v);
|
||||||
|
if (fieldLabelVal) {
|
||||||
|
v = fieldLabelVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use server-provided internal_label (if available) as the key in the combined text
|
||||||
|
// Fall back to the human-friendly FIELD_LABELS or the field name
|
||||||
|
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
||||||
|
parts.push(`${internalKey}: ${v}`);
|
||||||
|
});
|
||||||
|
const combined = parts.join(' | ');
|
||||||
|
|
||||||
|
output.textContent = 'Sending...';
|
||||||
|
console.log('[survey] Sending combined payload to /predict');
|
||||||
|
try {
|
||||||
|
// Build an internal-keyed mapping to send alongside the combined text
|
||||||
|
const request_parsed_internal = {};
|
||||||
|
Object.keys(d).forEach(k => {
|
||||||
|
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
||||||
|
request_parsed_internal[internalKey] = d[k] === null || d[k] === undefined || d[k] === '' ? 'nan' : d[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch('/predict', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: combined })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
output.textContent = `Error ${res.status}: ${text}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
output.textContent = JSON.stringify(json, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
output.textContent = 'Network error: ' + (err && err.message ? err.message : String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Self-test: programmatically choose first option in each likert group, enable toggles and fill a couple fields,
|
||||||
|
// then trigger the Predict flow so testers can quickly exercise the endpoint.
|
||||||
|
function fillSelfTest() {
|
||||||
|
// pick first radio of each group
|
||||||
|
console.log('[survey] fillSelfTest starting');
|
||||||
|
const radios = Array.from(document.querySelectorAll('input[type=radio]'));
|
||||||
|
const grouped = {};
|
||||||
|
radios.forEach(r => { if (!grouped[r.name]) grouped[r.name] = []; grouped[r.name].push(r); });
|
||||||
|
Object.values(grouped).forEach(g => { if (g.length) g[0].checked = true; });
|
||||||
|
|
||||||
|
// enable all toggle checkboxes (rendered as .toggle-switch input)
|
||||||
|
document.querySelectorAll('.toggle-switch input[type=checkbox]').forEach(ch => ch.checked = true);
|
||||||
|
|
||||||
|
// fill first text inputs
|
||||||
|
const firstText = document.querySelector('input[type=text]'); if (firstText) firstText.value = 'Sample';
|
||||||
|
|
||||||
|
// set ranges to mid values
|
||||||
|
document.querySelectorAll('input[type=range]').forEach(r => {
|
||||||
|
const min = Number(r.min || 0);
|
||||||
|
const max = Number(r.max || 10);
|
||||||
|
r.value = Math.floor((min + max) / 2);
|
||||||
|
r.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[survey] fillSelfTest done populating fields');
|
||||||
|
// trigger predict
|
||||||
|
const btn = document.getElementById('predict');
|
||||||
|
if (btn) btn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('selftest').addEventListener('click', fillSelfTest);
|
||||||
|
|
||||||
|
// Keep range-value spans in sync with range inputs
|
||||||
|
function initSliders() {
|
||||||
|
const ranges = Array.from(document.querySelectorAll('input[type=range]'));
|
||||||
|
ranges.forEach(r => {
|
||||||
|
const span = document.querySelector(`.range-value[data-for="${r.id}"]`);
|
||||||
|
const update = () => {
|
||||||
|
if (span) span.textContent = r.value;
|
||||||
|
// set ARIA value
|
||||||
|
r.setAttribute('aria-valuenow', r.value);
|
||||||
|
};
|
||||||
|
// initialize
|
||||||
|
update();
|
||||||
|
r.addEventListener('input', update);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initSliders);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p><small>If you need CSV export or different field typing, tell me and I'll add it.</small></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -22,3 +22,33 @@ def get_ollama_embedding(text, model="nomic-embed-text"):
|
|||||||
response = requests.post(url, json=payload)
|
response = requests.post(url, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()["embedding"]
|
return response.json()["embedding"]
|
||||||
|
|
||||||
|
def parse_combined_text_to_dict(s: str) -> dict:
|
||||||
|
"""Parse a string of the form 'key1: value1 | key2: value2' into a dict.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Split on ' | ' to get key:value segments.
|
||||||
|
- For each segment, split on the first ':' to separate key and value.
|
||||||
|
- Strip whitespace. If a value is 'nan' (case-insensitive) or empty, use None.
|
||||||
|
- Return a dict mapping keys to values or None.
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
parts = [p.strip() for p in s.split("|")]
|
||||||
|
for part in parts:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
# split on the first colon
|
||||||
|
if ':' in part:
|
||||||
|
k, v = part.split(':', 1)
|
||||||
|
k = k.strip()
|
||||||
|
v = v.strip()
|
||||||
|
if v.lower() == 'nan' or v == '':
|
||||||
|
result[k] = None
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
else:
|
||||||
|
# fallback: store whole segment under a numeric key
|
||||||
|
result.setdefault('_extra', []).append(part)
|
||||||
|
return result
|
||||||
Reference in New Issue
Block a user