Deploy a Lithium Import Shock Monitor

Deploy a public dashboard that flags unusual lithium import contractions.

Introduction

30 Second Summary

A sudden drop in a critical material can affect production plans long before the cause becomes clear. Analysts need an early signal that highlights unusual contractions without claiming they prove a disruption.

In this project, you will turn official monthly U.S. lithium carbonate import values into an early-warning monitor. A leakage-safe seasonal forecast powers the shock signals shown in a public Streamlit Community Cloud app.

What You'll Build

At your public URL, the monitor turns monthly import data into a chart plus a short alert table that an analyst can inspect in seconds.

By the end of this project, you'll have:

  • A reproducible data pull in Google Colab that downloads monthly lithium carbonate import values from the U.S. Census Data API without exposing your key to GitHub.
  • A public import monitor that shows the latest value plus the full history of actual imports.
  • An explainable shock signal that compares forecasting baselines. You can inspect unusually negative residuals that cross the calibrated threshold.
  • A Secret Mission: Add an analyst-controlled threshold that updates the calibrated alert count and shock table live.

Are there any prerequisites?

This project assumes a GitHub account plus access to Google Colab. Intermediate Python experience plus familiarity with machine learning will help you focus on the analysis.

The guide covers Census API key setup plus Streamlit Community Cloud account setup.

Before We Start

Before the hands-on work begins, you'll define who this lithium import early-warning product serves. Your one-sentence statement will tie the monitor to a real analyst decision.

Set Up Data and Deployment Accounts

Your product decision now defines who uses the monitor. The data pipeline still needs authenticated access to the U.S. Census Data API.

The public app also needs a cloud deployment path that avoids local Mac software. Streamlit Community Cloud provides that path through GitHub.

Google Colab provides the browser-based Python environment. You'll install pinned packages there so the extractor uses known versions.

In this step, get ready to:
  • Activate private access to official Census trade data.
  • Connect Community Cloud to your GitHub account.
  • Prepare a Colab notebook with the pinned packages.
Activate your Census API key

An API key identifies your requests to the Census service. The key is free through the official signup process.

Handling a new credential can feel risky. In this project, you keep it outside GitHub before entering it through a hidden Colab prompt later.

  • Open the official Census key signup page.
  • Enter the contact details requested by the signup form.
  • Submit the signup form.
  • Open the message sent by the Census Data API.
  • Use the activation link in that message to activate the key.
  • Store the activated key in a password manager or another private note outside GitHub.

That is the sensitive setup handled. Your official-data credential is active. It remains outside GitHub.

Connect Community Cloud to GitHub

Community Cloud deploys apps from connected GitHub repositories. The account connection creates the path your monitor uses when it reaches the web.

This authorization screen can feel high-stakes. It connects your existing GitHub account so Community Cloud can deploy repositories you choose.

  • Open the Streamlit Community Cloud signup page.
  • Click Continue to sign-in.
  • Create your Community Cloud account with one of the displayed sign-in methods.
  • Click Connect GitHub account.
  • Complete the GitHub sign-in prompt if it appears.
  • Click Authorize streamlit on the GitHub authorization screen.
  • Confirm the Community Cloud workspace opens.

Your workspace should now be accessible from Community Cloud. The GitHub connection is the deployment bridge for your monitor.

Prepare your Colab environment

A Colab notebook gives you a cloud Python runtime in the browser. Pinned package versions keep your data work aligned with the deployed app.

  • Go to Google Colab in your browser.
  • Create a blank notebook.

You should see an empty code cell in the new notebook. This confirms the cloud environment is ready for setup.

  • Select the folder icon in Colab's left sidebar to show the runtime files.
  • Use the file pane's new-file control to create requirements.txt in the current Colab runtime.

You should see requirements.txt listed in the file pane.

  • Paste this package list into requirements.txt:
streamlit==1.66.1
pandas==3.1.0
requests==2.34.2

What does this file do?

  • The requirements.txt file records the package versions used by the project.
  • streamlit==1.66.1 provides the web-app framework.
  • pandas==3.1.0 provides the time-series data tools.
  • requests==2.34.2 provides the HTTP client used to call the Census endpoint.
  • Save requirements.txt.
  • Reopen requirements.txt from Colab's file pane.

You should see three lines. Each line should contain one package with its pinned version.

Missing the requirements file?

  • Confirm you created requirements.txt inside the current Colab runtime's file pane.
  • Replace the file contents if any package name or version differs from the reference above.

Ask for help: My requirements file is missing or contains different package versions. Can you help me check it?

✔️ Awesome, I've got everything!

Great. Double check that requirements.txt is saved before installing the packages.

ⓧ I'd like to double check the full code

Compare your package file with this complete reference.

streamlit==1.66.1
pandas==3.1.0
requests==2.34.2

Before you run the install cell, do you expect Colab to use unpinned packages or the exact versions in your command?

  • Select an empty code cell in your Colab notebook.
  • Install the pinned packages by running this command:
!pip install streamlit==1.66.1 pandas==3.1.0 requests==2.34.2

What does this command do?

  • The !pip install command asks the Colab runtime to install Python packages.
  • Each == pin selects the exact package version recorded in requirements.txt.
  • The packages are installed into the active notebook runtime.

The cell should finish without an error. You should see streamlit==1.66.1, pandas==3.1.0, and requests==2.34.2 represented in the installation output.

Package installation did not finish?

  • Check that your install cell matches the command above exactly.
  • Reconnect the Colab runtime if the notebook lost its connection.
  • Rerun the installation cell after the runtime reconnects.

Ask for help: My Colab package installation did not finish successfully. Can you help me interpret the visible error?

Your browser-based environment is ready for reproducible extraction. Next, you'll request the official lithium carbonate series and turn it into a downloadable CSV.

Extract the Official Import Series

Your accounts are ready. Your pinned packages are available in Google Colab. The monitor now needs evidence that matches the product decision you defined.

A forecasting signal needs a reproducible official source before it needs a model. This step uses the U.S. Census Data API to reveal the monthly lithium carbonate import history.

In this step, get ready to:
  • Request monthly general import values for HS code 283691.
  • Confirm the row count and date range for the intended commodity.
  • Download lithium_imports.csv without exposing the Census API key.
Build the authenticated extractor

The extractor needs the official endpoint and a Harmonized System commodity code before it can request any trade values. HS code 283691 identifies lithium carbonates.

This credential step is safe to complete in Colab because the hidden prompt masks what you paste. The extractor keeps the key in temporary runtime memory.

  • Return to the Google Colab notebook from earlier.
  • Add a code cell beneath the package installation cell.
  • Prepare the authenticated extractor by pasting this code into the new cell:
from datetime import date
from getpass import getpass

import pandas as pd
import requests
from google.colab import files

API_URL = "https://api.census.gov/data/timeseries/intltrade/imports/hs"
HS_CODE = "283691"
START_YEAR = 2010

api_key = getpass("Paste your Census API key: ").strip()
if not api_key:
    raise ValueError("A Census API key is required.")

today = date.today()
end_year = today.year
end_month = today.month - 2
if end_month <= 0:
    end_month += 12
    end_year -= 1

rows = []

What does this setup do?

  • The API_URL value points to the official monthly HS imports endpoint.
  • The HS_CODE value limits every request to lithium carbonates.
  • The getpass() prompt accepts the API key without displaying it in the notebook.
  • The date logic sets a recent completed month as the extraction boundary.
  • The rows list provides a temporary home for each monthly record.
  • Run the setup cell.
  • Paste your activated Census API key into the hidden prompt.
  • Press Enter to submit the hidden value.

The cell finishes after accepting the hidden value. The rows list is now ready to receive monthly records.

Key prompt not completing?

  • Rerun the cell if the hidden prompt closed before you submitted the key.
  • Paste the activated key from its secure storage location.
  • Keep the key outside the notebook code and GitHub.

Ask for help: My hidden Census API key prompt is not completing. Can you help me check the Colab cell without exposing my key?

Request the monthly lithium series

The Census endpoint accepts year and month parameters. The loop makes one small request for every month from 2010 through the calculated end month.

Expect this request cell to take a few minutes because it makes one call per month. The notebook remains busy until the final monthly response has been processed.

  • Add another code cell beneath the setup cell.
  • Request the monthly records by pasting this code into the new cell:
for year in range(START_YEAR, end_year + 1):
    last_month = end_month if year == end_year else 12
    for month in range(1, last_month + 1):
        params = {
            "get": "I_COMMODITY,I_COMMODITY_LDESC,GEN_VAL_MO",
            "I_COMMODITY": HS_CODE,
            "YEAR": str(year),
            "MONTH": f"{month:02d}",
            "key": api_key,
        }
        response = requests.get(API_URL, params=params, timeout=30)
        if response.status_code == 204:
            continue
        response.raise_for_status()
        payload = response.json()
        if not isinstance(payload, list) or len(payload) < 2:
            continue

        record = dict(zip(payload[0], payload[1]))
        rows.append(
            {
                "month": f"{year}-{month:02d}-01",
                "import_value_usd": int(record["GEN_VAL_MO"]),
                "hs_code": record["I_COMMODITY"],
                "commodity": record["I_COMMODITY_LDESC"],
            }
        )

How are the monthly records built?

  • The nested loops generate one year and month combination for each request.
  • The request asks for the commodity code and its description. It also asks for the monthly general import value.
  • The Requests library sends each query with a timeout.
  • Each successful JSON response becomes a dictionary with four fields.
  • The API key authenticates the request. It is excluded from the dictionary added to rows.
  • Run the monthly request cell.

The cell completes after loading the successful monthly responses into rows. A completed cell without an error means the extractor can move to its data checks.

Request cell stopped with an error?

  • Rerun the setup cell if the Colab runtime restarted after you entered the key.
  • Submit the key through the hidden prompt again.
  • Rerun the monthly request cell after the setup cell completes.

Ask for help: My monthly Census request cell stopped with an error. Can you help me identify whether the runtime state or an API response caused it?

Export and inspect the CSV

A trustworthy extract needs a visible identity check before it becomes model input. The confirmation line ties the data to its row count and date range.

Pandas converts the collected records into a table. The resulting CSV contains only the four fields needed by the monitor.

  • Add one final code cell beneath the request cell.
  • Validate and export the collected records by pasting this code into the new cell:
if not rows:
    raise RuntimeError("The Census API returned no lithium carbonate rows.")

df = pd.DataFrame(rows)
df.to_csv("lithium_imports.csv", index=False)
print(
    f"Saved {len(rows)} monthly rows from {rows[0]['month']} "
    f"to {rows[-1]['month']} for HS {HS_CODE}: {rows[-1]['commodity']}"
)
files.download("lithium_imports.csv")

What does the export check prove?

  • The empty-result check stops the export if the API returned no lithium carbonate records.
  • The DataFrame organizes every monthly record into the same four columns.
  • The printed line reports the row count and date range. It also reports the HS code and commodity description.
  • The download contains no api_key column because the key was never added to rows.

Before you run the export cell, what should a trustworthy confirmation line tell you about the dataset?

  • Run the export cell.

You will see a saved row count followed by the first and last month. The same line identifies HS 283691 with a lithium carbonate description.

  • Download lithium_imports.csv when the browser download starts.
  • Open the downloaded lithium_imports.csv in a spreadsheet or text editor.
  • Confirm the header contains month, import_value_usd, hs_code, and commodity.

You will see one row per returned month. You will not see an API key column.

That is the official-data pipeline working. Your monitor now has a reproducible lithium carbonate import history without a published credential.

CSV not downloading?

  • Allow the browser download if it was blocked.
  • Rerun the request cell if the export reports that no rows were returned.
  • Rerun the export cell after the request cell finishes.

Ask for help: My Colab extractor finished but lithium_imports.csv did not download. Can you help me check the export cell and browser download?

✔️ Awesome, I've got everything!

Great. Double check that lithium_imports.csv is saved in your downloads before moving on.

ⓧ I'd like to double check the full code

Compare your three Colab cells with the complete build_data.py script below.

from datetime import date
from getpass import getpass

import pandas as pd
import requests
from google.colab import files

API_URL = "https://api.census.gov/data/timeseries/intltrade/imports/hs"
HS_CODE = "283691"
START_YEAR = 2010

api_key = getpass("Paste your Census API key: ").strip()
if not api_key:
    raise ValueError("A Census API key is required.")

today = date.today()
end_year = today.year
end_month = today.month - 2
if end_month <= 0:
    end_month += 12
    end_year -= 1

rows = []
for year in range(START_YEAR, end_year + 1):
    last_month = end_month if year == end_year else 12
    for month in range(1, last_month + 1):
        params = {
            "get": "I_COMMODITY,I_COMMODITY_LDESC,GEN_VAL_MO",
            "I_COMMODITY": HS_CODE,
            "YEAR": str(year),
            "MONTH": f"{month:02d}",
            "key": api_key,
        }
        response = requests.get(API_URL, params=params, timeout=30)
        if response.status_code == 204:
            continue
        response.raise_for_status()
        payload = response.json()
        if not isinstance(payload, list) or len(payload) < 2:
            continue

        record = dict(zip(payload[0], payload[1]))
        rows.append(
            {
                "month": f"{year}-{month:02d}-01",
                "import_value_usd": int(record["GEN_VAL_MO"]),
                "hs_code": record["I_COMMODITY"],
                "commodity": record["I_COMMODITY_LDESC"],
            }
        )

if not rows:
    raise RuntimeError("The Census API returned no lithium carbonate rows.")

df = pd.DataFrame(rows)
df.to_csv("lithium_imports.csv", index=False)
print(
    f"Saved {len(rows)} monthly rows from {rows[0]['month']} "
    f"to {rows[-1]['month']} for HS {HS_CODE}: {rows[-1]['commodity']}"
)
files.download("lithium_imports.csv")

Your official import series is ready. Next up, you will turn the downloaded history into the first public version of the monitor.

Deploy the First Visible Monitor

Your official U.S. Census Data API extract now proves that the pipeline returns monthly lithium carbonate import values. A downloaded CSV still keeps that evidence on your computer.

In this step, you will publish the project through GitHub and deploy it with Streamlit Community Cloud. The raw-data view creates a shareable win before forecasting adds complexity.

In this step, get ready to:
  • Publish the extractor, package pins, and non-secret CSV in a public repository.
  • Build a raw-data app with a latest-value metric and import-history chart.
  • Deploy the app to a public URL and verify the live monitor.
Publish the project files to GitHub

A public repository gives Community Cloud a stable source for the app. It also keeps each deployment tied to a visible version of your code and data.

Publishing a repository can feel risky because anyone can inspect its files. Your project is safe to publish because build_data.py asks for the Census key at runtime while lithium_imports.csv contains no key.

  • Switch back to the Google Colab notebook from earlier.
  • Download build_data.py from Colab's file browser.
  • Download requirements.txt from the same file browser.

Your browser's downloads list now contains build_data.py and requirements.txt. The previously downloaded lithium_imports.csv completes the upload set.

  • Return to the GitHub browser tab from earlier.
  • Click New repository in the upper-right menu.
  • Select your personal account in the Owner field.
  • Enter lithium-import-shock-monitor in the Repository name field.
  • Select Public as the repository visibility.
  • Select Add a README file.
  • Click Create repository.

Good start. Your public lithium-import-shock-monitor repository now exists with a README at its root.

  • Click Add file above the repository file list.
  • Click Upload files.
  • Drag the three project files into the upload area.
  • Enter Add Census data pipeline files in the Commit message field.
  • Select Commit directly to the main branch.
  • Click Commit changes.

You should see build_data.py, lithium_imports.csv, and requirements.txt beside the README in the repository file list.

Missing an uploaded file?

  • Check your browser's downloads list for the exact filenames build_data.py and requirements.txt.
  • Confirm that lithium_imports.csv is the file downloaded by the successful extractor cell.
  • Repeat the upload for the missing file from the repository root.

Ask for help: Help me find why one of my project files is missing from my GitHub repository.

Build the raw-data Streamlit app

The first app focuses on one job. It turns the committed import series into a public view with a current value and a chronological history.

  • Click Add file above the repository file list.
  • Click Create new file.
  • Enter app.py in the file name field.
  • Build the raw-data monitor by pasting this code into the file editor:
import pandas as pd
import streamlit as st

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)
latest = df.iloc[-1]

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric("Latest import value", f"${latest['import_value_usd']:,.0f}")
chart = df.set_index("month")[["import_value_usd"]] / 1_000_000
chart.columns = ["Actual imports ($M)"]
st.line_chart(chart)

What does this code do?

  • The imports make pandas available for reading the CSV. They also make Streamlit available for rendering the page.
  • The page configuration gives the browser tab its project title. The wide layout leaves room for the time-series chart.
  • The data-loading section parses month as dates. Sorting places the newest observation at the end of the dataframe.
  • The metric displays the latest import value in U.S. dollars. The chart converts the historical values to millions of dollars for easier reading.
  • Click Commit changes....
  • Enter Add first Streamlit monitor in the Commit message field.
  • Select Commit directly to the main branch.
  • Click Commit changes.

You should now see app.py, build_data.py, lithium_imports.csv, and requirements.txt at the repository root.

Is app.py missing or incomplete?

  • Confirm that the filename is exactly app.py.
  • Check that app.py sits beside lithium_imports.csv at the repository root.
  • Compare the committed file with the full-code reference below.

Ask for help: Help me check why app.py is missing or incomplete in my GitHub repository.

✔️ Awesome, I've got everything!

Great. Double check that all four project files are committed at the repository root.

ⓧ I'd like to double check the full code

Compare your complete app.py file with this version.

import pandas as pd
import streamlit as st

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)
latest = df.iloc[-1]

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric("Latest import value", f"${latest['import_value_usd']:,.0f}")
chart = df.set_index("month")[["import_value_usd"]] / 1_000_000
chart.columns = ["Actual imports ($M)"]
st.line_chart(chart)
Deploy and verify the public monitor

Community Cloud reads the repository files and installs the pinned dependencies from requirements.txt. It then runs app.py as the public app's entrypoint.

Most apps deploy within a few minutes. A longer wait can happen while Community Cloud installs the dependencies for the first launch.

  • Switch back to the Streamlit Community Cloud workspace from earlier.
  • Click Create app in the upper-right corner.
  • Click Yup, I have an app. when asked whether you already have an app.
  • Select lithium-import-shock-monitor in the repository field.
  • Enter main in the branch field.
  • Enter app.py in the file path field.
  • Click Deploy.
  • Wait for the app launch to finish.

Community Cloud assigns the deployment a public address ending in .streamlit.app.

  • Record the assigned public URL here: https://your-app.streamlit.app.

Before you inspect the launched page, what title and data view do you expect the repository files to produce?

  • Open https://your-app.streamlit.app in a new browser tab.

You should see a page titled Lithium Import Shock Monitor. The page displays the source note, the latest import-value metric, and an actual-import history line chart.

That's the first public win complete. Your official lithium import history now has a shareable monitor that anyone can open from the assigned URL.

Did the deployment fail?

  • Confirm that the deployment file path is exactly app.py.
  • Confirm that app.py and lithium_imports.csv are at the same repository level.
  • Compare requirements.txt with the pinned package file from earlier.

Ask for help: Help me diagnose why my Streamlit Community Cloud deployment is not loading the committed app.

Your raw-data monitor is live and reading the committed Census extract. Next, you will test a naive alert rule and see why ordinary monthly volatility creates too many warnings.

Expose the Naive Alert Problem

Your official import series is already live in a public Streamlit app. However, a chart alone does not turn a contraction into an alert.

A previous-month baseline compares each month with the month immediately before it. In this step, you will test whether every decline deserves an alert.

In this step, get ready to:
  • Create a previous-month forecast for every eligible row.
  • Measure the absolute error from each previous-month forecast.
  • Publish the alert total to your public monitor.
Build the previous-month baseline

Each forecast uses the previous row as its expectation for the current month. The alert rule then flags every month whose import value falls below that expectation.

  • Switch back to the lithium-import-shock-monitor repository on GitHub.
  • Select app.py from the repository file list.
  • Use the pencil icon in the file toolbar to enter the browser editor.
  • Replace the entire file contents with the code below:
import pandas as pd
import streamlit as st

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)
df["naive_forecast_usd"] = df["import_value_usd"].shift(1)
df["naive_alert"] = df["import_value_usd"] < df["naive_forecast_usd"]
df["naive_error_usd"] = (
    df["import_value_usd"] - df["naive_forecast_usd"]
).abs()
latest = df.iloc[-1]
naive_mae = df["naive_error_usd"].mean()

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric("Latest import value", f"${latest['import_value_usd']:,.0f}")
chart = df.set_index("month")[["import_value_usd"]] / 1_000_000
chart.columns = ["Actual imports ($M)"]
st.line_chart(chart)
st.metric("Previous-month MAE", f"${naive_mae:,.0f}")
st.metric("Naive month-to-month alerts", int(df["naive_alert"].sum()))

What does this code do?

  • The shift(1) calculation lines up each row with the import value from one month earlier.
  • The first row has no previous month in the dataset, so its forecast remains empty.
  • The naive_alert column marks every decline from the previous month.
  • The naive_error_usd column uses .abs() to store the size of each forecast miss.
  • The mean() result becomes the mean absolute error shown by the Previous-month MAE metric.
  • The Naive month-to-month alerts metric counts all rows where naive_alert is true.
  • Each forecast reaches backward by one row. This avoids data leakage from future observations.
  • Commit the updated app.py through GitHub's commit controls with the message Add naive alert baseline.
  • Return to the committed app.py file view.

Your committed file should now create naive_forecast_usd, naive_alert, and naive_error_usd. Its final two lines should display the error metric and alert total.

Does the committed file look different?

  • Check that naive_mae = df["naive_error_usd"].mean() appears before the display code.
  • Check that both new st.metric lines appear after st.line_chart(chart).
  • Compare the indentation around the multi-line naive_error_usd calculation with the reference below.

Ask for help: My committed app.py does not match the naive-baseline version. Can you help me compare the forecast, error, alert, and metric lines?

✔️ Awesome, I've got everything!

That is a solid checkpoint. Your repository now contains a complete naive-baseline app.

ⓧ I'd like to double check the full code

Compare your committed app.py with this complete version.

import pandas as pd
import streamlit as st

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)
df["naive_forecast_usd"] = df["import_value_usd"].shift(1)
df["naive_alert"] = df["import_value_usd"] < df["naive_forecast_usd"]
df["naive_error_usd"] = (
    df["import_value_usd"] - df["naive_forecast_usd"]
).abs()
latest = df.iloc[-1]
naive_mae = df["naive_error_usd"].mean()

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric("Latest import value", f"${latest['import_value_usd']:,.0f}")
chart = df.set_index("month")[["import_value_usd"]] / 1_000_000
chart.columns = ["Actual imports ($M)"]
st.line_chart(chart)
st.metric("Previous-month MAE", f"${naive_mae:,.0f}")
st.metric("Naive month-to-month alerts", int(df["naive_alert"].sum()))
See the over-alerting result

Streamlit Community Cloud rebuilds the deployment from your GitHub commit. The rebuild can take a few minutes, so a short pause is expected.

  • Switch back to the Streamlit Community Cloud workspace from earlier.
  • Wait for the deployment to finish rebuilding from your latest commit.
  • Return to the public .streamlit.app page from earlier.

Before you refresh the app, do you expect every downward move in the chart to represent a genuine supply-chain shock?

  • Refresh the public app in your browser.

You should see a Previous-month MAE metric beneath the chart. You should also see a Naive month-to-month alerts metric with a large total.

  • Scan the line chart for recurring downward steps across the monthly history.
  • Compare those downward steps with the Naive month-to-month alerts total.

The rule counts many ordinary declines as alerts. It has no way to separate routine volatility from an unusually severe contraction.

Still seeing only the original chart?

  • Confirm that the latest GitHub commit contains both new st.metric lines.
  • Confirm that Streamlit Community Cloud still deploys from the lithium-import-shock-monitor repository.
  • Refresh the public page after the deployment finishes rebuilding.

Ask for help: My public Streamlit app still shows the old version. Can you help me trace the GitHub commit and deployment update?

You found the weakness by making it visible in the deployed product. Next, you will add yearly seasonality and prior-only residual calibration to create a more selective screening signal.

Calibrate the Shock Signal

Your public monitor now exposes the weakness in its first alert rule. Ordinary month-to-month volatility creates too many alerts for an analyst to investigate.

Now you'll compare that rule with a seasonal forecast. You will use residual standardization to separate unusually negative misses from routine variation. Prior-only calibration prevents data leakage from making the score look more reliable than it is.

In this step, get ready to:
  • Compare previous-month and same-month-last-year forecast errors.
  • Calculate a leakage-safe shock score from prior residuals.
  • Publish the calibrated alert table with an early-warning disclaimer.
Compare the forecasting baselines

The previous-month baseline assumes the latest observation is the best expectation for the next month. A same-month-last-year baseline uses the value from 12 months earlier to capture recurring annual patterns.

Why compare both baselines?

A seasonal baseline can fit recurring annual patterns. Its extra structure does not guarantee a smaller forecasting error.

Mean absolute error measures the typical size of each miss in dollars. Comparing both values lets the observed history decide which baseline performs better.

  • Switch back to the GitHub repository named lithium-import-shock-monitor from earlier.
  • Select app.py in the repository file list.
  • Click the pencil icon in the upper-right corner of the file view.
  • Replace the forecast and error calculations with the code below:
df["naive_forecast_usd"] = df["import_value_usd"].shift(1)
df["seasonal_forecast_usd"] = df["import_value_usd"].shift(12)
df["naive_alert"] = df["import_value_usd"] < df["naive_forecast_usd"]

df["naive_error_usd"] = (
    df["import_value_usd"] - df["naive_forecast_usd"]
).abs()
df["seasonal_error_usd"] = (
    df["import_value_usd"] - df["seasonal_forecast_usd"]
).abs()

What does this code do?

  • The pandas operation shift(12) aligns each month with the observation from one year earlier.
  • The seasonal_error_usd column stores the absolute difference between each observation and its seasonal forecast.
  • The original naive_forecast_usd calculation remains available as the benchmark.
  • Replace the chart and metric block from chart = df.set_index through the end of the file with the code below:
seasonal_mae = df["seasonal_error_usd"].mean()

chart = df.set_index("month")[[
    "import_value_usd",
    "seasonal_forecast_usd",
]] / 1_000_000
chart.columns = ["Actual imports ($M)", "Seasonal forecast ($M)"]
st.line_chart(chart)

st.write("## Forecast comparison")
st.metric("Previous-month MAE", f"${naive_mae:,.0f}")
st.metric("Same-month-last-year MAE", f"${seasonal_mae:,.0f}")
st.metric("Naive month-to-month alerts", int(df["naive_alert"].sum()))
st.write("Lower MAE is better; do not assume the seasonal baseline must win.")

What changes in the monitor?

  • The chart places actual imports beside the seasonal expectation in millions of dollars.
  • The two MAE metrics expose the historical error from each forecasting rule.
  • The naive alert count stays visible as evidence of the over-alerting problem.
  • Click Commit changes... above the file editor.
  • Enter Add seasonal forecast comparison in the commit message field.
  • Select the option that commits directly to the currently deployed branch.
  • Click Commit changes in the commit dialog.

Before you refresh, which baseline do you expect to have the lower MAE? Hold that prediction.

  • Switch back to the public Streamlit Community Cloud app from earlier.
  • Refresh the app after the new commit finishes deploying.

You should see separate lines for actual imports and the seasonal forecast. The forecast comparison should show both MAE values.

Compare your prediction with the displayed values. The smaller metric identifies the baseline with the lower average historical miss.

Missing the seasonal line?

  • Check that seasonal_forecast_usd uses shift(12).
  • Check that the chart selects both import_value_usd and seasonal_forecast_usd.
  • Confirm the latest commit contains your changes to app.py.

Ask for help: Why is my seasonal forecast missing from the Streamlit chart?

Calibrate prior-only residuals

A residual is the signed difference between the actual value and its forecast. A strongly negative residual means imports landed below the seasonal expectation.

The score needs a historical reference for the normal size of forecast misses. Shifting residuals before the rolling calculation ensures the current observation cannot influence its own benchmark.

  • Return to app.py in the repository from earlier.
  • Click the pencil icon in the upper-right corner of the file view.
  • Replace everything from the first import through the seasonal_error_usd calculation with the code below:
import pandas as pd
import streamlit as st

SHOCK_THRESHOLD = -2.0

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)

df["naive_forecast_usd"] = df["import_value_usd"].shift(1)
df["seasonal_forecast_usd"] = df["import_value_usd"].shift(12)
df["naive_alert"] = df["import_value_usd"] < df["naive_forecast_usd"]

df["naive_error_usd"] = (
    df["import_value_usd"] - df["naive_forecast_usd"]
).abs()
df["seasonal_error_usd"] = (
    df["import_value_usd"] - df["seasonal_forecast_usd"]
).abs()
df["residual_usd"] = (
    df["import_value_usd"] - df["seasonal_forecast_usd"]
)

How does the residual work?

  • The fixed SHOCK_THRESHOLD value defines the project screening cutoff at -2.0.
  • The residual_usd column preserves the direction of each forecasting miss.
  • Negative residuals identify months when actual imports were below the seasonal forecast.
  • Paste the prior-only calibration code directly below the residual_usd calculation:
prior_residuals = df["residual_usd"].shift(1)
df["residual_mean_24"] = prior_residuals.rolling(
    window=24, min_periods=12
).mean()
df["residual_std_24"] = prior_residuals.rolling(
    window=24, min_periods=12
).std()
df.loc[df["residual_std_24"] == 0, "residual_std_24"] = float("nan")
df["shock_score"] = (
    df["residual_usd"] - df["residual_mean_24"]
) / df["residual_std_24"]
df["shock_flag"] = df["shock_score"] <= SHOCK_THRESHOLD

How does this prevent leakage?

  • The shift(1) operation excludes the current residual from the calibration history.
  • The rolling window uses up to 24 earlier residuals. It requires at least 12 earlier residuals before producing a score.
  • The shock_score measures the current residual in units of prior residual standard deviation.
  • The zero-standard-deviation check prevents division by zero.
  • Click Commit changes... above the file editor.
  • Enter Add prior-only residual calibration in the commit message field.
  • Select the option that commits directly to the currently deployed branch.
  • Click Commit changes in the commit dialog.
  • Refresh the public app after the deployment updates.

You should see the monitor load with its forecast comparison intact. A successful page load proves the prior-only rolling calculations complete without stopping the app.

Does the app show an exception?

  • Check that prior_residuals is created from df["residual_usd"].shift(1).
  • Check that both rolling calculations use window=24, min_periods=12.
  • Confirm that SHOCK_THRESHOLD appears above the page configuration.

Ask for help: Help me debug the prior-only residual calibration in app.py.

  • Return to app.py in the repository from earlier.
  • Click the pencil icon in the upper-right corner of the file view.
  • Replace everything from latest = df.iloc[-1] through the current seasonal_mae line with the code below:
latest = df.dropna(subset=["shock_score"]).iloc[-1]
naive_mae = df["naive_error_usd"].mean()
seasonal_mae = df["seasonal_error_usd"].mean()

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric(
    "Latest import value",
    f"${latest['import_value_usd']:,.0f}",
    f"${latest['residual_usd']:,.0f} vs seasonal forecast",
)
st.metric("Latest shock score", f"{latest['shock_score']:.2f}")
st.write(
    "Status: Shock signal"
    if bool(latest["shock_flag"])
    else "Status: No shock signal"
)

What does the latest status mean?

  • The latest complete row supplies the import value and standardized shock score.
  • The metric delta shows the dollar residual against the seasonal forecast.
  • The status reports whether the latest score crosses the fixed screening threshold.
  • Click Commit changes... above the file editor.
  • Enter Display the latest shock score in the commit message field.
  • Select the option that commits directly to the currently deployed branch.
  • Click Commit changes in the commit dialog.
  • Refresh the public app after the deployment updates.

You should see a latest shock score beneath the import metric. You should also see a status based on the fixed cutoff.

Publish the calibrated alert view

A decision-ready monitor needs a short list of unusual months. The calibrated table filters for negative residuals that cross the fixed screening threshold.

The final disclaimer keeps the interpretation disciplined. The score supports early investigation without claiming a probability or a cause.

  • Return to app.py in the repository from earlier.
  • Click the pencil icon in the upper-right corner of the file view.
  • Replace the existing forecast comparison block with the code below:
st.write("## Forecast comparison")
st.metric("Previous-month MAE", f"${naive_mae:,.0f}")
st.metric("Same-month-last-year MAE", f"${seasonal_mae:,.0f}")
st.metric("Naive month-to-month alerts", int(df["naive_alert"].sum()))
st.metric("Calibrated shock months", int(df["shock_flag"].sum()))
st.write("Lower MAE is better; do not assume the seasonal baseline must win.")

Why keep both alert counts?

The naive count preserves evidence from the earlier experiment. The calibrated count shows how many months cross the fixed standardized threshold.

Seeing both counts makes the reduction in routine alerts visible. The comparison also prevents the calibrated rule from hiding its simpler benchmark.

  • Paste the calibrated table and disclaimer directly below the forecast comparison block:
st.write("## Calibrated negative-residual signals")
shocks = df.loc[df["shock_flag"], [
    "month",
    "import_value_usd",
    "seasonal_forecast_usd",
    "shock_score",
]].copy()
shocks["month"] = shocks["month"].dt.strftime("%Y-%m")
shocks["import_value_usd"] = (
    shocks["import_value_usd"] / 1_000_000
).round(1)
shocks["seasonal_forecast_usd"] = (
    shocks["seasonal_forecast_usd"] / 1_000_000
).round(1)
shocks["shock_score"] = shocks["shock_score"].round(2)
shocks.columns = [
    "Month",
    "Import value ($M)",
    "Seasonal forecast ($M)",
    "Shock score",
]
shocks = shocks.sort_values("Month", ascending=False)
st.dataframe(shocks, width="stretch", hide_index=True)

st.write(
    "This score is an early-warning screening rule, not a disruption "
    "probability or evidence that a specific event caused the contraction."
)

What does the table show?

  • The table keeps only rows where shock_flag is true.
  • Dollar values are converted to millions for faster comparison.
  • The newest flagged month appears first.
  • The disclaimer defines the signal as a screening rule.

✔️ Awesome, I've got everything!

Great. Double check that every change to app.py is saved in the editor before committing the final monitor.

ⓧ I'd like to double check the full code

Compare your complete app.py with this final fixed-threshold version.

import pandas as pd
import streamlit as st

SHOCK_THRESHOLD = -2.0

st.set_page_config(page_title="Lithium Import Shock Monitor", layout="wide")

df = pd.read_csv("lithium_imports.csv", parse_dates=["month"])
df = df.sort_values("month").reset_index(drop=True)

df["naive_forecast_usd"] = df["import_value_usd"].shift(1)
df["seasonal_forecast_usd"] = df["import_value_usd"].shift(12)
df["naive_alert"] = df["import_value_usd"] < df["naive_forecast_usd"]

df["naive_error_usd"] = (
    df["import_value_usd"] - df["naive_forecast_usd"]
).abs()
df["seasonal_error_usd"] = (
    df["import_value_usd"] - df["seasonal_forecast_usd"]
).abs()
df["residual_usd"] = (
    df["import_value_usd"] - df["seasonal_forecast_usd"]
)

prior_residuals = df["residual_usd"].shift(1)
df["residual_mean_24"] = prior_residuals.rolling(
    window=24, min_periods=12
).mean()
df["residual_std_24"] = prior_residuals.rolling(
    window=24, min_periods=12
).std()
df.loc[df["residual_std_24"] == 0, "residual_std_24"] = float("nan")
df["shock_score"] = (
    df["residual_usd"] - df["residual_mean_24"]
) / df["residual_std_24"]
df["shock_flag"] = df["shock_score"] <= SHOCK_THRESHOLD

latest = df.dropna(subset=["shock_score"]).iloc[-1]
naive_mae = df["naive_error_usd"].mean()
seasonal_mae = df["seasonal_error_usd"].mean()

st.write("# Lithium Import Shock Monitor")
st.write(
    "Official monthly U.S. general import value for lithium carbonates "
    "using Census HS code 283691."
)
st.metric(
    "Latest import value",
    f"${latest['import_value_usd']:,.0f}",
    f"${latest['residual_usd']:,.0f} vs seasonal forecast",
)
st.metric("Latest shock score", f"{latest['shock_score']:.2f}")
st.write(
    "Status: Shock signal"
    if bool(latest["shock_flag"])
    else "Status: No shock signal"
)

chart = df.set_index("month")[[
    "import_value_usd",
    "seasonal_forecast_usd",
]] / 1_000_000
chart.columns = ["Actual imports ($M)", "Seasonal forecast ($M)"]
st.line_chart(chart)

st.write("## Forecast comparison")
st.metric("Previous-month MAE", f"${naive_mae:,.0f}")
st.metric("Same-month-last-year MAE", f"${seasonal_mae:,.0f}")
st.metric("Naive month-to-month alerts", int(df["naive_alert"].sum()))
st.metric("Calibrated shock months", int(df["shock_flag"].sum()))
st.write("Lower MAE is better; do not assume the seasonal baseline must win.")

st.write("## Calibrated negative-residual signals")
shocks = df.loc[df["shock_flag"], [
    "month",
    "import_value_usd",
    "seasonal_forecast_usd",
    "shock_score",
]].copy()
shocks["month"] = shocks["month"].dt.strftime("%Y-%m")
shocks["import_value_usd"] = (
    shocks["import_value_usd"] / 1_000_000
).round(1)
shocks["seasonal_forecast_usd"] = (
    shocks["seasonal_forecast_usd"] / 1_000_000
).round(1)
shocks["shock_score"] = shocks["shock_score"].round(2)
shocks.columns = [
    "Month",
    "Import value ($M)",
    "Seasonal forecast ($M)",
    "Shock score",
]
shocks = shocks.sort_values("Month", ascending=False)
st.dataframe(shocks, width="stretch", hide_index=True)

st.write(
    "This score is an early-warning screening rule, not a disruption "
    "probability or evidence that a specific event caused the contraction."
)

What should match?

This reference contains the complete fixed-threshold monitor. Its rolling statistics use shift(1) before both prior-residual calculations.

  • Click Commit changes... above the file editor.
  • Enter Publish calibrated shock monitor in the commit message field.
  • Select the option that commits directly to the currently deployed branch.
  • Click Commit changes in the commit dialog.

Before you check the deployment, do you expect the calibrated list to be shorter than the naive alert count? Keep your prediction in mind.

  • Switch back to the public app from earlier.
  • Refresh the page after the latest commit finishes deploying.
  • Compare the naive alert count with the calibrated shock count.
  • Inspect the calibrated signal table.
  • Confirm the early-warning disclaimer appears below the table.

You should see actual imports beside the seasonal forecast. You should also see both MAE values.

The page should show a latest shock score and both alert counts. The calibrated table should contain only months with scores at or below -2.0.

Strong finish. Your public monitor now turns official import history into a leakage-safe screening signal with an explicit interpretation boundary.

Is the final table missing?

  • Check that shock_flag compares shock_score with SHOCK_THRESHOLD.
  • Check that the table filters rows with df["shock_flag"].
  • Confirm the public app finished updating from the latest commit.

Ask for help: Why is my calibrated shock table missing or showing the wrong rows?

Secret mission

Add an Analyst Sensitivity Control

Turn the fixed shock cutoff into a live analyst control. Watch the alert list respond as you test different sensitivity levels. Keep the score framed as a screening rule.

Clean Up Your Resources

Clean Up Your Resources

Your monitor runs entirely on free services, so there are no ongoing project charges. Choose whether to keep the public app available, let it hibernate, or remove the resources.

Resources you used:

  • Active public deployment on Streamlit Community Cloud.
  • Public GitHub repository named lithium-import-shock-monitor containing app.py, build_data.py, requirements.txt, and lithium_imports.csv.

Keep Your Census Key Private

Your U.S. Census Data API key does not live in the repository or deployment. Keep the key outside GitHub with every cleanup option.

Keep everything running

No action is needed. Choose this option if you want the public monitor to remain available for future demonstrations or threshold experiments.

  • Leave the Streamlit Community Cloud deployment active so the public monitor remains shareable.
  • Keep the GitHub repository public so the deployment can continue reading its source files.
  • Store the Census API key outside the repository.

Community Cloud may put the app to sleep after 12 hours without traffic. A future visitor can wake it by opening the public URL.

Pause - I'll come back to this later

Let the hosted app hibernate while preserving its code and public URL. This keeps the project ready for a future visit without deleting anything.

  • Stop visiting the public app for at least 12 hours.
  • Leave the GitHub repository unchanged.
  • Keep the Census API key outside the repository.

Community Cloud puts the app to sleep after 12 hours without traffic. The next visitor wakes the same deployment.

Delete - I don't want to use this again

Deleting the deployment looks final, but your source files remain in GitHub until you choose to remove the repository. Start with the deployment so the public URL stops serving the app.

  • Return to the Streamlit Community Cloud workspace from earlier.
  • Locate the deployment connected to lithium-import-shock-monitor.
  • Open the app's overflow menu.
  • Select Delete app.
  • Confirm the deletion when prompted.

The public URL now stops serving your monitor. Your committed code and non-secret CSV snapshot remain available in GitHub.

Repository deletion permanently removes the public source and its committed history. Continue only if you no longer need those files.

  • Switch back to the lithium-import-shock-monitor repository in GitHub.
  • Use the repository settings page to reach its deletion controls.
  • Start the permanent repository deletion from the deletion area.
  • Enter lithium-import-shock-monitor when GitHub requests the repository name.
  • Confirm the permanent deletion.

The repository URL is no longer accessible. This confirms that the public repository has been removed.

What Gets Removed?

Deleting the Community Cloud app removes the hosted deployment. It leaves the GitHub repository intact.

Deleting the repository removes app.py, build_data.py, requirements.txt, and the committed lithium_imports.csv snapshot. Your Census API key remains separate because it was never committed.

Can't Delete a Resource?

Confirm that you are signed in to the Community Cloud account connected to the GitHub account that owns the app. The deployment controls are available from the workspace instead of the public app URL.

If the repository deletion controls are unavailable, confirm that you are viewing the repository while signed in to its owner account.

Help me clean up the deployment or repository.

Nice Work!

Nice Work!

You did it! Your public Lithium Import Shock Monitor now turns official monthly trade data from the U.S. Census Data API into an explainable early-warning screen on Streamlit Community Cloud.

You've learned how to:

  • Create a reproducible official-data extract for monthly U.S. lithium carbonate imports. Keep the API key out of GitHub. Publish only the non-secret CSV snapshot.
  • Deploy a public raw-data monitor before adding forecasting logic. Show analysts the latest import value. Plot the full import history at a stable cloud URL.
  • Compare previous-month and seasonal baselines using walk-forward MAE. Build a leakage-safe shock score from prior residuals. Present the result as a screening signal.
  • Secret Mission: Add an interactive sensitivity threshold for analyst scenarios. See the calibrated alert count update immediately. Watch the shock table respond to each threshold.

Ready to quiz yourself?