The world’s leading publication for data science, AI, and ML professionals.

Enhancing E-Commerce with Generative AI – Part 1

From Data to Product Recommendations

Image from unsplash.com
Image from unsplash.com

Generative AI is revolutionizing how businesses in various domains operate and e-commerce is no exception. As e-commerce continues to grow, so does the need for more scalable and sophisticated solutions. In these series of posts, we explore various applications of generative AI in e-commerce. In part 1, we look at how generative AI helps in product recommendations. We first use products metadata to learn representative embeddings for them, and show how these embeddings help in classifying items. We then compute user embedding based on their historical purchases. Finally, we use the learned users embeddings and items embeddings to recommend products. In part 2 of the series, we will look into generating engaging product descriptions and how to extract key positive and negative features from reviews.

Let’s get started with part 1. Here is the table of content for this post:

Table of Contents

1. The Dataset

  • Synthetic Data Generation
  • Item Metadata Dataset Generation
  • User-Item Dataset Generation

3. Learning Representative Embeddings for Items

  • Data Preparation and Cleaning
  • Combining Features, Description, and Title
  • Using Sentence-Transformers for Embedding
  • Evaluating Item Embeddings
  • Sub-Category Assignment
  • TSNE Visualization
  • KNN Classification

4. Learning Representation for Users

  • High-Quality User Selection
  • Train-Test Split for User Data
  • Adding Negative Examples

5. Evaluating Recommendation Quality

  • Precision@k Metric
  • Implementation of Precision@k

6. Building Users’ Embeddings

  • Approach 1: Users as a Bag of Their Purchases
  • Approach 2: Exponentially Decaying Weighted Average

8. Conclusion

  • Summary of Findings
  • Closing Thoughts

The Dataset

While there are many user-item datasets available online, for the purpose of this post, we synthetically generate the dataset. This dataset consists of two parts: 1) item metadata, and 2) user-item data.

The item-metadata dataset contains item_id, title, feature, description, and category. The user-item purchase data contains user_id, item_id, and timestamp. This dataset indicates which user has bought which item at what time.

Item Metadata Dataset Generation

To generate the item metadata dataset, I used openAI API. You have to have enough credits with openAI and then create an API key. Here is the code I wrote to generate the item-metadata dataset. You can replace your api key in the code below and generate some data points.

from openai import OpenAI

client = OpenAI(api_key='your-api-key')

def form_prompt():

  completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
      {"role": "user", 
      "content": """
        Generate a item-metadata dataset for me which has five columns: item_id, title, feature, description, category. I want feature and description to be a list, and category to be set to "fashion". 
        The item_id column is an integer that is picked randomly.  
        """
      }
    ]
  )

  return(completion.choices[0].message.content)

if __name__ == '__main__':

    response = form_prompt()
    print(response)

As you see, we are using GPT-4o and we are prompting it to generate items of the fashion category. This generated five examples of data as following:

item-metadata generated from openAI- image by author
item-metadata generated from openAI- image by author

Now that we see our prompt is working, let’s change it a bit to pass an item_id and generate a data point:

def form_prompt(item_id):

  completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
      {"role": "user", 
      "content": """
        I give you an item_id for an item in fashion category, and want you to generate title, feature and description for it. 
        I want the the description to be full sentences and both features and description to be a list.
        item id: {item_id}  
        """.format(item_id=item_id)
      }
    ]
  )

  return(completion.choices[0].message.content)

if __name__ == '__main__':

    response = form_prompt(1234)
    print(response)

and here is the output:

**Item ID: 1234**

**Title:**
Elegant Women's Floral Summer Dress - Lightweight and Breathable

**Features:**
1. Made from lightweight, breathable fabric.
2. Beautiful floral print design.
3. Adjustable spaghetti straps for a perfect fit.
4. Flattering A-line silhouette.
5. Perfect for casual outings, beach days, or summer parties.
6. Available in various sizes from S to XL.
7. Easy to wash and maintain.
8. Offers both comfort and style.

**Description:**
1. This elegant women's floral summer dress is crafted from lightweight and breathable fabric, ensuring you stay cool and comfortable even on the hottest days.
2. Featuring a beautiful floral print, this dress captures the essence of summer and adds a touch of femininity to your wardrobe.
3. The adjustable spaghetti straps allow for a customized fit, making sure you not only look good but feel good too.
4. Designed with a flattering A-line silhouette, this dress enhances your natural curves while providing a relaxed and comfortable fit.
5. Whether you're heading to a casual outing, a beach day, or a summer party, this versatile dress is perfect for various occasions.
6. Available in a range of sizes from S to XL, there's a perfect fit for everyone.
7. Maintenance is a breeze – it's easy to wash and maintain, keeping you looking fresh and stylish with minimal effort.
8. Combining comfort and style, this summer dress is a must-have addition to any fashion-forward wardrobe.

We can easily call this prompt for multiple item_id and generate many data points:

if __name__ == '__main__':
    for item_id in range(1,1000):
        response = form_prompt(item_id)
        print(response)

We will process the response and save them in a item_metadata.json file to use later.

User-Item Dataset Generation

It is a much easier task to synthetically generate a user-item dataset. This dataset contains user_id, item_id, and timestamp. To generate it, we can do the following:

import json
import random
from datetime import datetime, timedelta

def generate_random_timestamp(start_year=2022, end_year=2024):
    start = datetime(start_year, 1, 1)
    end = datetime(end_year, 12, 31)
    return start + (end - start) * random.random()

def create_json_file(num_records, filename):
    data = []

    for _ in range(num_records):
        item_id = random.randint(1, 1000)
        user_id = random.randint(1, 200)
        timestamp = generate_random_timestamp().strftime('%Y-%m-%d %H:%M:%S')

        record = {
            "item_id": item_id,
            "user_id": user_id,
            "timestamp": timestamp
        }

        data.append(record)

    with open(filename, 'w') as file:
        json.dump(data, file, indent=4)

In the above code, we assumed there are 1000 items, and 200 users. We can generate the item_user_data.json file as following:


create_json_file(7000, 'item_user_data.json')

Now that both datasets are ready, let’s start solving the item recommendation problem.

Learning Representative Embeddings for Items

In recommending products to users, we first need to learn a representative embeddings for items/products. Once we have that, then we use users’ purchase history to obtain a representation for them based on the items they have bought. And only then, when we have representations for users and products, we can do product recommendation.

So, first we compute a representative embeddings for items based on their feature, title and description. The goal here is to have an embedding that reflects item’s category or sub-category. If we find such an embedding, we can use it to identify the category (or sub-category) of new items.

Data Preparation And Cleaning

We first load the data. We read item-metadata dataset, which contains metadata about products in fashion category:

import pandas as pd
meta_df = pd.read_json("item_metadata.json", lines=True)

To ensure we work with high quality data points, we should filter out rows which have an empty list for feature, and description. We should also filter out rows which have lesser than 10 features, because the more features a product has the better embedding we can compute for it. The goal of the cleaning process is to keep only high quality rows.

mask1 = meta_df['features'].apply(lambda x: x != [])
mask2 = meta_df['description'].apply(lambda x: x != [])

df2 = meta_df[mask1 & mask2]

df2.loc[:, 'Length'] = df2['features'].apply(len)
df3 = df2[df2['Length'] >= 10]
print(df3.shape)

Now, df3 contains high quality items with enough description and features.

Combining Features, Description, and Title

Next, we combine three columns of features , description and title into one column and use it to compute embedding vectors for the products:

df3.loc[:, 'j_features'] = df3['features'].apply(lambda x: ''.join(x))
df3.loc[:, 'j_description'] = df3['description'].apply(lambda x: '.'.join(x))
df3.loc[:, 'combined'] = df3[['title', 'j_features', 'j_description']].agg(' '.join, axis=1)

Ok, we are done. combined is the column that we are going to use to compute an embedding for each item.

Using Sentence-Transformers for Embedding

We can use any pre-trained LLM to compute item embedding but I prefer to use sentence-transformers since the description contains sentences and sentence-transformers are known to learn better representations for sentences. If you are not familiar with sentence-transformers read my other post on it:

SentenceTransformer: A Model For Computing Sentence Embedding

First, install sentence transformer in your notebook environment:

!pip install sentence-transformers

Second, load one of their models:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

and then compute an embedding:

df3.loc[:, 'embeddings'] = df3['combined'].apply(lambda x: model.encode(x))

Evaluating Item Embeddings

Now, let’s check the quality of the embeddings. Is it really representative of the category or the sub-category of the item?

In this post, I choose to check for sub-category since all the items we have loaded and inferred embeddings are in one category, i.e. fashion.

Sub-Category Assignment

So first let’s add a sub_category column to our dataset:

def assign_sub_cat(x):
    x = x.lower()
    for sub_cat in ['clothing', 'jewelry', 'shoe', 'watch']:
        if sub_cat in x:
            return sub_cat
    for sub_cat in ['handbag', 'wallet']:
        if sub_cat in x:
            return 'handbag & wallet'
    for sub_cat in ['belt', 'hat', 'scarf', 'sunglass', 'tie']:
        if sub_cat in x:
            return sub_cat 
    for sub_cat in ['activewear', 'swimwear', 'outerwear', 'socks']:
        if sub_cat in x:
            return sub_cat
    for sub_cat in ['luggage','backpack', 'travel']:
        if sub_cat in x:
            return 'luggage & travel gear'
    for sub_cat in ['intimates', 'underwear']:
        if sub_cat in x:
            return 'intimates'
    return 'other'

df3.loc[:, 'sub_category'] = df3['combined'].apply(lambda x: assign_sub_cat(x))

As you see, based on the words that are in the combined column, we picked a sub_category. The sub_category takes the following 14 string values:

{0: 'activewear',
 1: 'belt',
 2: 'clothing',
 3: 'handbag & wallet',
 4: 'hat',
 5: 'jewelry',
 6: 'luggage & travel gear',
 7: 'other',
 8: 'outerwear',
 9: 'scarf',
 10: 'shoe',
 11: 'socks',
 12: 'sunglass',
 13: 'tie',
 14: 'watch'}

TSNE Visualization

Now, that every item has an embedding and a sub_category, let’s plot them in 2D via TSNE and see if there any obvious clusters:

from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

embeddings_array = np.array(df3['embeddings'].tolist())
categories = df3['sub_category'].astype('category').cat.codes

# Apply t-SNE to reduce embeddings to 2D
tsne = TSNE(n_components=2, random_state=42)
embeddings_2d = tsne.fit_transform(embeddings_array)

plt.figure(figsize=(14, 10))
scatter = plt.scatter(embeddings_2d[:, 0], embeddings_2d[:, 1], c=categories, cmap='viridis', edgecolor='k', s=50)
plt.legend(*scatter.legend_elements(), title="Subcategories")
plt.title('2D t-SNE of Product Embeddings by Subcategory')
plt.xlabel('Dimension 1')
plt.ylabel('Dimension 2')
plt.show()

And it gives the following plot.

TSNE representation of the item embeddings- image by author
TSNE representation of the item embeddings- image by author

If you pay close attention to the plot, you can see few clusters of items. For example the yellow dots, form a cluster that corresponds to the number 13 which is watch items. Another cluster is the sun glasses (number 12) on the left side of the plot, and another more widespread cluster is the number 11 which corresponds to socks.

clusters in 2D t-SNE representation of the embeddings - image by author
clusters in 2D t-SNE representation of the embeddings – image by author

This is good news! Even though we are in 2D and due to dimension reduction we have lost a lot of information, still the embeddings represent the sub-categories.

KNN Classification

Next, let’s use these embeddings to classify items into their sub-category. To do this, we utilize KNN (K Nearest Neighbor) classification algorithm in Scikit-learn:

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import classification_report

X = np.array(df3['embeddings'].tolist())
y = df3['sub_category']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3) 

# Initialize and train the k-NN classifier
knn = KNeighborsClassifier(n_neighbors=3)
knn.fit(X_train, y_train)

# Make predictions
y_pred = knn.predict(X_test)

# Evaluate the classifier
print(classification_report(y_test, y_pred))

As you see we took 30% of the data as test and the remaining as training data. We print the classification report, and the results are as following:

performance of knn classification - image by author
performance of knn classification – image by author

We see that the weighted average precision, recall and f1-score is all in the range of 70%, which is good. It is yet another signal that the embeddings are representative of the sub-category field.

Learning Representation for Users

To build users embeddings, we load item_user_data.json file. This dataset contains the items users have purchased:

import pandas as pd
purchase_df = pd.read_json("item_user_data.json", lines=True)

The dataset looks as following:

Item_user_data dataset- image by author
Item_user_data dataset- image by author

What we want to do is that we want to select high quality users, these are users who have a long purchase history. We then sort their purchases in time and pick the first N purchases as training data, and the remaining as test data. The training data will help us to build their representation (or embedding), and the test data allows us to test the quality of their embedding.

High-Quality User Selection

Let’s first convert the timestamp column to unix epoch time in terms of seconds. This gives an integer number:

# Convert 'timestamp' to Unix epoch time in seconds
purchase_df.loc[:, 'timestamp_int'] = purchase_df['timestamp'].astype(int) // 10**9

A new column timestamp_int is added:

image by author
image by author

We then select users who are "high quality" meaning they have a long purchase history. Here we define "long" as 30 items or more:

users_to_items = {}

for _,row in purchase_df.iterrows():
    user = row['user_id']
    item = row['item_id']
    time = row['timestamp_int']
    if user not in users_to_items:
        users_to_items[user] = []
    users_to_items[user].append((item, time))

selected_users = {}
for k,v in users_to_items.items():
    items = list(set([x[0] for x in v]))
    if len(items)>=30:
        selected_users[k] = v

Ok, selected_users is our dictionary to work with.

Train-Test Split for User Data

Now, let’s split the data of these users into train and test:

from collections import defaultdict

def split_user_data(selected_users, test_size=10):
    train_data = defaultdict(list)
    test_data = defaultdict(list)

    for user, purchases in selected_users.items():
        # Sort purchases by time
        sorted_purchases = sorted(purchases, key=lambda x: x[1])

        # Split into train and test
        if len(sorted_purchases) > test_size:
            train_data[user] = sorted_purchases[:-test_size]
            test_data[user] = sorted_purchases[-test_size:]
        else:
            train_data[user] = []
            test_data[user] = sorted_purchases

    return dict(train_data), dict(test_data)

train_data, test_data = split_user_data(selected_users, test_size=10)

As you see, we chose to pick the last 10 purchases of every user as their test data, and the remaining as their train data. These 10 items are their positive examples, meaning these are the items users have actually purchased.

Adding Negative Examples

Now, we need to add some negative examples too, i.e. items that users have not purchased. To do so, we sample 10 items from all existing items that are neither in user’s purchase history nor in the test data that we want to predict. This forms our negative examples.

import random 

# Collect all unique items
all_items = set()
for purchases in selected_users.values():
    for item, _ in purchases:
        all_items.add(item)

# Sample 10 items for each user that they have not purchased
negative_examples = {}
for user, purchases in selected_users.items():
    user_items = {item for item, _ in purchases}
    available_items = list(all_items - user_items)
    if len(available_items) >= 10:
        sampled_items = random.sample(available_items, 10)
    else:
        sampled_items = available_items  # If fewer than 10, return all available
    negative_examples[user] = sampled_items

Our positive examples is also the set of items the users have bought. This set also contains 10 positive examples per user.

# Positive examples
positive_examples = {}
for k,v in test_data.items():
    positive_examples[k] = [item[0] for item in v]

Let me state that our test set is the union of positive examples and negative examples for each user:

test_set = {}
for user, v in positive_examples.items():
    test_set[user] = list(set(v + negative_examples[user]))

Evaluating Recommendation Quality

A common metric to evaluate quality of recommendations is precision@k. This metric looks at the top k recommended items and measure what percentage of them are relevant; where relevant here means they are in positive examples of the user.

The definition of precision@k is as following:

precision at k metric - Image by author
precision at k metric – Image by author

Precision@k is one the most commonly used metrics but it is not the only one. There are many other metrics such as Normalized Discounted Cumulative Gain (NDCG) which measures the ranking quality by considering the position of recommended items in the list. We will use precision@k in this post.

def precision_at_k(user_recommendations, k):
    """
    Compute Precision at k for a set of user recommendations.

    :param user_recommendations: dict, where key is a user ID and value is a list of (item, relevance) tuples
    :param k: int, the number of top recommendations to consider
    :return: average of precision at k scores for all users
    """
    precision_scores = {}

    for user, recommendations in user_recommendations.items():
        # Take the top k recommendations
        top_k_recommendations = recommendations[:k]

        # Count the number of relevant items in the top k recommendations
        relevant_count = sum([1 for item, relevance in top_k_recommendations if relevance == 1])

        # Calculate precision at k
        precision = relevant_count / k

        precision_scores[user] = precision

    l = list(precision_scores.values())
    return 100 * sum(l)/len(l)

In above code, user_recommendations is a dictionary that maps a user to a an ordered list of (item, relevancy) where relevancy is 1 if the item is in the positive examples of the user, otherwise it is 0. This list is sorted such that the first item in the list is the most recommended. Recommendations are always sorted descendingly based on a similarity score which is often cosine similarity. The cosine similarity is computed between a user embedding and an item embedding to measure how likely a user is to buy the item.

def cosine_similarity(vec1, vec2):
    """Compute the cosine similarity between two vectors."""
    return np.dot(vec1, vec2) / (norm(vec1) * norm(vec2))

Building Users’ Embeddings

Now that we have the evaluation metric, and the data is also ready, we can build users embeddings. There are many techniques and approaches to build a user embedding from their items embeddings. Here, are a few:

  1. The simplest method is to define user embedding as a bag of their items’ embeddings. In this approach, there is no one embedding assigned to a user, but a list of embeddings is considered as a representative for the user.
  2. Another approach is to reduce the bag of embeddings to one single embedding either through averaging or by weight-decay averaging, where weight here is the timestamp. The idea is that the most recent purchases will influence the user’s embedding more than the early purchases.
  3. A third approach is to use more sophisticated methods such as attention mechanism to compute user embedding.

We start with the first approach for user embedding which is the simplest one.

Approach 1: Users As A Bag of Their Purchases

This is the simplest approach where we define user embedding as a bag of their items’ embeddings.

Let’s load the embeddings and compute the cosine similarity scores between the test_set (which contains positive and negative examples) and the user embedding.

import numpy as np
from numpy.linalg import norm
from collections import defaultdict
import ast
import json

def load_embeddings(file_path):
    """
    Load embeddings from a file. Assumes each line is formatted as 'item, embedding'.
    """
    with open(file_path, 'r') as file:
        embeddings = json.load(file)

    for k,v in embeddings.items():
        embeddings[k] = ast.literal_eval(v)
    return embeddings

def compute_scores(train_data, test_set, embeddings_file):
    # Load embeddings
    embeddings = load_embeddings(embeddings_file)
    # Initialize the result dictionary
    scores = defaultdict(list)

    # Compute scores for each user
    for user, train_items in train_data.items():
        train_items = [item for item, _ in train_items]
        train_vectors = [embeddings[item] for item in train_items if item in embeddings]

        if user in test_set:
            test_items = test_set[user]
            for test_item in test_items:
                if test_item in embeddings:
                    test_vector = embeddings[test_item]
                    similarity_sum = sum(cosine_similarity(test_vector, train_vector) for train_vector in train_vectors)
                    scores[user].append((test_item, similarity_sum))

    return dict(scores)

embeddings_file = 'fashion_item_emb.json'  # this file contains item embeddings
scores = compute_scores(train_data, test_set, embeddings_file)

Now that we have scores for each user, let’s order it descendingly and add relevancy (either 1 or 0):

users_recom = {}
for user, v in scores.items():
    users_recom[user] = []
    ll = sorted(v, key=lambda x: x[1], reverse=True)
    for item,_ in ll:
        if item in positive_examples[user]:
            users_recom[user].append((item, 1))
        else:
            users_recom[user].append((item, 0))

We are now ready to compute precision@k. Let’s print precision@k for k=1,3,5 .

print(f"precision@1 is {round(precision_at_k(users_recom, 1),2)}")
print(f"precision@3 is {round(precision_at_k(users_recom, 3),2)}")
print(f"precision@5 is {round(precision_at_k(users_recom, 5),2)}")

The result is as following:

precision@1 is 68.82
precision@3 is 62.01
precision@5 is 57.74

As we see, with a simple user representation, the precision@k is not bad at all.

Next, let’s try to compute user embedding as a weighted average of their items embeddings, where weight reflects the recency of the purchase.

Approach 2: Exponentially Decaying Weighted Average

In this approach, we compute the exponentially decaying weighted average of item embeddings. In simple terms, a user embedding is the weighted average of its items embeddings where weight is defined by exponential function.

import numpy as np

def compute_user_embedding(purchases, item_embeddings, decay_rate=0.001):
    """
    Compute the user embedding as the weighted average of item embeddings.
    """

    if not purchases:
        return np.zeros_like(next(iter(item_embeddings.values())))

    # Extract item_ids and timestamps
    item_ids, timestamps = zip(*purchases)
    valid_item_ids, valid_timestamps = [],[]

    for i, item_id in enumerate(item_ids):
        if item_id in item_embeddings:
            valid_item_ids.append(item_id)
            valid_timestamps.append(timestamps[i])

    # Normalize timestamps
    normalized_timestamps = max(valid_timestamps) - np.array(valid_timestamps)

    # Compute weights using exponential decay
    weights = np.exp(-decay_rate * normalized_timestamps)

    # Compute weighted embeddings
    embeddings = np.array([item_embeddings[item_id] for item_id in valid_item_ids])
    weighted_embeddings = embeddings * weights[:, np.newaxis]

    # Compute the weighted average
    user_embedding = weighted_embeddings.sum(axis=0) / weights.sum()

    return user_embedding

In the above function, the parameters are as following:

  • purchases: List of tuples (item_id, timestamp)
  • item_embeddings: Dictionary mapping item_id to embedding vector
  • decay_rate: Exponential decay rate for weighting the recency And the exponential weight for the average is defined in the following line:
weights = np.exp(-decay_rate * normalized_timestamps)

Alright, now that we have the users embeddings, lets compute recommendations and measure precision@k.

The following code computes the recommendations:

def compute_recoms_exp_decay(user_embedding, test_set, embeddings_file, positive_examples):
    # Load embeddings
    embeddings = load_embeddings(embeddings_file)    # Initialize the result dictionary
    scores = defaultdict(list)
    users_recom = defaultdict(list)

    # Compute scores for each user
    for user, user_emb in user_embedding.items():
        if user in test_set:
            test_items = test_set[user]
            for test_item in test_items:
                if test_item in embeddings:
                    test_vector = embeddings[test_item]
                    similarity_score = cosine_similarity(test_vector, user_emb)
                    scores[user].append((test_item, similarity_score))

        # Sort recoms based on scores and add relevancy
        ll = sorted(scores[user], key=lambda x: x[1], reverse=True)
        for item,_ in ll:
            if item in positive_examples[user]:
                users_recom[user].append((item, 1))
            else:
                users_recom[user].append((item, 0))
    return users_recom

And now we can call the function and run precision@k for it:

users_recom = compute_recoms_exp_decay(user_embedding, test_set, "fashion_item_emb.json", positive_examples)

print(f"precision@1 is {round(precision_at_k(users_recom, 1),2)}")
print(f"precision@3 is {round(precision_at_k(users_recom, 3),2)}")
print(f"precision@5 is {round(precision_at_k(users_recom, 5),2)}")

The results are as following, which are not as good as approach 1:

precision@1 is 62.37
precision@3 is 61.29
precision@5 is 58.92

Conclusion

In this post, we looked at the problem of recommendation in e-commerce domain, and we explored how generative AI can help to compute relevant recommendations to users. We first shown how to synthetically generate the data, and then broke down the problem into two sub-problems: 1) learning item embeddings, and 2) learning user embedding and finally using the two embeddings to compute a similarity score as a recommendation score. We also talked about a common evaluation metric called precision@k which measures the quality of top k recommended items.

Recommendation is one of the many aspects that generative AI can significantly enhance in e-commerce domain. In the next post, we will look into how generative AI can help in generating engaging product descriptions as well as extracting sentiments from reviews and highlighting them as key positive and negative aspects of the products.


Thank you for reading.

Follow me for more similar posts on Large Language Models and machine learning in general. If you have any questions or suggestions, please feel free to reach out to me: Email: [email protected] LinkedIn: https://www.linkedin.com/in/minaghashami/


Related Articles