Machine learning · Applied ML study
Drug Review Analysis
An NLP and machine-learning study of patient drug reviews: a TF-IDF sentiment model, engineered features, and a Random Forest vs MLP comparison.
Stack
- Python
- pandas
- scikit-learn
- spaCy
- NLTK
- PyTorch
- seaborn
- Google Colab
- reviews cleaned for the sentiment model
- 320,753
- reviews cleaned for the sentiment model
- sentiment test accuracy, positive vs negative
- 83.5%
- sentiment test accuracy, positive vs negative
- lisinopril reviews in the main study
- 6,524
- lisinopril reviews in the main study
- models compared: Random Forest and MLP
- 2
- models compared: Random Forest and MLP
What it does
The objective was to investigate how textual sentiment and structured attributes can be combined to improve predictive performance. The data is the drugs.com side-effects review dataset: each row is one patient review with the drug, condition, age group, sex, date, ratings for effectiveness, ease of use and satisfaction, the review text, and how many readers found it useful.
The work has two parts. First, every review in the dataset is cleaned and used to train a sentiment model. Then the study narrows to a single drug, lisinopril, and combines that sentiment with engineered features to predict a five-level side-effect score, comparing a Random Forest with an MLP written in PyTorch.
1 · Sentiment model (all reviews)
2 · Side-effect study (lisinopril)
Cleaning the review text
Raw reviews are noisy, so each one passes through a four-step cleaning pipeline before vectorisation:
- Emoticons like
:)and:(are replaced with the placeholder wordshappyemoticonandsademoticon, so their sentiment survives punctuation removal. - Punctuation is replaced with spaces.
- Stopwords from scikit-learn and NLTK combined, plus tokens starting with a digit, are removed.
- Lemmatisation with spaCy (
en_core_web_sm) lowercases each word and reduces it to its base form.
def clean_review(text, STOPWORDS=STOPWORDS, nlp=nlp): """Cleans up review text for sentiment analysis""" # … def rem_stop_num(text): """Remove stopwords and tokens starting with digits""" return " ".join(word for word in text.split() if word not in STOPWORDS and not word[0].isdigit()) def lemmatize(text): """Lemmatize, lowercase, and strip""" return " ".join(tok.lemma_.lower().strip() for tok in nlp(text) if tok.lemma_ != "-PRON-") # Pipeline text = rep_emo(text) text = rep_punct(text) text = rem_stop_num(text) text = lemmatize(text) return textA sentiment model from 320,000 reviews
After cleaning, 320,753 reviews remain. Each is labelled from its satisfaction rating: below 3 is negative, above 3 is positive, and exactly 3 is neutral. The data is split 75/25 with stratification, neutral reviews are set aside, and the binary task is learned from a TF-IDF representation (2,500 features, terms appearing in at least 10 reviews and at most 80% of them) with a Random Forest classifier.
| Class | Precision | Recall | F1 | Test reviews |
|---|---|---|---|---|
| Negative (rating < 3) | 0.83 | 0.80 | 0.82 | 32,110 |
| Positive (rating > 3) | 0.84 | 0.86 | 0.85 | 37,177 |
Overall test accuracy was 83.5% on 69,287 held-out reviews (99.2% on the training set). This model is used later to score the neutral, 3-star reviews, where the rating alone doesn't say which way the text leans.
Feature engineering
The main study uses the 6,524 lisinopril reviews left after removing empty and whitespace-only entries. From these the notebook builds:
- Temporal features: year, month and day of the week, parsed from the review date.
- Skew correction:
UsefulCountandEaseofUseare scaled into [1, 2] and passed through a Box-Cox power transform, so heavily skewed counts don't dominate. - Encoding: one-hot columns for age group, sex, month and weekday, and a standardised effectiveness rating.
- Review rank: for ratings below or above 3 it follows the rating, and for neutral 3-star reviews it comes from the sentiment model's prediction on the cleaned text.
- Synthetic demographics: a name column generated with Mimesis and a race column sampled at random in fixed proportions. Because they're randomly generated, they exist only to exercise the pipeline and carry no real-world information.
The prediction target is a five-level side-effect score, defined in the notebook as the inverse of the satisfaction rating (satisfaction 5 maps to level 1, satisfaction 1 to level 5). The data is split 75/25, stratified by that score.
Random Forest vs MLP
Two models were trained on the same split. The Random Forest uses 100 trees on all engineered features. The MLP is a PyTorch network with three hidden layers (128, 64 and 32 units), each followed by batch normalisation, GELU and 40% dropout, with Kaiming initialisation. It trains for 400 full-batch epochs with AdamW and cross-entropy loss, using the four numeric features.
class PowerfulMLP(nn.Module): def __init__(self, in_dim, out_dim, dropout=0.4): super(PowerfulMLP, self).__init__() self.fc1 = nn.Linear(in_dim, 128) self.bn1 = nn.BatchNorm1d(128) self.fc2 = nn.Linear(128, 64) self.bn2 = nn.BatchNorm1d(64) self.fc3 = nn.Linear(64, 32) self.bn3 = nn.BatchNorm1d(32) self.fc4 = nn.Linear(32, out_dim) self.dropout = nn.Dropout(dropout) self.act = nn.GELU() # … def forward(self, x): x = self.act(self.bn1(self.fc1(x))) x = self.dropout(x) x = self.act(self.bn2(self.fc2(x))) x = self.dropout(x) x = self.act(self.bn3(self.fc3(x))) x = self.dropout(x) out = self.fc4(x) return outOn the 1,631 test reviews the Random Forest reached 83.3% accuracy (99.8% on training data) and the MLP 68.7%. The per-level breakdown shows where the gap comes from: the MLP handles the extremes well but rarely predicts the middle levels 3 and 4.
F1 score by side-effect level (test set)
- Random Forest
- MLP
Show as a table
| Level | Random Forest F1 | MLP F1 | Test reviews |
|---|---|---|---|
| Level 1 | 0.89 | 0.83 | 276 |
| Level 2 | 0.82 | 0.67 | 202 |
| Level 3 | 0.69 | 0.34 | 258 |
| Level 4 | 0.72 | 0.07 | 211 |
| Level 5 | 0.89 | 0.80 | 684 |
Takeaways
The full notebook, with every chart and output, is on Google Colab.