Skip to the analysis
GARETH GRANTTECHNOLOGY · DATA · INNOVATION Back to projects
Back to all projects

Portfolio case study · NLP · Responsible AI

News text analytics.From patterns to responsible decisions.

A step-by-step investigation of supplied news records comparing classifiers, testing shortcut learning and exploring themes without overstating the labels.

Gareth Grant · 5 October 2026 · Executed code, charts and interpretation

44,898supplied records
2inherited labels
44executed code cells
30,000maximum TF-IDF features

Evidence note: Labels were inherited from the source files and were not independently fact-checked. A model score is evidence about patterns in this dataset—not a probability that a real-world claim is true.

Table of contents

News Text Analytics: From Dataset Patterns to Responsible Decisions

Gareth Grant | Portfolio and learning notebook

A step-by-step investigation of 44,898 supplied news records. We compare simple classifiers, investigate shortcut learning, and explore topics. Each code cell has one learning objective and a space for comments.

Question: How well can article language distinguish the labels in these two files, and what limits its usefulness outside this dataset?

The labels are inherited from Fake.csv (0) and True.csv (1). They are not independently verified in this project. A model score is not a probability that a claim is true.

Route: setup → quality audit → development/test split → exploration → baseline → model comparison → independent test → explanations → topics → practical applications.

Run: Python 3.11+ with pandas, numpy, scipy, scikit-learn, matplotlib and IPython. Put both CSV files beside this notebook (or in data/), change DATA_DIR below, then run cells in order. No NLTK downloads, Colab uploads, or installation commands are needed. The published outputs were executed on the supplied files; rerunning replaces them. Full training may take several minutes.

Publication: Do not redistribute article text without checking rights and dataset terms. The HTML companion suppresses raw article excerpts. Keep full datasets outside your public website repository.

1. Import tools

Each tool has a clear purpose: tables, numeric arrays, reproducible models, metrics and charts.

Your comment: Add your observation or question here.

Code cell 1
from pathlib import Path
import re, json, platform, hashlib
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import sklearn
from IPython.display import display, Markdown
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import MultinomialNB
from sklearn.dummy import DummyClassifier
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.metrics import accuracy_score, balanced_accuracy_score, f1_score, precision_score, recall_score, roc_auc_score, classification_report, ConfusionMatrixDisplay, RocCurveDisplay, brier_score_loss
from sklearn.calibration import CalibrationDisplay

2. Configure paths and reproducibility

This is the only path you should normally need to edit. The random seed makes splits repeatable.

Your comment: Add your observation or question here.

Code cell 2
DATA_DIR = Path('.')  # Change to Path('data') if needed.
SEED = 42
LABELS = {0: 'Fake-file label', 1: 'True-file label'}
plt.rcParams.update({'figure.figsize': (8, 4), 'axes.spines.top': False, 'axes.spines.right': False})

3. Record the environment

Versions matter when reproducing numeric results.

Your comment: Add your observation or question here.

Code cell 3
display(pd.Series({'Python': platform.python_version(), 'pandas': pd.__version__, 'numpy': np.__version__, 'scikit-learn': sklearn.__version__}, name='version').to_frame())
version
Python 3.12.14
pandas 2.2.3
numpy 2.3.5
scikit-learn 1.8.0

4. Load the fake-labelled file

Read one file at a time so failures are easy to locate.

Your comment: Add your observation or question here.

Code cell 4
fake = pd.read_csv(DATA_DIR / 'Fake.csv')
print('Fake rows:', len(fake))
Fake rows: 23481

5. Load the true-labelled file

The labels describe the source files, not the outcome of our own fact-check.

Your comment: Add your observation or question here.

Code cell 5
true = pd.read_csv(DATA_DIR / 'True.csv')
print('True rows:', len(true))
True rows: 21417

6. Validate the schema

Fail early if a required column is missing.

Your comment: Add your observation or question here.

Code cell 6
required = {'title', 'text', 'subject', 'date'}
assert required <= set(fake.columns) and required <= set(true.columns)
print('Required columns found in both files.')
Required columns found in both files.

7. Attach labels and combine

Use a new table rather than modifying the imported originals.

Your comment: Add your observation or question here.

Code cell 7
raw = pd.concat([fake.assign(label=0), true.assign(label=1)], ignore_index=True)
print('Raw shape:', raw.shape)
Raw shape: (44898, 5)

8. Check missing values

Missing values and blank strings are different problems.

Your comment: Add your observation or question here.

Code cell 8
display(raw.isna().sum().rename('missing').to_frame())
missing
title 0
text 0
subject 0
date 0
label 0

9. Build a conservative article key

Lowercase and whitespace normalization identify exact content copies with superficial formatting differences. This is not semantic deduplication.

Your comment: Add your observation or question here.

Code cell 9
data = raw.copy()
data['article'] = data['title'].fillna('') + ' ' + data['text'].fillna('')
data['key'] = data['article'].str.lower().str.replace(r'\s+', ' ', regex=True).str.strip()
print('Empty combined articles:', data['key'].eq('').sum())
Empty combined articles: 0

10. Audit contradictory labels

Identical text with both labels cannot provide a consistent training target.

Your comment: Add your observation or question here.

Code cell 10
conflict_keys = data.groupby('key')['label'].nunique()
conflict_keys = conflict_keys[conflict_keys > 1].index
print('Conflicting exact article keys:', len(conflict_keys))
Conflicting exact article keys: 0

11. Remove contradictions, blank bodies and exact copies

A nonempty headline can hide an empty body. We require a body for the article model.

Your comment: Add your observation or question here.

Code cell 11
data = data[~data['key'].isin(conflict_keys) & data['text'].fillna('').str.strip().ne('')].copy()
data = data.drop_duplicates('key')
print('Rows after content cleaning:', len(data))
Rows after content cleaning: 38653

12. Prevent repeated titles or bodies crossing splits

Keep one record per normalized nonempty title and per normalized body. This conservative choice may remove legitimate updates; document the loss. Near-duplicate stories can still remain.

Your comment: Add your observation or question here.

Code cell 12
data['title_key'] = data['title'].fillna('').str.lower().str.replace(r'\s+', ' ', regex=True).str.strip()
data['body_key'] = data['text'].fillna('').str.lower().str.replace(r'\s+', ' ', regex=True).str.strip()
data = data[data['title_key'].ne('')].drop_duplicates('title_key').drop_duplicates('body_key').reset_index(drop=True)
print('Final rows:', len(data), '| Total removed:', len(raw) - len(data))
Final rows: 38262 | Total removed: 6636

13. Inspect metadata shortcuts

Subject is excluded from predictors. Its class separation reveals a collection shortcut in the original design.

Your comment: Add your observation or question here.

Code cell 13
display(pd.crosstab(data['subject'], data['label']).rename(columns=LABELS))
label Fake-file label True-file label
subject
Government News 514 0
News 9049 0
US_News 783 0
left-news 681 0
politics 6417 0
politicsNews 0 11145
worldnews 0 9673

Interpretation: The subject categories do not overlap between the files. Including them can reveal the label directly. Excluding this column is necessary, but other source/style cues can remain inside the article body. Deduplication reduces repeated-document contamination; it cannot establish publisher independence.

14. Make the independent test split

Reserve 20% before exploring language or fitting any vocabulary. Stratification keeps label proportions similar.

Your comment: Add your observation or question here.

Code cell 14
development, test = train_test_split(data, test_size=0.20, stratify=data['label'], random_state=SEED)

15. Make the validation split

A further 20% of development data becomes validation: approximately 64/16/20 overall.

Your comment: Add your observation or question here.

Code cell 15
train, valid = train_test_split(development, test_size=0.20, stratify=development['label'], random_state=SEED)

16. Verify split isolation

These assertions check exact normalized content, title and body isolation.

Your comment: Add your observation or question here.

Code cell 16
for column in ['key', 'title_key', 'body_key']:
    assert set(train[column]).isdisjoint(valid[column])
    assert set(development[column]).isdisjoint(test[column])
display(pd.DataFrame({'train': train['label'].value_counts(), 'validation': valid['label'].value_counts(), 'test': test['label'].value_counts()}).rename(index=LABELS))
train validation test
label
True-file label 13323 3331 4164
Fake-file label 11164 2791 3489

17. Plot training label balance

Use counts with explicit labels. The original pie chart risked mismatching value-count order and labels.

Your comment: Add your observation or question here.

Code cell 17
train['label'].map(LABELS).value_counts().plot.bar(color=['#16324F', '#FF2400'])
plt.ylabel('Articles'); plt.title('Training labels'); plt.xticks(rotation=0); plt.tight_layout(); plt.show()
Analysis chart

18. Summarise training article length

Explore training only. Differences in length can be a shortcut rather than evidence of truth.

Your comment: Add your observation or question here.

Code cell 18
word_counts = train['article'].str.split().str.len()
display(word_counts.groupby(train['label'].map(LABELS)).describe().round(1))
count mean std min 25% 50% 75% max
label
Fake-file label 11164.0 438.3 356.4 2.0 291.0 390.0 519.0 8148.0
True-file label 13323.0 393.0 271.7 33.0 160.0 370.0 527.0 5181.0

19. Measure a source marker

This measures the observed Reuters marker in your supplied training data, without assuming it proves reliability.

Your comment: Add your observation or question here.

Code cell 19
marker = train['article'].str.contains(r'\breuters\b', case=False, regex=True)
display(marker.groupby(train['label'].map(LABELS)).mean().mul(100).rename('Contains Reuters (%)').round(2).to_frame())
Contains Reuters (%)
label
Fake-file label 1.19
True-file label 99.85

20. Define transparent text cleaning

Remove the obvious Reuters token and URLs, but retain negation, numbers and word order. More aggressive cleaning is not automatically better.

Your comment: Add your observation or question here.

Code cell 20
def clean_article(value):
    value = re.sub(r'https?://\S+|www\.\S+', ' ', str(value))
    value = re.sub(r'\breuters\b', ' ', value, flags=re.I)
    return re.sub(r'\s+', ' ', value).strip().lower()

21. Apply fixed cleaning

This function learns no dataset statistics. TF-IDF will still be fitted only on training data.

Your comment: Add your observation or question here.

Code cell 21
train_text = train['article'].map(clean_article)
valid_text = valid['article'].map(clean_article)

22. Fit a majority baseline

A baseline establishes the value added by language modelling. No oversampling is justified by the modest imbalance alone.

Your comment: Add your observation or question here.

Code cell 22
baseline = DummyClassifier(strategy='most_frequent')
baseline.fit(np.zeros((len(train), 1)), train['label'])
baseline_pred = baseline.predict(np.zeros((len(valid), 1)))
print('Validation baseline accuracy:', round(accuracy_score(valid['label'], baseline_pred), 4))
Validation baseline accuracy: 0.5441

23. Define the TF-IDF representation

TF-IDF weights terms using training frequencies. Bigrams capture short phrases; limits keep memory manageable.

Your comment: Add your observation or question here.

Code cell 23
def vectorizer():
    return TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_df=0.95, max_features=30000, sublinear_tf=True)

24. Build logistic regression

The pipeline prevents the validation/test vocabulary from informing training. C=1 is fixed in advance.

Your comment: Add your observation or question here.

Code cell 24
logistic = Pipeline([('tfidf', vectorizer()), ('classifier', LogisticRegression(C=1.0, max_iter=1000, solver='liblinear', random_state=SEED))])

25. Train logistic regression

Fit the vectorizer and classifier together using training articles only.

Your comment: Add your observation or question here.

Code cell 25
logistic.fit(train_text, train['label'])
print('Logistic regression fitted.')
Logistic regression fitted.

26. Build and train Naive Bayes

This is a fast, meaningful text-classification comparator using the same representation.

Your comment: Add your observation or question here.

Code cell 26
naive_bayes = Pipeline([('tfidf', vectorizer()), ('classifier', MultinomialNB(alpha=1.0))])
naive_bayes.fit(train_text, train['label'])
print('Naive Bayes fitted.')
Naive Bayes fitted.

27. Define evaluation metrics

Macro-F1 gives equal weight to both labels. Fake-label recall uses positive label 0 explicitly.

Your comment: Add your observation or question here.

Code cell 27
def metrics(y, pred):
    return {'accuracy': accuracy_score(y, pred), 'balanced_accuracy': balanced_accuracy_score(y, pred), 'macro_f1': f1_score(y, pred, average='macro'), 'fake_precision': precision_score(y, pred, pos_label=0, zero_division=0), 'fake_recall': recall_score(y, pred, pos_label=0, zero_division=0)}

28. Compare validation results

Choose the best model on validation macro-F1, not on the independent test.

Your comment: Add your observation or question here.

Code cell 28
models = {'Logistic regression': logistic, 'Naive Bayes': naive_bayes}
validation_results = pd.DataFrame({name: metrics(valid['label'], model.predict(valid_text)) for name, model in models.items()}).T
validation_results.loc['Majority baseline'] = metrics(valid['label'], baseline_pred)
display(validation_results.round(4))
accuracy balanced_accuracy macro_f1 fake_precision fake_recall
Logistic regression 0.9868 0.9862 0.9867 0.9909 0.9799
Naive Bayes 0.9572 0.9566 0.9568 0.9557 0.9502
Majority baseline 0.5441 0.5000 0.3524 0.0000 0.0000

29. Select the model

Only trained text models are eligible; the baseline remains a reference.

Your comment: Add your observation or question here.

Code cell 29
best_name = validation_results.loc[list(models), 'macro_f1'].idxmax()
print('Selected on validation:', best_name)
Selected on validation: Logistic regression

30. Refit on all development articles

Reuse the fixed specification. The independent test remains unseen by every fitted component.

Your comment: Add your observation or question here.

Code cell 30
from sklearn.base import clone
final_model = clone(models[best_name])
final_model.fit(development['article'].map(clean_article), development['label'])
print('Final model fitted on', len(development), 'development articles.')
Final model fitted on 30609 development articles.

31. Evaluate the independent test

This is the primary performance estimate for this dataset and split.

Your comment: Add your observation or question here.

Code cell 31
test_text = test['article'].map(clean_article)
test_pred = final_model.predict(test_text)
test_prob = final_model.predict_proba(test_text)[:, list(final_model.classes_).index(1)]
test_metrics = metrics(test['label'], test_pred)
display(pd.Series(test_metrics).round(4).to_frame('test'))
print(classification_report(test['label'], test_pred, target_names=[LABELS[0], LABELS[1]], digits=4))
                 precision    recall  f1-score   support

Fake-file label     0.9942    0.9762    0.9851      3489
True-file label     0.9804    0.9952    0.9877      4164

       accuracy                         0.9865      7653
      macro avg     0.9873    0.9857    0.9864      7653
   weighted avg     0.9867    0.9865    0.9865      7653

test
accuracy 0.9865
balanced_accuracy 0.9857
macro_f1 0.9864
fake_precision 0.9942
fake_recall 0.9762

32. Visualise classification errors

Rows are true labels; columns are model predictions. A true-file article flagged fake is a reputationally costly error.

Your comment: Add your observation or question here.

Code cell 32
ConfusionMatrixDisplay.from_predictions(test['label'], test_pred, display_labels=[LABELS[0], LABELS[1]], cmap='Blues')
plt.title('Independent test confusion matrix'); plt.tight_layout(); plt.show()
Analysis chart

33. Assess probability ranking

ROC-AUC measures ranking across thresholds, not factual verification or probability calibration.

Your comment: Add your observation or question here.

Code cell 33
print('Test ROC-AUC:', round(roc_auc_score(test['label'], test_prob), 4))
RocCurveDisplay.from_predictions(test['label'], test_prob)
plt.title('Ranking the True-file label'); plt.tight_layout(); plt.show()
Test ROC-AUC: 0.999
Analysis chart

34. Inspect probability calibration

Compare predicted True-file label scores with observed label frequency. Scores need external calibration before operational decisions.

Your comment: Add your observation or question here.

Code cell 34
print('Brier score (lower is better):', round(brier_score_loss(test['label'], test_prob), 4))
CalibrationDisplay.from_predictions(test['label'], test_prob, n_bins=10, strategy='quantile')
plt.title('Test calibration by label'); plt.tight_layout(); plt.show()
Brier score (lower is better): 0.0155
Analysis chart

35. Estimate conditional uncertainty

Bootstrap test predictions, not the training pipeline. This interval omits retraining, source shift and correlated-story uncertainty.

Your comment: Add your observation or question here.

Code cell 35
rng = np.random.default_rng(SEED)
y_array = test['label'].to_numpy()
bootstrap_f1 = []
for _ in range(300):
    indices = rng.integers(0, len(test), len(test))
    bootstrap_f1.append(f1_score(y_array[indices], test_pred[indices], average='macro'))
print('Conditional 95% bootstrap interval for macro-F1:', np.quantile(bootstrap_f1, [0.025, 0.975]).round(4))
Conditional 95% bootstrap interval for macro-F1: [0.9835 0.989 ]

36. Train a headline-only stress test

Headlines reduce body dateline cues. This additional specification is diagnostic and does not replace the selected model.

Your comment: Add your observation or question here.

Code cell 36
headline_model = clone(logistic)
headline_model.fit(development['title'].map(clean_article), development['label'])
headline_pred = headline_model.predict(test['title'].map(clean_article))
display(pd.DataFrame({'Article model': test_metrics, 'Headline diagnostic': metrics(test['label'], headline_pred)}).T.round(4))
accuracy balanced_accuracy macro_f1 fake_precision fake_recall
Article model 0.9865 0.9857 0.9864 0.9942 0.9762
Headline diagnostic 0.9479 0.9460 0.9473 0.9593 0.9249

Interpretation: Compare the headline result with the article model. A performance drop indicates that body content helps, but does not identify whether that help comes from facts, topics, formatting or publisher style. Removing one source token does not remove every source cue. The test is a random within-dataset holdout; it is not an unseen-publisher or future-period evaluation. Do not keep revising the model against these test results.

37. Count errors without republishing articles

Error summaries are useful on a website and avoid printing article bodies.

Your comment: Add your observation or question here.

Code cell 37
errors = test.assign(predicted=test_pred, true_label_score=test_prob)
errors = errors[errors['label'] != errors['predicted']]
display(errors.groupby(['label', 'predicted']).size().rename('errors').to_frame())
errors
label predicted
0 1 83
1 0 20

38. Explain logistic regression word associations

Coefficients describe associations after other features are considered. They do not establish causation or deception.

Your comment: Add your observation or question here.

Code cell 38
terms = logistic.named_steps['tfidf'].get_feature_names_out()
weights = logistic.named_steps['classifier'].coef_[0]
term_table = pd.DataFrame({'term': terms, 'coefficient': weights})
display(pd.concat([term_table.nsmallest(10, 'coefficient').assign(direction='Fake-file association'), term_table.nlargest(10, 'coefficient').assign(direction='True-file association')], ignore_index=True))
term coefficient direction
0 video -8.775696 Fake-file association
1 via -8.457162 Fake-file association
2 read more -5.067111 Fake-file association
3 image -4.725431 Fake-file association
4 just -4.713479 Fake-file association
5 this -4.575586 Fake-file association
6 read -4.466633 Fake-file association
7 featured image -4.434204 Fake-file association
8 featured -4.385447 Fake-file association
9 you -4.018811 Fake-file association
10 said 9.719024 True-file association
11 said on 6.921651 True-file association
12 washington 5.520089 True-file association
13 on wednesday 4.538372 True-file association
14 on tuesday 4.400306 True-file association
15 on 4.267287 True-file association
16 on thursday 4.223689 True-file association
17 president donald 4.153672 True-file association
18 said in 4.150258 True-file association
19 on monday 3.874062 True-file association

Explore topics separately

The original notebook learned LDA topics before its train/test split and then used those features for classification. Here LDA is a separate exploratory analysis trained only on a reproducible sample of training articles. Topic quality is not classifier accuracy. Sentiment and word clouds were removed from the core workflow: sentiment of stemmed text is difficult to interpret, and word clouds do not demonstrate predictive value.

39. Select a reproducible topic sample

A 3,000-article sample keeps this learning exercise practical. It is not a comprehensive map of all news.

Your comment: Add your observation or question here.

Code cell 39
topic_sample = train.sample(n=min(3000, len(train)), random_state=SEED)['article'].map(clean_article)

40. Create word counts for LDA

LDA uses counts. The English stop-word choice is specific to this exploratory topic task.

Your comment: Add your observation or question here.

Code cell 40
topic_vectorizer = CountVectorizer(stop_words='english', min_df=5, max_df=0.8, max_features=5000)
topic_counts = topic_vectorizer.fit_transform(topic_sample)
print('Topic count matrix:', topic_counts.shape)
Topic count matrix: (3000, 5000)

41. Fit a small topic model

Six topics and five passes are declared choices, not a claim of an optimal number of topics.

Your comment: Add your observation or question here.

Code cell 41
topic_model = LatentDirichletAllocation(n_components=6, max_iter=5, learning_method='online', random_state=SEED, n_jobs=1)
topic_model.fit(topic_counts)
print('Topic model fitted.')
Topic model fitted.

42. Inspect topic words

Use these words to suggest topic names, then verify by reading documents locally. A topic is not a true/fake label.

Your comment: Add your observation or question here.

Code cell 42
topic_terms = topic_vectorizer.get_feature_names_out()
topic_rows = [{'topic': i + 1, 'top_words': ', '.join(topic_terms[component.argsort()[-10:][::-1]])} for i, component in enumerate(topic_model.components_)]
display(pd.DataFrame(topic_rows))
topic top_words
0 1 police, said, people, city, black, media, gun,...
1 2 trump, president, clinton, said, donald, campa...
2 3 said, trump, north, united, china, russia, kor...
3 4 said, government, minister, party, state, pres...
4 5 said, house, state, tax, trump, senate, presid...
5 6 people, just, trump, video, twitter, like, new...

43. Plot average topic weights

These are average mixture weights within the sampled training corpus, not counts of unique stories.

Your comment: Add your observation or question here.

Code cell 43
topic_weights = topic_model.transform(topic_counts).mean(axis=0)
plt.bar(np.arange(1, 7), topic_weights, color='#16324F')
plt.xlabel('Topic'); plt.ylabel('Average mixture weight'); plt.title('Training-sample topic mixture'); plt.tight_layout(); plt.show()
Analysis chart

44. Generate an evidence-based portfolio conclusion

This cell updates the wording when results change. Avoid copying an old percentage into a new run.

Your comment: Add your observation or question here.

Code cell 44
display(Markdown(f'**Measured result:** The selected {best_name} achieved test accuracy **{test_metrics["accuracy"]:.2%}** and macro-F1 **{test_metrics["macro_f1"]:.4f}** on **{len(test):,}** held-out articles after conservative deduplication. These results demonstrate recognition of labels in this dataset; they do not validate automatic fact-checking in new domains.'))

Measured result: The selected Logistic regression achieved test accuracy 98.65% and macro-F1 0.9864 on 7,653 held-out articles after conservative deduplication. These results demonstrate recognition of labels in this dataset; they do not validate automatic fact-checking in new domains.

What this shows a recruiter or client

This project demonstrates data-quality checks, reproducible splits, leakage prevention, sparse text features, baseline comparison, validation-based selection, uncertainty estimation, error analysis, explainability, topic discovery and responsible interpretation. Its strongest contribution is an auditable workflow rather than an inflated accuracy claim.

Business applications — proposals, not measured deployments

DecisionHow this workflow could helpData neededSuccess measure
Brand and reputation monitoringRank incoming coverage for analyst review and group emerging themesCurrent coverage, analyst annotations, sources and datesPrecision in the top review queue; analyst time per reviewed item
Competitive intelligenceGroup product, competitor and customer themesLicensed industry articles and sector taxonomyTopic usefulness judged by analysts; missed important stories
Compliance and auditLog ingestion, model version, score, review outcome and evidenceAuthorised records plus verified review decisionsTraceability completeness and review error rates

A sensible pilot compares analysts with and without the tool on the same incoming stream. Estimate benefit as verified time saved × loaded hourly cost, minus running/review costs and false-alert handling. This notebook has not measured return on investment. Scores should prioritise review; factual decisions require evidence.

Astronomy — a transfer of method

I interpret “astrominor” as astronomy/astronomers. This news dataset contains no validated astronomy task, so the trained news classifier cannot be presented as an astronomy model.

A useful astronomy project would apply topic modelling to paper abstracts, tag papers by research area, or route observatory incident reports to specialists. NASA has documented LDA/NLP analysis of astrophysics research trends, and ADS supports astronomy literature discovery (sources below). Those examples motivate the transfer; they do not validate this notebook on astronomy data.

Build a new domain corpus with permissions, expert topic labels and paper dates. Hold out later papers and related paper families. Compare retrieval recall@k or expert-agreed tagging macro-F1 against a simple keyword baseline. Preserve negation, units and object identifiers. Literature topic trends can inform research planning; they cannot confirm astronomical discoveries.

Government — human-reviewed information triage

Possible pilots include grouping public consultation responses, routing service complaints, or prioritising potentially misleading public-health claims for qualified review. Each needs local, current, multilingual data and a task-specific label definition. This historical English news corpus provides no evidence of performance in South African public services.

Measure correct routing, urgent-case recall, reviewer workload and error rates by language and source. Keep evidence links, review logs, access controls, a correction route and retention rules. Do not use this model alone for censorship, penalties, eligibility or allegations about individuals. Its confidence is about dataset labels, not legal or factual truth.

Limitations and next experiments

1. Source and topic confounding remain even after removing subject and the Reuters token. Obtain publisher identifiers and use source-held-out evaluation.

2. Related stories may remain despite exact normalized deduplication. Cluster near-duplicates and keep each cluster in one split.

3. The date field is retained for future work, but inconsistent formats need auditing before a chronological split. Test on later independently labelled articles.

4. Labels were inherited; independently fact-check a stratified sample with multiple reviewers and report agreement.

5. Probabilities and uncertainty are conditional on this corpus. Evaluate calibration and threshold trade-offs on new development data before any operational pilot.

6. Topic number was fixed for readability. Assess topic stability across seeds and expert usefulness on training/development data.

7. The original SMOTE and TF-IDF objects were unused; the active classifier used topic distributions. The rebuilt notebook states exactly which models are actually trained.

Sources and further reading

- Supplied dataset’s stated Kaggle source: confirm licensing and provenance before publication.

- scikit-learn: common pitfalls and leakage: why preprocessing belongs inside a training pipeline.

- NASA: Transforming Science Prioritization Processes Using Artificial Intelligence: an example of LDA/NLP applied to astrophysics research trends.

- SAO/NASA ADS: astronomy literature discovery.

Publication wording: “I built a reproducible news-text classification and topic-exploration workflow, compared interpretable models against a baseline, and audited leakage and source shortcuts. The project includes held-out evaluation and practical proposals for analyst-assisted decision support.”

Additional corrections to the original interpretation

The original text describes TF-IDF cross-validation, but the active pipeline used LDA topic distributions. It also reports SMOTE improvements even though the displayed code only instantiated SMOTE and never applied it. Word embeddings are claimed in the conclusion but are not implemented. Those claims are removed here.

The original confusion-matrix narrative says 9,980 examples, but its listed counts sum to 8,937 (4,189 + 3,987 + 458 + 303). Counts and interpretation must come from the same execution. This rebuilt notebook renders the actual matrix and derives the headline result from the current metrics.

ROC-AUC of 0.97 does not mean 97% of positive cases were identified. It measures how well scores rank a randomly selected positive-labelled example above a negative-labelled example. Recall at a specified threshold measures the fraction identified.

Code copied to clipboard