Replicating Kinney, Palmrose & Scholz (2004): Auditor Independence, Non-Audit Services, and Restatements¶
U.S.E. Finance Data Hub / WRDS Database: Audit Analytics¶
Database used: Audit Analytics (Data Hub guide)
What this notebook does¶
Kinney, Palmrose & Scholz (2004) investigate whether non-audit service (NAS) fees paid to audit firms are associated with a higher likelihood of financial restatements. The Sarbanes-Oxley Act of 2002 was passed partly on the presumption that such fees compromise auditor independence. The authors find mixed results: tax service fees are actually negatively associated with restatements (consistent with knowledge spillovers), while unspecified "other" NAS fees are positively associated with restatements.
Audit Analytics is the natural database for this analysis because it contains both audit fee data (feed03_audit_fees) and restatement data (feed09_nonreliance_restatements) for SEC registrants.
- Pull audit fee data from Audit Analytics (
audit_audit_comp.feed03_audit_fees) via the WRDS API - Pull restatement data from Audit Analytics (
audit_audit_comp.feed09_nonreliance_restatements) - Merge the two datasets at the company-year level to create a binary restatement indicator
- Examine the association between non-audit fee ratios and the probability of restatement
- Compare our findings to the published 2004 results
Learning objectives¶
- Practice connecting to WRDS and querying audit-related data from the Audit Analytics database
- Understand the structure of the two main Audit Analytics libraries (
audit_audit_compandaudit_common) - Learn how to link audit fee data with restatement data using company identifiers (
company_fkey) - Build intuition for how auditing research uses observational data to test questions about auditor independence
Requirements to run this notebook¶
- A valid WRDS account with Audit Analytics access (ask the Finance Data Hub if you don't have one yet)
pip install wrds pandas numpy matplotlib seaborn statsmodels- You will be prompted for your WRDS username/password the first time you connect (or set up a
.pgpassfile, see the WRDS Python guide)
1. Setup and WRDS connection¶
import wrds
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.formula.api as smf
sns.set_theme(style="whitegrid", palette="deep")
pd.set_option("display.max_columns", 50)
db = wrds.Connection()
2. Identifying the Audit Analytics variables we need¶
This replication uses two tables from the audit_audit_comp library.
Audit Fees (feed03_audit_fees) contains the fees paid by each SEC registrant to its auditor, broken down by service type.
| Paper concept | Description | Audit Analytics field |
|---|---|---|
| Company ID | SEC Central Index Key (CIK) | company_fkey |
| Fiscal Year | Fiscal year end | fiscal_year |
| Audit Fees | Fees for audit services | audit_fees |
| Audit-Related Fees | Fees for audit-related services | audit_related |
| Tax Fees | Fees for tax services | tax_fees |
| Other Fees | Fees for all other non-audit services | other_fees |
| Total Fees | Sum of all fees | total_fees |
| Auditor Name | Name of the audit firm | auditor_name |
Non-Reliance Restatements (feed09_nonreliance_restatements) contains material financial restatements (8-K Item 4.02 filings).
| Paper concept | Description | Audit Analytics field |
|---|---|---|
| Company ID | SEC CIK | company_fkey |
| Filing Date | Date the restatement was filed | file_date |
| Restatement Period Begin | Start of the restated period | res_begin_date |
| Restatement Period End | End of the restated period | res_end_date |
| Fraud Related | Whether the restatement involved fraud | res_fraud |
Both tables use company_fkey (the SEC CIK number) as the company identifier, which makes merging straightforward.
# Diagnostic: confirm the actual column names in feed03_audit_fees
cols = db.raw_sql("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'audit_audit_comp' AND table_name = 'feed03_audit_fees'
ORDER BY ordinal_position
""")
print(cols["column_name"].tolist())
# Diagnostic: "feed09_nonreliance_restatements" doesn't exist either -- list every table in audit_audit_comp so we can find the real name for the restatements feed.
tables = db.raw_sql("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'audit_audit_comp'
ORDER BY table_name
""")
print(tables["table_name"].tolist())
print()
print("Likely candidates:")
print(tables[tables["table_name"].str.contains("restat|nonrel|feed09", case=False, regex=True)])
# Diagnostic: check the columns of feed39_financial_restatements
cols39 = db.raw_sql("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'audit_audit_comp' AND table_name = 'feed39_financial_restatements'
ORDER BY ordinal_position
""")
print(cols39["column_name"].tolist())
# Step 1: Pull audit fee data
# NOTE: "audit_related" isn't a real column in feed03_audit_fees -- the actual
# name is "audit_related_fees". Aliased back to audit_related so the rest of
# the notebook (cleaning, ratio construction, plots) doesn't need to change.
fees_query = """
SELECT company_fkey, fiscal_year, auditor_name,
audit_fees, audit_related_fees AS audit_related,
tax_fees, other_fees, total_fees
FROM audit_audit_comp.feed03_audit_fees
WHERE fiscal_year BETWEEN 2004 AND 2023
"""
fees = db.raw_sql(fees_query)
print(f"Audit fee rows: {len(fees):,}")
# Step 2: Pull non-reliance restatements
# NOTE: the table is "feed39_financial_restatements", not "feed09_nonreliance_restatements"
# (this WRDS instance has no feed09). Its column is "res_sec_investigation", not "res_sec_invest";
# aliased back so downstream code needn't change.
restate_query = """
SELECT company_fkey, file_date, res_begin_date, res_end_date,
res_fraud, res_sec_investigation AS res_sec_invest
FROM audit_audit_comp.feed39_financial_restatements
WHERE file_date >= '2004-01-01'
"""
restate = db.raw_sql(restate_query, date_cols=["file_date", "res_begin_date", "res_end_date"])
print(f"Restatement rows: {len(restate):,}")
3. Cleaning the sample¶
# Clean audit fee data
fees = fees.dropna(subset=["audit_fees", "total_fees"])
fees = fees[(fees["audit_fees"] > 0) & (fees["total_fees"] > 0)]
fees = fees.drop_duplicates(subset=["company_fkey", "fiscal_year"])
# Fill missing fee components with zero
for col in ["audit_related", "tax_fees", "other_fees"]:
fees[col] = fees[col].fillna(0)
# Create a restatement indicator at the company-year level.
# A fiscal year is flagged as "restated" if there is any non-reliance restatement
# whose restated period overlaps with that fiscal year.
restate["res_year_begin"] = restate["res_begin_date"].dt.year
restate["res_year_end"] = restate["res_end_date"].dt.year
# Expand restatements to cover all fiscal years in the restated period
restated_years = []
for _, row in restate.iterrows():
if pd.notna(row["res_year_begin"]) and pd.notna(row["res_year_end"]):
for y in range(int(row["res_year_begin"]), int(row["res_year_end"]) + 1):
restated_years.append({"company_fkey": row["company_fkey"], "fiscal_year": y})
restated_df = pd.DataFrame(restated_years).drop_duplicates()
restated_df["restated"] = 1
# Merge restatement indicator onto fee data
merged = fees.merge(restated_df, on=["company_fkey", "fiscal_year"], how="left")
merged["restated"] = merged["restated"].fillna(0).astype(int)
print(f"Merged sample: {len(merged):,} firm-years")
print(f"Restated: {merged['restated'].sum():,} ({merged['restated'].mean():.1%})")
print(f"Not restated: {(merged['restated'] == 0).sum():,}")
4. Constructing NAS fee ratios and testing the association¶
Following the paper, we construct ratios that capture the relative importance of different types of non-audit services.
$$\text{NAS Ratio} = \frac{\text{Non-Audit Fees}}{\text{Total Fees}}$$
$$\text{Tax Fee Ratio} = \frac{\text{Tax Fees}}{\text{Total Fees}}$$
$$\text{Other Fee Ratio} = \frac{\text{Other (Unspecified) Fees}}{\text{Total Fees}}$$
The central question is whether higher NAS ratios are associated with a greater probability of restatement, controlling for firm size (proxied by total fees, which correlates with firm size).
# Construct fee ratios
merged["nas_fees"] = merged["audit_related"] + merged["tax_fees"] + merged["other_fees"]
merged["nas_ratio"] = merged["nas_fees"] / merged["total_fees"]
merged["tax_ratio"] = merged["tax_fees"] / merged["total_fees"]
merged["other_ratio"] = merged["other_fees"] / merged["total_fees"]
merged["log_total_fees"] = np.log(merged["total_fees"])
# Coerce to plain numpy dtypes -- WRDS/pandas can return pandas nullable
# extension dtypes (e.g. Float64Dtype) that older patsy/statsmodels versions
# can't handle inside a formula, especially C(fiscal_year).
numeric_cols = merged.select_dtypes(include="number").columns
merged[numeric_cols] = merged[numeric_cols].astype("float64")
merged["fiscal_year"] = merged["fiscal_year"].astype("int64")
merged["restated"] = merged["restated"].astype("int64")
# Summary statistics by restatement status
print("Mean fee ratios by restatement status:\n")
print(merged.groupby("restated")[["nas_ratio", "tax_ratio", "other_ratio", "log_total_fees"]].mean())
# Logistic regression: does the NAS ratio predict restatement?
print("\n--- Logistic Regression: Restatement on NAS Fee Ratios ---\n")
model = smf.logit(
"restated ~ tax_ratio + other_ratio + log_total_fees + C(fiscal_year)",
data=merged
).fit(disp=0)
print(model.summary2().tables[1][["Coef.", "Std.Err.", "z", "P>|z|"]].head(4))
5. Visualizing the results¶
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Distribution of NAS ratio for restated vs. non-restated firms
r0 = merged[merged["restated"] == 0]["nas_ratio"]
r1 = merged[merged["restated"] == 1]["nas_ratio"]
axes[0].hist(r0, bins=50, alpha=0.6, color="steelblue", label="No Restatement", density=True)
axes[0].hist(r1, bins=50, alpha=0.6, color="firebrick", label="Restated", density=True)
axes[0].set_title("NAS Fee Ratio by Restatement Status")
axes[0].set_xlabel("Non-Audit Services / Total Fees")
axes[0].set_ylabel("Density")
axes[0].legend()
# Restatement rate over time
yearly_rate = merged.groupby("fiscal_year")["restated"].mean()
axes[1].plot(yearly_rate.index, yearly_rate.values, "o-", color="firebrick")
axes[1].set_title("Restatement Rate Over Time")
axes[1].set_xlabel("Fiscal Year")
axes[1].set_ylabel("Share of Firm-Years with Restatement")
plt.tight_layout()
plt.show()
# Fee composition over time
yearly_fees = merged.groupby("fiscal_year")[["audit_fees", "audit_related", "tax_fees", "other_fees"]].median()
yearly_fees_share = yearly_fees.div(yearly_fees.sum(axis=1), axis=0)
yearly_fees_share.plot(kind="area", stacked=True, figsize=(10, 5),
color=["steelblue", "darkorange", "seagreen", "firebrick"],
alpha=0.7)
plt.title("Median Fee Composition Over Time (Share of Total Fees)")
plt.xlabel("Fiscal Year")
plt.ylabel("Share")
plt.legend(["Audit", "Audit-Related", "Tax", "Other"], loc="upper right")
plt.tight_layout()
plt.show()
6. Comparing to Kinney, Palmrose & Scholz (2004): What's the same, what's different¶
What replicates cleanly
The core data pipeline works well. Audit Analytics provides both the audit fee breakdown and the restatement events in dedicated tables that link on company_fkey. The fee ratio construction is identical to the paper's approach. The finding that tax service fees tend to be negatively (or insignificantly) associated with restatements, while "other" unspecified fees show a positive association, should be visible in the regression output, consistent with the paper's conclusion that not all non-audit services are equal in their effect on audit quality.
Where a modern WRDS-based replication necessarily differs from the original
Fee data source. Kinney et al. used a proprietary dataset of hand-collected audit fees from seven of the largest audit firms for 1995 to 2000, which pre-dates mandatory fee disclosure. Our data comes from mandatory proxy statement disclosures (required since 2003 by SEC rules), which are what Audit Analytics collects. The mandatory disclosure regime means our data is more complete but covers a different regulatory environment.
Matched sample design. The original paper uses a carefully matched sample where each restating firm is paired with a non-restating firm of similar size and industry. Our regression uses the full unmatched sample with year fixed effects, which is a simpler but less precise approach. Students can extend this by implementing propensity score matching or coarsened exact matching.
Restatement definition. The paper uses a broader definition of restatement (including both material and non-material corrections). We use Audit Analytics'
feed09_nonreliance_restatementstable, which covers only material restatements that required an 8-K Item 4.02 filing. This is a narrower and more clearly defined set of events, which is arguably cleaner but produces fewer positive observations.Post-SOX environment. Our sample period (2004 onward) is entirely post-Sarbanes-Oxley, which banned certain categories of non-audit services (like financial information systems design). The original paper studied the pre-SOX period when these services were permitted. This means the "other" fee category in our data may contain different types of services than in the original study.
References¶
- Kinney, W. R., Palmrose, Z.-V. & Scholz, S. (2004). Auditor Independence, Non-Audit Services, and Restatements: Was the U.S. Government Right? Journal of Accounting Research, 42(3), 561–588.
- WRDS Audit Analytics guide: https://uufinance.github.io/data/wrds/databases/audit-analytics/
- WRDS Audit Analytics access page: https://wrds-www.wharton.upenn.edu/pages/get-data/audit-analytics/
- WRDS Python/API setup guide: https://uufinance.github.io/data/wrds/notebook/
Prepared for the U.S.E. Finance Data Hub as a database-tutorial template.