Skip to content

DS 101 · Module 2

Trustworthy ESG Analysis with AI Assistance

Your code produced a result. How do you know the result deserves trust?

Start Section 1
0 / 5 completed
Sync my progressJoin a session to keep progress across devices and share it with your instructor

Info

After you join, your completed lesson numbers are sent to your instructor together with your learner code, so they can see your progress. Use the codes your instructor gave you, and do not enter your name, company secrets or other personal data. Progress is kept with the rest of that session's data.

Learning objectives

  • Identify data-quality issues involving missing values, duplicates, data types, units, and outliers, and justify every cleaning decision in a decision record
  • Use derived columns, groupby(), and aggregation to calculate Scope 1+2 emissions, emissions intensity, and year-over-year change
  • Choose charts that suit comparison and trend questions, and separate evidence, interpretation, and unknowns with Fact / Analysis / Unknown
  • Use AI to help write and debug pandas code, and check its code, formulas, units, outputs, and claims with a five-step verification
  • Complete a reproducible, verifiable, and explainable ESG analysis on your own in the Independent Lab

Data quality

40 min

ESG Data Quality and Cleaning

Info

Learning objective: Identify and handle common data-quality issues without "fixing" uncertain data just so it looks normal.

This lesson uses DS101_ESG_Company_Data_Practice.csv, an illustrative teaching dataset that deliberately keeps some of the data-quality problems you meet in real work. Resist the urge to calculate straight away. Review the file the way you would review a report someone handed you: Are there blanks? Are there repeated rows? Are the numeric columns really numbers? Are the units consistent? Are there values outside a sensible range, or values that stand out? Is each company name written the same way every time?

Three basic inspection commands
import pandas as pd

df = pd.read_csv("DS101_ESG_Company_Data_Practice.csv")

# How many missing values each column has
df.isna().sum()

# How many rows are exact copies of an earlier row
df.duplicated().sum()

# Which data type pandas inferred for each column
df.dtypes
  • A missing value is not zero. A blank means "we do not know"; a 0 means "we know it was none". They say very different things.
  • A repeated row is not always a true duplicate. Check the key (company_id and year) first, then decide whether it is a double entry or two different records.
  • A numeric column can be read as text. A thousands separator such as a comma is enough to turn the whole column into object, and then it cannot be used in calculations.
  • Inconsistent units break comparison. If one column mixes units, the sizes of its numbers no longer mean the same thing.
  • An outlier is a signal to investigate, not a value to delete automatically. It may be an error, or it may be a real change.
  • A plausible value can still be wrong. Falling inside the normal range does not mean it has been checked against the source.

Warning

Required example: if scope2_tco2e is blank, do not fill it with 0 automatically unless the source documentation establishes that zero is the correct value. Treating a blank as 0 understates emissions, and every Scope 1+2 total and intensity value built on it will be wrong too.
Further checks (inspect only, original columns unchanged)
# Does any company_id and year combination appear more than once?
print(df[df.duplicated(subset=["company_id", "year"], keep=False)])

# Are company names written consistently (for example, extra spaces)?
print(df["company_name"].unique())

# Which values cannot be read as numbers? (stored in a new variable, original column untouched)
revenue_num = pd.to_numeric(df["revenue_m_twd"], errors="coerce")
print(df.loc[revenue_num.isna() & df["revenue_m_twd"].notna(), ["company_id", "year", "revenue_m_twd"]])

# Are percentage values outside 0 to 100?
print(df[(df["renewable_energy_pct"] < 0) | (df["renewable_energy_pct"] > 100)])

# Summary statistics help spot values that stand out; then look at the same company's other years
df.describe()
Data-quality decision record: Issue / Evidence / Impact / Treatment decision / Justification / What must remain unknown (illustrative example scenarios, not taken from the practice file)
IssueEvidenceImpact if left untreatedTreatment decisionJustificationWhat must remain unknown
One year of waste_tonnes for a company is about 1000 times its other yearsdescribe() shows a maximum that stands out; the source note gives the unit as kg, while the data dictionary specifies tonnesLeft untreated, that year's waste figure is overstated by about 1000x, distorting any total or comparison that includes itAfter confirming with the data provider, convert to tonnes in a new column and keep the original columnOne column must use one unit, or years cannot be compared; the conversion was checked against the source, not guessedWhether other years also mistakenly used kg has not yet been checked one by one, and stays unknown
employees is blank for one company in one yearisna().sum() shows 1 missing value in that columnImputing 0 or an average would push that year's per-employee metric artificially down or upLeave it missing, do not impute; exclude that year from per-employee metrics and state this as a limitationNo source confirms the headcount, so imputing it would mean inventing dataThe true headcount for that year stays unknown; the per-employee trend has a gap that is not estimated
A company name is written slightly differently across rows (for example, a trailing space)df["company_name"].unique() lists values that look identical but compare as unequal stringsLeft untreated, groupby("company_name") splits the same company into two groups, understating any aggregateStrip leading and trailing spaces and standardize the spelling into a new column, then group on that column; keep the original columnGrouping and aggregation depend on exact string matches; confirm the rows really are the same company before standardizing, rather than rewriting blindlyWhether other spelling differences remain undiscovered still needs attention in later checks

A good cleaning habit: keep the original columns and put cleaned results in new columns or a new DataFrame; write every change into the decision record; and where something cannot be confirmed, leave it missing and say so in the limitations of your conclusion.

  • Start with the overall shape: df.shape and df.dtypes, to confirm size and types are as expected
  • Then check missing values: df.isna().sum(), to see which columns have blanks
  • Then check duplicates: df.duplicated(subset=["company_id","year"]).sum(), to confirm the key is not repeated
  • Finally check range and outliers: df.describe() and column-level range checks, to find numbers that need verification
  • Following this order helps avoid missing an earlier problem just because a later one caught your eye first
Check yourself

You notice that scope2_tco2e is blank in one row. What should your first step be?

You notice that scope2_tco2e is blank in one row. What should your first step be?

Key takeaway: Data cleaning is not about making data look tidy; every change must be explainable and traceable.

Metrics and visualization

40 min

ESG Metrics and Grouped Analysis

Info

Learning objective: Use derived columns, groupby(), and aggregation to answer company-level and year-level questions.

A derived column is a new column calculated from existing ones. This lesson uses three simplified teaching formulas: Scope 1+2 emissions (an absolute amount), emissions intensity (tCO2e per NT$ million of revenue), and year-over-year percentage change. Absolute emissions answer "how much was emitted"; intensity answers "how much was emitted relative to the size of revenue". They are answers to different questions. The examples below use the cleaned file, DS101_ESG_Company_Data_Clean.csv.

Create derived columns
import pandas as pd

df = pd.read_csv("DS101_ESG_Company_Data_Clean.csv")

# Scope 1+2 emissions (tCO2e)
df["scope12_tco2e"] = df["scope1_tco2e"] + df["scope2_tco2e"]
# Emissions intensity (tCO2e per NT$ million revenue)
df["scope12_intensity"] = df["scope12_tco2e"] / df["revenue_m_twd"]
Grouped summary by company (named aggregation)
summary = (
    df.groupby("company_name", as_index=False)
      .agg(
          avg_scope12=("scope12_tco2e", "mean"),
          avg_intensity=("scope12_intensity", "mean")
      )
)

Notice that this summary keeps one row per company: the year dimension has been averaged away. It can answer "what was the average level from 2020 to 2025?" but not "how did it change year by year?". For year-by-year change you must keep company-year granularity, sort by company and year, and only then calculate the change.

Year-over-year percentage change
df = df.sort_values(["company_name", "year"])
df["scope12_yoy_pct"] = (
    df.groupby("company_name")["scope12_tco2e"]
      .pct_change() * 100
)
  • Is the denominator non-zero and in the expected unit? (revenue_m_twd is NT$ million.)
  • Are missing values being silently excluded? (mean() skips NaN by default, so an average may come from only some of the years.)
  • Does the grouping preserve the granularity you intended: by company, by year, or by company and year?
  • Is an average across years actually meaningful for this question?
  • Is the percentage change unstable, or misleadingly large, because the prior value is very small?

Warning

Intensity is not automatically comparable across companies either: sector, reporting boundary, method, and data completeness all shape the number. The formulas in this course are simplified teaching examples. They are not a basis for rating or ranking companies, and they do not replace official ESG accounting and reporting guidance.
Worked example: calculating year-over-year percent change (illustrative values, not from the course file)

Suppose a company's scope12_tco2e was 4000 in 2023 and 4600 in 2024. Year-over-year percent change = (4600 - 4000) / 4000 x 100 ≈ 15%, which is what pct_change() returns once multiplied by 100. df.groupby("company_name")["scope12_tco2e"].pct_change() computes each row's change against the previous row for that same company, not against the previous row of the whole table.

Info

An easy detail to miss: the first year in each company's series has no prior year to compare with, so pct_change() returns NaN there. That is expected, not a bug. When you average the year-over-year change, know whether those NaN rows are excluded or treated as missing.
Check yourself

The question is "How did each company's Scope 1+2 emissions change year by year?" Which grouping keeps the granularity needed to answer it?

The question is "How did each company's Scope 1+2 emissions change year by year?" Which grouping keeps the granularity needed to answer it?

Key takeaway: A correct formula is only the first step; also check the denominator, grouping, missing values, and granularity.

35 min

Visualization: From Numbers to Evidence

Info

Learning objective: Choose simple, readable charts that show comparisons and trends without misleading.
Choosing a chart for the question
Question you want to answerSuggested chartWatch out for
How does a metric change over time?Line chartPut year on the x-axis; one line per company, with markers
How do categories compare?Bar chartThe value axis should usually start at 0, or differences are exaggerated
Is there a relationship between two numeric variables?Scatter plotIt explores a relationship; it does not prove causation. With few points, patterns can be chance
Comparing many companies or many metrics at once?Small multiples: one small chart per company or per metric, all with the same structureUse the same axis range and chart size across them for a fair comparison; with too many, show only the ones most relevant to the question
  • Label both axes and state units, for example "tCO2e per NT$ million revenue".
  • Bar charts should usually start at 0; a truncated axis makes small differences look large.
  • On line charts too, check whether the axis range exaggerates or flattens the change.
  • Too many categories or colors make a chart hard to read; split it into several charts or show only the categories that matter for the question.
  • The title should state what is shown, not claim what it means.
Intensity trend by company (continues the Lesson 7 df, which already has scope12_intensity)
import matplotlib.pyplot as plt

for company, g in df.groupby("company_name"):
    plt.plot(g["year"], g["scope12_intensity"], marker="o", label=company)

plt.xlabel("Year")
plt.ylabel("tCO2e per NT$ million revenue")
plt.title("Scope 1+2 emissions intensity by company")
plt.legend()
plt.show()

Info

Accessibility tip: do not rely on color alone to tell companies apart. Give each line a different marker shape (for example marker="o", "s", "^"), and use a legend or label each line directly at its end, so the chart still reads for people with color vision differences and in black-and-white print.
  1. Fact
    What the chart directly shows. For example: one company's intensity broadly declined between 2020 and 2025.
  2. Analysis
    A reasonable interpretation, with its basis stated. For example: the decline may partly reflect faster revenue growth, since revenue is the denominator of intensity.
  3. Unknown
    What this data cannot establish. For example: whether the decline came from reduction measures, or whether the reporting boundary changed.
Writing the title

Overclaiming: "The company that succeeded at cutting carbon". Describing what is shown: "Scope 1+2 emissions intensity by company, 2020 to 2025 (illustrative teaching data)". The first presents an interpretation as a fact; the second says only what the chart shows.

A title that can mislead

  • "Evergreen Components Co. wins on carbon cuts" dresses up an interpretation as a fact
  • States no time range or data source
  • Implies a cross-company ranking, even though the boundaries may not be comparable

A title that only describes what is shown

  • "Evergreen Components Co. Scope 1+2 emissions intensity, 2020 to 2025 (synthetic teaching data)"
  • States the time range and the nature of the data clearly
  • Leaves the reader to draw their own conclusion, instead of drawing it for them
Check yourself

You draw a scatter plot with renewable_energy_pct on the x-axis and scope12_intensity on the y-axis. The points broadly slope down to the right. Which statement is most appropriate?

You draw a scatter plot with renewable_energy_pct on the x-axis and scope12_intensity on the y-axis. The points broadly slope down to the right. Which statement is most appropriate?

Key takeaway: A chart expresses evidence; it is not a tool for turning speculation into fact.

AI assistance and verification

30 min

AI-Assisted Analysis and Verification

Info

Learning objective: Use AI to assist Python analysis while building a verification process that is never skipped.
AI-assisted analysis workflow
  1. Human: asks the analysis question
  2. AI: proposes code
  3. Python: executes the code
  4. Human: verifies the data, method, and output
  5. Human: communicates the conclusion

Text description: the workflow starts with a person asking the analysis question. The AI proposes code for it. Python runs the code and produces a result. A person then verifies that the data, method, and output are right, and finally a person writes and communicates the conclusion. The AI owns only the middle step, proposing code; defining the question, verifying, and concluding all stay with people.

Recommended AI request template
Task:
Use pandas to calculate Scope 1+2 emissions intensity by company and year.

Schema:
company_name: text
year: integer
revenue_m_twd: NT$ million
scope1_tco2e: tCO2e
scope2_tco2e: tCO2e

Rules:
- Do not invent columns.
- Do not replace missing emissions with zero.
- Show the formula explicitly.
- Preserve company-year granularity.
- Explain each transformation.
- Include verification checks for units, missing values, and row counts.
  1. Schema check
    Every column the code references exists in the data.
  2. Formula check
    The calculation matches the metric you intended.
  3. Unit check
    Units are compatible with each other and clearly stated.
  4. Output check
    Spot-check at least one row by hand.
  5. Claim check
    The narrative does not go beyond what the evidence supports.
Example of AI-returned code (flawed on purpose, for spotting errors; do not use)
# AI-generated code: flawed
df = df.fillna(0)
df["intensity"] = df["total_emissions"] / df["revenue_m_twd"]
print("The company with the lowest intensity is the best company.")

Warning

Core idea: "the code ran and printed a number" does not mean the answer is right. AI-generated analysis code is often syntactically correct, executes cleanly, and even prints a number that looks reasonable, while the logic behind it is wrong. For each of the ten risks below, you should be able to spot the problem yourself and know how to verify it.
Why you cannot just trust AI-generated analysis code: ten common risks and how to verify them
RiskWhat the buggy code or output looks likeHow the learner catches it / verifies it
Code runs but the answer is wrongThe example above raises no error and prints a confident conclusion; but total_emissions is a fabricated column, so intensity is actually a result of NaN / 0.Do not trust a result just because nothing turned red. Run a Schema check first: print df.columns and confirm every column the code references actually exists.
Wrong denominatorThe AI divides emissions summed across 2021-2025 by a single year's (2025) revenue, inflating the reported emissions intensity.Write down what time span the numerator and denominator each represent; they must match, then spot-check the formula by hand on one row.
Wrong groupbydf.groupby("company_name")["scope12_tco2e"].sum(): grouping only by company collapses the year dimension, then the result is used to answer "which year had the highest emissions?"Compare the grouping to the granularity the question actually needs (company? year? company x year?), and confirm groupby() keeps that dimension.
Unit mismatchThe same block of code adds or divides a revenue column in NT$ and a revenue column in NT$ million without converting either.Check the data dictionary for every numeric column's unit; convert into a new column and label the unit in its name or a comment.
Missing values silently droppeddf["scope1_tco2e"].mean() ignores NaN by default; if a company has two years missing, the mean is really computed from only the other three years, with no warning.Before averaging or summing, check df["col"].isna().sum() first; state in the conclusion exactly which years the average is based on.
Duplicate rowsAfter merging two tables, one company-year appears as two identical rows, so summing doubles its reported emissions.Before merging or aggregating, check df.duplicated(subset=["company_id","year"]).sum(); confirm the row count matches expectations after the merge.
Wrong datatype / date parsingOne date is stored as "2021/13/05"; pd.to_datetime() silently turns it into NaT (not a date) by default, and a later year filter drops that row with no error.After converting types or dates, check isna().sum() for any new missing values; use errors="raise" first to surface values that fail to parse.
Correlation described as causationThe written conclusion says "caused by" or "because", when the evidence is only two variables observed changing together.Check the wording for "caused", "led to", "because"; if only a co-occurrence was observed, rewrite it as "an observed relationship, not established causation".
Stale or wrong sourceThe AI cites a plausible-looking emissions factor or baseline-year number in its answer with no source, or the source turns out to be an outdated, superseded version.Any external factor or standard value must be traceable to "what is this number's source, and which year's version is it"; if no source can be found, do not use it.
Chart that visually overstates differenceA bar chart's y-axis starts at 90 instead of 0, making a 3% real difference in emissions intensity between two companies look several times larger.Check whether the axis starts at 0 (it normally should for a bar chart); use the same axis range across charts being compared.
Duplicate rows and datatype example (extension: find what is wrong below)
# After merging two data sources
merged = pd.merge(emissions_2024, emissions_2024_restated, on="company_id", how="outer")
merged["scope1_tco2e"] = merged["scope1_tco2e"].sum()  # Bug: the same company now appears twice

# year was read as text (e.g. one raw cell was entered as "2021 (final)")
merged["year"] = pd.to_numeric(merged["year"], errors="coerce")  # Bug: unparsable values silently become missing
print(merged["year"].isna().sum())  # Verification: how many became missing after conversion, instead of assuming all succeeded
  • Inventing a column, such as referencing total_emissions when the data has no such column.
  • Filling NaN with 0 without any justification.
  • Mixing revenue units, for example NT$ and NT$ million in the same calculation.
  • Using the wrong denominator for an aggregate ratio, for example dividing emissions summed over several years by one year's revenue.
  • Writing "caused by" when the data only shows a correlation.
  • Calling a company "the best" with no defined criteria and no comparable boundaries.
Worked example: the five-step check applied to one question

Question: "Which sample company had the lowest emissions intensity in 2025?" Schema check: confirm the code only uses scope1_tco2e, scope2_tco2e, and revenue_m_twd, with no reference to a column that does not exist. Formula check: confirm intensity = (scope1_tco2e + scope2_tco2e) / revenue_m_twd, not just one Scope on its own. Unit check: confirm revenue_m_twd really is in NT$ million, and not mixed with a plain NT$ figure from another year. Output check: recompute the lowest company's result by hand with a calculator. Claim check: write the conclusion as "in this synthetic dataset, this company had the lowest emissions intensity in 2025", not as "this company has the best environmental performance".

Warning

Privacy and data safety: do not upload confidential institutional or company data to an AI tool without authorization, and do not put personal or sensitive data into public or shared AI prompts. Practice with the illustrative course files, and check your organization's policy before using real ESG or internal operational data.
Check yourself

The AI's code contains df["total_emissions"] / df["revenue_m_twd"], and running it raises a KeyError. What is the most appropriate next step?

The AI's code contains df["total_emissions"] / df["revenue_m_twd"], and running it raises a KeyError. What is the most appropriate next step?

Key takeaway: AI can accelerate coding, but it cannot take responsibility for definitions, formulas, or conclusions.

Independent Lab

35 min

Independent Lab: Integrating Trustworthy Analysis

Info

Learning objective: Complete an ESG analysis on your own, from data-quality checks to communicating the conclusion, with every step reproducible, verifiable, and explainable.
Your task

Using DS101_ESG_Company_Data_Practice.csv, work independently: identify one ESG trend or anomaly worth reporting. Complete the necessary data-quality checks, cleaning, metric calculation, visualization, and verification, while clearly separating evidence, interpretation, and unknowns.

  1. 1. Question
    State the question you are answering in one sentence.
  2. 2. Data source
    Name the file(s) you used and where they come from.
  3. 3. Grain
    State what one row represents.
  4. 4. Units
    List the unit of every column you actually used.
  5. 5. Cleaning decisions
    List the data-quality issues you found, with a decision record for each one, its reason, and what must remain unknown.
  6. 6. Analysis method
    Describe the method and steps you used.
  7. 7. Derived metrics
    Write each formula with its units, for example the numerator and denominator of scope12_intensity.
  8. 8. Visualization
    At least one chart with axis labels, units, and a descriptive title.
  9. 9. Verification checks
    Pick one row and check the code's result by hand or with a calculator, and record what checks you ran.
  10. 10. AI assistance record
    If you used AI, say what it helped with and what you verified yourself.
  11. 11. Human review
    State what review you (or a peer/teacher) did on this analysis.
  12. 12. Limitations
    Three evidence statements (Fact), one interpretation statement (Analysis), and at least two limitations or unknowns (Unknown).
  13. 13. Reproduction instructions
    State what someone else would need to reproduce your analysis from these steps.
Completion rubric
ResultCriteria
CompleteAll 13 required elements are present and the claims are consistent with the evidence.
Needs revisionThe code runs, but units, formulas, or data-quality decisions are undocumented, or the claims overreach the data.
  • Reporting a metric's number without writing out its formula, numerator and denominator, and unit
  • Producing a chart with no axis labels, or a title that is just the file name
  • Deleting an outlier as soon as it is found, with no verification and no entry in the decision record
  • Writing "because... therefore" in the conclusion when the evidence only shows things changing together
  • Leaving out limitations or unknowns, so the conclusion sounds more certain than the data can support

Warning

The practice data are illustrative teaching data and do not represent any real company. Do not put confidential or personal data into AI tools. AI can help you write code, but the analysis and the conclusion are yours to own.

Key takeaway: Reproducible, verifiable, and explainable: that is an analysis you can take into real work.

What next?