#########################################
# Textual Analysis on S&P 500 Companies #
# CRSP CIZ Format #
# Qingyi (Freda) Song Drechsler #
# Date: April 2021 #
# Updated: October 2026 #
#########################################
# Import packages
# wrds
import wrds
# common packages
import os
import re
import pandas as pd
import numpy as np
import pickle as pkl
from pprint import pprint
from collections import OrderedDict
from collections import defaultdict
import multiprocessing
import nltk
from nltk.corpus import stopwords
stop_words = stopwords.words("english")
# gensim
import gensim
import gensim.corpora as corpora
from gensim.utils import simple_preprocess
from gensim.models import CoherenceModel
from gensim.test.utils import common_corpus, common_dictionary
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
# spacy
import spacy
nlp = spacy.load('en_core_web_sm', disable=['parser', 'ner'])
# sklearn
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.metrics import accuracy_score
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
# Visualization
import matplotlib.pyplot as plt
%matplotlib inlineTextual Analysis on S&P500 Companies
Bag of words and Doc2Vec on company business descriptions
This set of code demonstrates how to conduct textual analysis using S&P500 companies’ business description. It covers some basic textual analysis steps, including:
- Clean and Prepare Corpus
- Form Bag of Words
- Similarity Based on BOW
- Similarity Based on Doc2Vec
part 0: import packages
This code uses many NLP related packages, such as NLTK for stop words, Gensim and SpaCy for various preparation, vectorization tasks, and scikit-learn for vectorization and similarity calculation. Of course, there are many other ways of handling this task, and this code just serves as a toy model to demonstrate the concept.
part 1: build S&P500 index constituents
Let’s first establish connection with WRDS server and extract the constituents information of S&P500 Index.
###################
# Connect to WRDS #
###################
conn=wrds.Connection()
### Get S&P500 Index Membership from CRSP
### I opt for the monthly frequency of the data,
### but one can choose to work with crsp.dsp500list_v2
### if more precise date range is needed.
sp500 = conn.raw_sql("""
select distinct a.permno, a.mbrstartdt, a.mbrenddt,
b.mthcaldt, b.mthret
from crsp_m_indexes.msp500list_v2 as a,
crsp_m_stock.msf_v2 as b
where a.permno=b.permno
and b.mthcaldt >= a.mbrstartdt and b.mthcaldt <= a.mbrenddt
and b.mthcaldt between '01/01/2000' and '06/30/2026'
order by b.mthcaldt, a.permno
""", date_cols=['mbrstartdt', 'mbrenddt', 'mthcaldt'])Loading library list...
Done
Prior to 2020, Compustat used to provide index constituents data through WRDS, however, this data component was discontinued by Compustat as of October 2020, and researchers can now access S&P500 Index constituents through CRSP. However, many of the other index constituents information are no longer available due to coverage difference between CRSP and Compustat. If you as a researcher find this change inconvenient for your research projects, please reach out to S&P support support.datafeed.mi@spglobal.com directly.
Next, let’s add various other company identifiers (e.g. ticker) by linking the sp500 dataframe with crsp.msenames.
### Add Other Company Identifiers from CRSP.STKSECURITYINFOHIST
### - You don't need this step if only PERMNO is required
### - This step aims to add TICKER, CUSIP, company name,
### share type, primary exchange, SIC code and etc.
stkinfo = conn.raw_sql("""
select distinct permno, issuernm, cusip, ticker,
sharetype, securitytype, securitysubtype,
primaryexch, siccd, secinfostartdt, secinfoenddt
from crsp_m_stock.stksecurityinfohist
""", date_cols=['secinfostartdt', 'secinfoenddt'])
# Merge with SP500 data
sp500_full = pd.merge(sp500, stkinfo, how='left', on='permno')
# Impose the date range restrictions
sp500_full = sp500_full.loc[
(sp500_full.mthcaldt >= sp500_full.secinfostartdt)
& (sp500_full.mthcaldt <= sp500_full.secinfoenddt)]As the corpus of this code comes from CIQ’s company business description, we need to add company identifier used in the CIQ eco system, GVKEY.
### Add Compustat Identifiers
### - Link with Compustat's GVKEY and IID if need to work with
### fundamental data
### - Linkage is done through crsp.ccmxpf_linktable
ccm=conn.raw_sql("""
select distinct gvkey, liid as iid, lpermno as permno,
linktype, linkprim, linkdt, linkenddt
from crsp_m_ccm.ccmxpf_linktable
where substr(linktype,1,1)='L'
and (linkprim ='C' or linkprim='P')
""", date_cols=['linkdt', 'linkenddt'])
# if linkenddt is missing then set to today date
ccm['linkenddt']=ccm['linkenddt'].fillna(pd.to_datetime('today'))
# Merge the CCM data with S&P500 data
# First just link by matching PERMNO
sp500ccm = pd.merge(sp500_full, ccm, how='left', on=['permno'])
# Then set link date bounds
sp500ccm = sp500ccm.loc[(sp500ccm['mthcaldt']>=sp500ccm['linkdt'])\
&(sp500ccm['mthcaldt']<=sp500ccm['linkenddt'])]
# Rearrange columns for final output
sp500ccm = sp500ccm[['mthcaldt', 'permno', 'issuernm', 'cusip',
'ticker', 'sharetype', 'primaryexch', 'siccd',
'gvkey', 'iid', 'mbrstartdt', 'mbrenddt',
'mthret']]Optional step of linking with CIK in case you want to connect with SEC filings for your analysis:
### Add CIKs and Link with SEC Index Files using CIK
names = conn.raw_sql("""
select gvkey, cik, sic, naics, gind, gsubind
from comp.names
""")
# Merge sp500 constituents table with names table
sp500cik = pd.merge(sp500ccm, names, on='gvkey', how='left')
sp500cik.head()| mthcaldt | permno | issuernm | cusip | ticker | sharetype | primaryexch | siccd | gvkey | iid | mbrstartdt | mbrenddt | mthret | cik | sic | naics | gind | gsubind | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2000-01-31 | 10078 | SUN MICROSYSTEMS INC | 86681010 | SUNW | NS | Q | 3570 | 012136 | 01 | 1992-08-20 | 2010-01-28 | 0.014528 | 0000709519 | 3571 | 334111 | 452020 | 45202010 |
| 1 | 2000-01-31 | 10104 | ORACLE CORP | 68389X10 | ORCL | NS | Q | 7370 | 012142 | 01 | 1989-08-03 | 2026-08-31 | -0.108477 | 0001341439 | 7372 | 513210 | 451030 | 45103020 |
| 2 | 2000-01-31 | 10107 | MICROSOFT CORP | 59491810 | MSFT | NS | Q | 7370 | 012141 | 01 | 1994-06-07 | 2026-08-31 | -0.161670 | 0000789019 | 7372 | 513210 | 451030 | 45103020 |
| 3 | 2000-01-31 | 10138 | T ROWE PRICE ASSOC INC | 74147710 | TROW | NS | Q | 6210 | 012138 | 01 | 1999-10-13 | 2026-08-31 | 0.052454 | 0001113169 | 6282 | 523940 | 402030 | 40203010 |
| 4 | 2000-01-31 | 10145 | HONEYWELL INTERNATIONAL INC | 43851610 | HON | NS | N | 3714 | 001300 | 01 | 1925-12-31 | 2026-08-31 | -0.167931 | 0000773840 | 9997 | 999977 | 201050 | 20105010 |
part 2: read in business description
S&P offers various databases under its umbrella, including most commonly used Compustat (COMP) and Capital IQ (CIQ). This block of code reads in the long format of business description from CIQ and short version from COMP. We will be using the long version as corpus for this project.
### Extract business description
# short description - "busdesc" from comp.company
compbus = conn.raw_sql("""
select gvkey, conm, cik, busdesc from comp.company
""")
# long description - "businessdescription" from CIQ
# link with GVKEY using CompanyID
ciqbus = conn.raw_sql("""
select a.*, b.gvkey, b.startdate, b.enddate
from ciq.ciqbusinessdescription as a,
ciq.wrds_gvkey as b
where a.companyid = b.companyid
""", date_cols = ['startdate', 'enddate'])Occasionally, a GVKEY can be mapped with multiple CompanyIDs (identifiers used in CIQ system). Here is one example:
# GVKEY 003413 maps to more than one CIQ CompanyID
ciqbus.loc[ciqbus.gvkey == '003413']| companyid | businessdescription | gvkey | startdate | enddate | |
|---|---|---|---|---|---|
| 6755 | 263295.0 | Consolidated Edison, Inc., through its subsidi... | 003413 | 1998-01-02 | NaT |
| 43234 | 6020983.0 | Consolidated Edison Company of New York, Inc. ... | 003413 | NaT | 1998-01-01 |
To avoid double counting, for a given GVKEY, if more than one companyid is mapped, I pick the larger companyid as it is probably a more recent link. This will yield a one-to-one mapping between GVKEY and CompanyID.
# Choose record with max CompanyID
maxid = ciqbus.groupby('gvkey')['companyid'].max().reset_index()
ciqbus_nodup = pd.merge(ciqbus, maxid, how = 'inner',
on = ['gvkey', 'companyid'])
ciqbus_nodup.loc[ciqbus_nodup.gvkey == '003413']| companyid | businessdescription | gvkey | startdate | enddate | |
|---|---|---|---|---|---|
| 39989 | 6020983.0 | Consolidated Edison Company of New York, Inc. ... | 003413 | NaT | 1998-01-01 |
Let’s inspect the short and long version of business description of the same company, using AAI Corporation as an example.
### Merge business description from COMP and CIQ
# join by GVKEY
busdesc = pd.merge(compbus, ciqbus_nodup, how = 'inner', on = 'gvkey')
# Print one short and long version of the business description
# for inspection
# AAI Corporation (GVKEY 001002); selected by GVKEY,
# as row order can change
aai = busdesc.loc[busdesc.gvkey == '001002'].iloc[0]
print('\nShort business description from COMP:\n', aai.busdesc )
print('\nLong business description from CIQ:\n', aai.businessdescription )
Short business description from COMP:
Textron Systems Corporation designs, develops, manufactures, and supports aerospace and defense technologies. It offers unmanned aircraft systems, micro air vehicles, remote video terminals, ground control stations, and portable ground control stations.
Long business description from CIQ:
Textron Systems Corporation designs, develops, manufactures, and supports aerospace and defense technologies. It offers unmanned aircraft systems, micro air vehicles, remote video terminals, ground control stations, and portable ground control stations. The company also designs and develops training solutions, such as sonar, satellite, and aircraft trainers. In addition, it offers simulation-based maintenance training devices, modular simulated aircraft maintenance trainers, sentry airborne warning and control systems, joint surveillance target attack radar systems, electronic warfare trainers, embedded on-board trainers, and air defense trainers. Further, the company provides flight line, depot, and product line electronic test equipment; and acoustic detection systems, aviation ground support equipment, and armament and mechanical systems. Furthermore, it offers operations, maintenance, logistics, and supply chain services; and engineering and modification, training and simulation, and depot support services. The company offers its products for intelligence gathering, surveillance, target acquisition, reconnaissance, observation, and battle damage assessment applications. Textron Systems Corporation has strategic partnership with Kaman. Textron Systems Corporation was founded in 1950 and is based in Hunt Valley, Maryland. Textron Systems Corporation operates as a subsidiary of Textron Inc.
part 3: clean and prepare corpus
This is a standard cleaning process that can be applied to other text bodies, user can modify the procedure to suite specific textual cleaning need. It does the following common cleaning tasks:
- convert text to lower case
- keep only alphabetic characters (remove numbers and special characters)
- remove stop words
- form bigrams
- lemmatize words
Let’s use companies in the S&P500 Index as of 2020 as our universe:
# Use SP500 Companies in 2020 as universe
univ = sp500cik.loc[sp500cik.mthcaldt=='2020-12-31'][
['mthcaldt', 'permno', 'issuernm', 'cusip', 'gvkey', 'iid',
'cik', 'ticker', 'sic', 'naics']]
# Merge to get business description
sp500busdesc = pd.merge(univ, busdesc[['gvkey', 'companyid', 'busdesc',
'businessdescription']],
how = 'inner', on = ['gvkey'])
sp500busdesc.head()| mthcaldt | permno | issuernm | cusip | gvkey | iid | cik | ticker | sic | naics | companyid | busdesc | businessdescription | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2020-12-31 | 10104 | ORACLE CORP | 68389X10 | 012142 | 01 | 0001341439 | ORCL | 7372 | 513210 | 22247.0 | Oracle Corporation offers products and service... | Oracle Corporation offers products and service... |
| 1 | 2020-12-31 | 10107 | MICROSOFT CORP | 59491810 | 012141 | 01 | 0000789019 | MSFT | 7372 | 513210 | 21835.0 | Microsoft Corporation, a technology company, d... | Microsoft Corporation, a technology company, d... |
| 2 | 2020-12-31 | 10138 | T ROWE PRICE GROUP INC | 74144T10 | 012138 | 01 | 0001113169 | TROW | 6282 | 523940 | 23217.0 | T. Rowe Price Group, Inc. is a publicly owned ... | T. Rowe Price Group, Inc. is a publicly owned ... |
| 3 | 2020-12-31 | 10145 | HONEYWELL INTERNATIONAL INC | 43851610 | 001300 | 01 | 0000773840 | HON | 9997 | 999977 | 1340740.0 | Honeywell International Inc. engages in the in... | Honeywell International Inc. engages in the in... |
| 4 | 2020-12-31 | 10516 | ARCHER DANIELS MIDLAND CO | 03948310 | 001722 | 01 | 0000007084 | ADM | 2070 | 31122 | 251704.0 | Archer-Daniels-Midland Company provides human ... | Archer-Daniels-Midland Company provides human ... |
Start Cleaning. First convert the busdesc (short) and businessdescription (long) column into lists.
# Convert both short and long busindess description
# (busdesc, businessdescription) to list
busdesc_lst = sp500busdesc.busdesc.tolist()
busdesclong_lst = sp500busdesc.businessdescription.tolist()
# For this exercise, I use long business description as corpus
text_feed = busdesclong_lst
len(busdesc_lst), len(busdesclong_lst)(501, 501)
Below are several functions for textual cleaning tasks:
- text_clean() - convert all texts to lower case and remove non-letter components
# convert all text to lower case and remove non alphabetic components
def text_clean(raw_text):
# 1. convert to lower case
txt1 = raw_text.lower().strip()
# 2. remove non-letters
txt2 = re.sub("[^a-zA-Z]", " ", txt1).split(' ')
return (txt2)- sent_to_words() - convert sentences to list
# convert sentences to list
def sent_to_words(sentences):
for sent in sentences:
yield(text_clean(sent))- remove_stopwords()- take out the stop words defined by NLTK
# Remove Stop Words
def remove_stopwords(texts):
nostop = [[word for word in doc if word not in stop_words]
for doc in texts]
return nostop- make_bigrams()- forms bigram if needed
# form bigrams
def make_bigrams(texts):
#bigram = gensim.models.Phrases(texts, min_count=1, threshold=10)
bigram = gensim.models.Phrases(texts)
bigram_mod = gensim.models.phrases.Phraser(bigram)
return [bigram_mod[doc] for doc in texts]- lemmatization_str()- lemmatize the words and convert the output to list of strings
# lemmatization with output of list of strings to fit into countvectorizer
def lemmatization_str(texts):
texts_out = []
for sent in texts:
doc = nlp(" ".join(sent))
texts_out.append(' '.join([token.lemma_ for token in doc]))
return texts_outNow let’s apply these functions to the input list:
### Apply the raw text into the cleaning process
# Input text: text_feed
# Output text: text_prep
print('Start cleaning ... ')
print('Remove non-alphabetic characters and convert to lower case ... ')
text_nonum = list(sent_to_words(text_feed))
print('Remove stop words ... ')
text_nostops = remove_stopwords(text_nonum)
print('Form bigrams ... ')
text_bigrams = make_bigrams(text_nostops)
print('Lemmatize words ... ')
text_lemmatized = lemmatization_str(text_bigrams)
print('Cleaning done. text_prep is the cleaned output.')
text_prep = text_lemmatized
len(text_prep)Start cleaning ...
Remove non-alphabetic characters and convert to lower case ...
Remove stop words ...
Form bigrams ...
Lemmatize words ...
Cleaning done. text_prep is the cleaned output.
501
The cleaning process above should convert the input text to all lower case, remove non letters components, form bigrams and lemmatize the words. Below is a comparison of input texts versus the cleaned text, using Ralph Lauren’s business description as an example:
Raw input text:
# Raw input text: Ralph Lauren's long business description
rl_pos = sp500busdesc.loc[sp500busdesc.ticker == 'RL'].index.values[0]
print(text_feed[rl_pos])Ralph Lauren Corporation designs, markets, and distributes lifestyle products in North America, Europe, Asia, and internationally. The company offers apparel, including a range of men’s, women’s, and children’s clothing; footwear and accessories, which comprise casual shoes, dress shoes, boots, sneakers, sandals, eyewear, watches, fashion and fine jewelry, scarves, hats, gloves, and umbrellas, as well as leather goods comprising handbags, luggage, small leather goods, and belts; home products, such as bed and bath lines, furniture, fabric and wall coverings, lighting, dining, floor coverings, decorative accessories, and giftware; and fragrances. The company sells apparel and accessories under the Ralph Lauren Collection, Ralph Lauren Purple Label, Polo Ralph Lauren, Double RL, Lauren Ralph Lauren, RLX Ralph Lauren, Polo Ralph Lauren Children, and Chaps brands; women's fragrances under the Ralph Lauren Collection, Woman by Ralph Lauren, Romance Collection, and Ralph Collection brand names; and men's fragrances under the Ralph's Club, Purple Label, Polo Blue, Polo Red, Polo Green, Polo Black, Polo 67, Safari, Polo Sport, and Big Pony Men's brand names. Its restaurant collection includes The Polo Bar in New York City; RL Restaurant in Chicago; Ralph's in Paris; The Bar at Ralph Lauren located in Milan; Ralph's Bar located in Chengdu, China; and Ralph's Coffee concept. The company sells its products to department stores, specialty stores, and golf and pro shops, as well as directly to consumers through its retail stores, concession-based shop-within-shops, and its digital commerce sites. It operates retail stores and concession-based shop-within-shops; and operates Ralph Lauren stores and shops through licensing partners. Ralph Lauren Corporation was founded in 1967 and is based in New York, New York.
Cleaned output text:
# Cleaned output text
print(text_prep[rl_pos])ralph_lauren corporation design market distribute lifestyle product north_america europe asia internationally company_offers apparel include range man woman child clothing footwear accessory comprise casual shoe dress shoe boot sneaker sandal eyewear watch fashion fine jewelry scarf hat glove umbrella well leather good comprise handbag luggage small leather good belt home product bed bath line furniture fabric wall covering lighting dining floor covering decorative accessory giftware fragrance company sell apparel accessory ralph_lauren collection ralph_lauren purple label polo ralph_lauren double rl lauren ralph_lauren rlx ralph_lauren polo ralph_lauren child chap brand woman fragrance ralph_lauren collection woman ralph_lauren romance collection ralph collection brand_name man fragrance ralph club purple label polo blue polo red polo green polo black polo safari polo sport big pony man brand_names restaurant collection include polo bar new_york city rl restaurant chicago ralph paris bar ralph_lauren locate milan ralph bar locate chengdu china ralph coffee concept company sells_product department_stores specialty store golf pro shop well directly_consumer retail_store concession base shop within shop digital commerce_sites operate retail_store concession base shop within shop operate ralph_lauren store shop license partner ralph_lauren corporation_founde base new_york new_york
part 4: form bag of words
This block of code uses the vectorization tools offered in scikit-learn, and forms bag-of-words (BOW) for each corpus.
# Vectorize the vocab
vect = CountVectorizer()
# Form vector for corpus
vect_array = vect.fit_transform(text_prep).toarray()
### Create BOW Dictionary
intext = text_prep
# Create an empty dictionary to store the similarity score
bow_dict = {}
for row_id, text in enumerate(intext):
# loop through all records
frequency = defaultdict(int)
for token in text.split(' '):
#store tokens and count in frequency dict
frequency[token] += 1
# store frequency dict to bow_dict
bow_dict[row_id] = frequency
# Store BOW into df
bow_df = pd.DataFrame()
for item, lst in list(bow_dict.items()):
_df = pd.DataFrame(lst.keys(), lst.values())
_df['comp_id'] = item
bow_df = pd.concat([bow_df, _df])
bow_df = bow_df.reset_index().rename(columns={'index': 'freq',
0: 'word'})
# slice out SP500 company ids for linking
sp500id = sp500busdesc[['permno', 'gvkey', 'iid', 'ticker']]\
.reset_index().rename(columns={'index': 'comp_id'})
# Add PERMNO GVKEY IID info
bow_df = pd.merge(bow_df, sp500id, how = 'left', on = ['comp_id'])The BOW dataframe contains the following information:
- word: focal word
- freq: number of times a word appears in that document
- company identifiers
Below is a subset of BOW for the company Apple. For instance, the word “apple” appears 15 times, and the word “service” appears 6 times in Apple’s business description obtained from CIQ.
# Most frequent words in Apple's business description
bow_df.loc[bow_df.ticker == 'AAPL'][['word', 'freq', 'permno',
'gvkey', 'iid', 'ticker']]\
.sort_values(by='freq', ascending=False).head(10)| word | freq | permno | gvkey | iid | ticker | |
|---|---|---|---|---|---|---|
| 5445 | apple | 15 | 14593 | 001690 | 01 | AAPL |
| 5481 | service | 6 | 14593 | 001690 | 01 | AAPL |
| 5476 | third_party | 4 | 14593 | 001690 | 01 | AAPL |
| 5446 | inc | 4 | 14593 | 001690 | 01 | AAPL |
| 5455 | accessory | 3 | 14593 | 001690 | 01 | AAPL |
| 5487 | store | 3 | 14593 | 001690 | 01 | AAPL |
| 5459 | line | 3 | 14593 | 001690 | 01 | AAPL |
| 5474 | well | 3 | 14593 | 001690 | 01 | AAPL |
| 5452 | computer | 3 | 14593 | 001690 | 01 | AAPL |
| 5504 | subscription | 3 | 14593 | 001690 | 01 | AAPL |
And out of curiosity, I summarize below the top 10 most frequently appeared words in all S&P500 companies’ business description. Researchers might want to customize the generic stop words defined by NLTK to include certain content specific stop words, such as “company” or “inc” in this case.
### Examine word distribution across all company business
### descriptions (documents)
totalcnt = bow_df.groupby('word')['freq'].sum().reset_index()\
.sort_values(by=['freq'], ascending = False)
# Report top 10 most frequent words
totalcnt.head(10)| word | freq | |
|---|---|---|
| 7248 | service | 1262 |
| 6354 | product | 1174 |
| 1689 | company | 932 |
| 7489 | solution | 705 |
| 4007 | inc | 666 |
| 7894 | system | 488 |
| 4012 | include | 484 |
| 8794 | well | 445 |
| 7190 | segment | 436 |
| 6420 | provide | 411 |
part 5: similarity based on Bow
This section relies on the vectors created based on BOW and calculates the pairwise similarity score among companies in this universe. Before diving into the full sample, let’s inspect this approach using individual companies: Disney, Microsoft, Wells Fargo and Citi.
### Illustrating using several companies:
# Find position of the document in the list for each company
dis_pos = sp500busdesc.loc[sp500busdesc.ticker =='DIS'].index.values[0]
msft_pos = sp500busdesc.loc[sp500busdesc.ticker =='MSFT'].index.values[0]
wfc_pos = sp500busdesc.loc[sp500busdesc.ticker =='WFC'].index.values[0]
c_pos = sp500busdesc.loc[sp500busdesc.ticker =='C'].index.values[0]
### Report pairwise similarity score among the select firms
# use cosine_similarity function
print('Pairwise similarity score:')
def bow_pair(i, j):
return cosine_similarity([vect_array[i]], [vect_array[j]])[0, 0]
print('Microsoft vs Disney:',
"{:.2f}".format(bow_pair(dis_pos, msft_pos)))
print('Disney vs Wells Fargo:',
"{:.2f}".format(bow_pair(dis_pos, wfc_pos)))
print('Wells Fargo vs Citi:', "{:.2f}".format(bow_pair(c_pos, wfc_pos)))Pairwise similarity score:
Microsoft vs Disney: 0.15
Disney vs Wells Fargo: 0.14
Wells Fargo vs Citi: 0.61
And the pairwise similarity score among these companies are reported below. As anticipated, the two financial institutions, Wells Fargo and Citi, display relatively high similarity score based on BOW formed from their respective business description.
Now let’s apply the similarity score calculation on the entire sample:
### Similarity score across all companies
# list containing all tickers
ticker_lst = sp500busdesc.ticker.tolist()
# empty list to store the bow similarity scores
bow_score = []
# input vector
invect = vect_array
# function to loop through company pair (i,j)
def bow_simscore(inlist):
for i in range(len(invect)):
for j in range(len(invect)):
_tmp = {'comp1': ticker_lst[i], # ticker for company i
'comp2': ticker_lst[j], # ticker for company j
# similarity score for pair (i,j)
'bow_score': cosine_similarity([invect[i]],
[invect[j]])[0, 0]}
bow_score.append(_tmp)
return bow_score
### Apply over all companies
print ('Calculating pairwise similarity score among all firms ... ')
bow_score = bow_simscore(invect)
print ('Done.')Calculating pairwise similarity score among all firms ...
Done.
Tidy up the output and sort the results in descending order to find the most similar company for each focal company.
### Tidying up the output
# Convert to dataframe
bow_score_df = pd.DataFrame(bow_score)
# take out comparison against itself (i,i), which is always 1 by nature
# then drop the (i,j) vs (j, i) duplicates
bow_score_df = bow_score_df.loc[bow_score_df.comp1 != bow_score_df.comp2]\
.drop_duplicates()
# Sort by comp1 then descending based on bow_score
# to find most similar companies
bow_score_df = bow_score_df.sort_values(by=['comp1', 'bow_score'],
ascending = [True, False])
bow_score_df.head()| comp1 | comp2 | bow_score | |
|---|---|---|---|
| 210744 | A | ROP | 0.420425 |
| 210683 | A | TMO | 0.411720 |
| 210792 | A | WAT | 0.361186 |
| 210488 | A | KEYS | 0.360261 |
| 210638 | A | DHR | 0.356645 |
Then for each of the S&P 500 companies, find its most similar peer based on BOW similarity score:
# For each company, keep the company with the highest cosine score
similar_comp_bow = bow_score_df.loc[
bow_score_df.groupby('comp1')['bow_score'].idxmax()]
# merge back company identifier info
_ids = sp500busdesc[['issuernm', 'ticker', 'sic', 'naics']]
similar_comp_bow = pd.merge(similar_comp_bow, _ids, how = 'left',
left_on = 'comp1', right_on = 'ticker')
similar_comp_bow = pd.merge(similar_comp_bow, _ids, how = 'left',
left_on = 'comp2', right_on = 'ticker')
similar_comp_bow = similar_comp_bow.rename(columns={
'comp2': 'comp_bow',
'issuernm_x':'issuernm1', 'issuernm_y':'issuernm_bow',
'sic_x': 'sic1', 'sic_y':'sic_bow',
'naics_x':'naics1', 'naics_y':'naics_bow'})\
.drop(columns=['ticker_x', 'ticker_y'])
similar_comp_bow = similar_comp_bow[
['comp1', 'issuernm1', 'sic1', 'naics1', 'comp_bow',
'issuernm_bow', 'sic_bow', 'naics_bow', 'bow_score']]\
.drop_duplicates()I report below the most similar company for some of the familiar tickers. Based on BOW pairwise similarity score, some company has clear peers (e.g. GM vs F and or V vs MA), others report most similar peer somehow different from their dominant industry (e.g. FB vs TWTR or AMZN vs ADBE). The latter case often happens among companies that expand in multiple fields of operation.
# Lookup most similar company by ticker
company_ticker = ['NCLH', 'MSFT', 'WFC', 'XOM',
'AMZN', 'C', 'DIS', 'V', 'JNJ',
'WMT', 'UAL', 'FB', 'CBOE',
'GM', 'ABT', 'MMM', 'BMY']
sample_bow = similar_comp_bow.loc[
similar_comp_bow.comp1.isin(company_ticker)]
sample_bow.style.format({'bow_score': '{:.2f}'})| comp1 | issuernm1 | sic1 | naics1 | comp_bow | issuernm_bow | sic_bow | naics_bow | bow_score | |
|---|---|---|---|---|---|---|---|---|---|
| 7 | ABT | ABBOTT LABORATORIES | 3845 | 334510 | BDX | BECTON DICKINSON & CO | 3841 | 339112 | 0.45 |
| 35 | AMZN | AMAZON COM INC | 5961 | 455219 | ADBE | ADOBE INC | 7370 | 518210 | 0.39 |
| 68 | BMY | BRISTOL MYERS SQUIBB CO | 2834 | 325412 | AMGN | AMGEN INC | 2836 | 325414 | 0.48 |
| 74 | C | CITIGROUP INC | 6199 | 522110 | USB | U S BANCORP DEL | 6020 | 522110 | 0.64 |
| 80 | CBOE | C B O E GLOBAL MARKETS INC | 6200 | 523210 | CME | C M E GROUP INC | 6200 | 523210 | 0.42 |
| 133 | DIS | DISNEY WALT CO | 4888 | 516120 | FOXA | FOX CORP | 4833 | 516120 | 0.38 |
| 175 | FB | FACEBOOK INC | 7370 | 519290 | TWTR | TWITTER INC | 7370 | 519130 | 0.25 |
| 200 | GM | GENERAL MOTORS CO | 3711 | 336110 | F | FORD MOTOR CO DEL | 3711 | 336110 | 0.52 |
| 253 | JNJ | JOHNSON & JOHNSON | 2834 | 325412 | ZBH | ZIMMER BIOMET HOLDINGS INC | 3842 | 339113 | 0.41 |
| 306 | MMM | 3M CO | 9997 | 999977 | NWL | NEWELL BRANDS INC | 3630 | 3352 | 0.46 |
| 315 | MSFT | MICROSOFT CORP | 7372 | 513210 | FFIV | F 5 NETWORKS INC | 7373 | 541512 | 0.45 |
| 321 | NCLH | NORWEGIAN CRUISE LINE HLDGS LTD | 4400 | 483112 | CCL | CARNIVAL CORP | 4400 | 483112 | 0.54 |
| 449 | UAL | UNITED AIRLINES HOLDINGS INC | 4512 | 481111 | EXPE | EXPEDIA GROUP INC | 4700 | 561510 | 0.37 |
| 459 | V | VISA INC | 6099 | 522320 | MA | MASTERCARD INC | 7389 | 522320 | 0.45 |
| 480 | WFC | WELLS FARGO & CO NEW | 6020 | 522110 | USB | U S BANCORP DEL | 6020 | 522110 | 0.66 |
| 485 | WMT | WALMART INC | 5331 | 455219 | TGT | TARGET CORP | 5331 | 455219 | 0.35 |
| 494 | XOM | EXXON MOBIL CORP | 2911 | 324110 | PSX | PHILLIPS 66 | 2911 | 324110 | 0.52 |
part 6: similarity based on doc2vec
This is an alternative approach to calculate similarity score, and it relies on Doc2Vec as part of the Gensim package. Please note that the sample size of this toy project is probably too small but I nevertheless include it here for demonstration purpose.
# Uses text_nonum as input as doc2vec approach doesn't require
# cleaning of stop words or lemmatization
# Tagging documents
tagged_text = [TaggedDocument(words=_d, tags=[str(i)])
for i, _d in enumerate(text_nonum)]
# Now train the data
#cores = multiprocessing.cpu_count()
assert gensim.models.doc2vec.FAST_VERSION > -1 # accelerate the process
# Build model
# fixed seed and a single worker make the results reproducible
model = Doc2Vec(dm=1, vector_size=40, min_count = 1, seed=2021, workers=1)
model.build_vocab(tagged_text)
for epoch in range(10):
model.train(tagged_text, epochs=model.epochs,
total_examples=model.corpus_count)
print("Epoch #{} is trained.".format(epoch+1))Epoch #1 is trained.
Epoch #2 is trained.
Epoch #3 is trained.
Epoch #4 is trained.
Epoch #5 is trained.
Epoch #6 is trained.
Epoch #7 is trained.
Epoch #8 is trained.
Epoch #9 is trained.
Epoch #10 is trained.
Again let’s first inspect the pairwise similarity score among the four individual companies we examined before.
### Report pairwise similarity score among the select firms
# use cosine_similarity function
print('Pairwise similarity score:')
def d2v_pair(i, j):
return model.wv.n_similarity(text_nostops[i], text_nostops[j])
print('Microsoft vs Disney:',
"{:.2f}".format(d2v_pair(msft_pos, dis_pos)))
print('Disney vs Wells Fargo:',
"{:.2f}".format(d2v_pair(dis_pos, wfc_pos)))
print('Wells Fargo vs Citi:', "{:.2f}".format(d2v_pair(wfc_pos, c_pos)))Pairwise similarity score:
Microsoft vs Disney: 0.79
Disney vs Wells Fargo: 0.63
Wells Fargo vs Citi: 0.96
Let’s now expand the analysis to the entire sample of all 500 companies:
# Calculate d2v based similarity score for the entire sample
d2v_score = []
def d2v_simscore(inlist):
for i in range(len(inlist)):
for j in range(len(inlist)):
_tmp = {'comp1': ticker_lst[i],
'comp2': ticker_lst[j],
'd2v_score': model.wv.n_similarity(inlist[i],
inlist[j]) }
d2v_score.append(_tmp)
return d2v_score
# apply over all companies
d2v_score = d2v_simscore(text_nostops)
# Convert to dataframe
d2v_score_df = pd.DataFrame(d2v_score)
# take out comparison against itself (i,i)
# then drop the (i,j) vs (j, i) duplicates
d2v_score_df = d2v_score_df.loc[d2v_score_df.comp1 != d2v_score_df.comp2]\
.drop_duplicates()
# Sort by comp1 then descending based on bow_score
d2v_score_df = d2v_score_df.sort_values(by=['comp1', 'd2v_score'],
ascending = [True, False])And then pick out the most similar company:
# For each company, keep the most similar company
similar_comp_d2v = d2v_score_df.loc[
d2v_score_df.groupby('comp1')['d2v_score'].idxmax()]
similar_comp_d2v = pd.merge(similar_comp_d2v, _ids, how = 'left',
left_on = 'comp1', right_on = 'ticker')
similar_comp_d2v = pd.merge(similar_comp_d2v, _ids, how = 'left',
left_on = 'comp2', right_on = 'ticker')
similar_comp_d2v = similar_comp_d2v.rename(columns={
'comp2': 'comp_d2v',
'issuernm_x':'issuernm1', 'issuernm_y':'issuernm_d2v',
'sic_x': 'sic1', 'sic_y':'sic_d2v',
'naics_x':'naics1', 'naics_y':'naics_d2v'})\
.drop(columns=['ticker_x', 'ticker_y'])
similar_comp_d2v = similar_comp_d2v[
['comp1', 'issuernm1', 'sic1', 'naics1', 'comp_d2v',
'issuernm_d2v', 'sic_d2v', 'naics_d2v', 'd2v_score']]\
.drop_duplicates()Again, let’s inspect the same group of companies before:
sample_d2v = similar_comp_d2v.loc[
similar_comp_d2v.comp1.isin(company_ticker)]
sample_d2v.style.format({'d2v_score': '{:.2f}'})| comp1 | issuernm1 | sic1 | naics1 | comp_d2v | issuernm_d2v | sic_d2v | naics_d2v | d2v_score | |
|---|---|---|---|---|---|---|---|---|---|
| 7 | ABT | ABBOTT LABORATORIES | 3845 | 334510 | BDX | BECTON DICKINSON & CO | 3841 | 339112 | 0.95 |
| 35 | AMZN | AMAZON COM INC | 5961 | 455219 | ADBE | ADOBE INC | 7370 | 518210 | 0.94 |
| 68 | BMY | BRISTOL MYERS SQUIBB CO | 2834 | 325412 | ABBV | ABBVIE INC | 2836 | 325414 | 0.96 |
| 74 | C | CITIGROUP INC | 6199 | 522110 | JPM | JPMORGAN CHASE & CO | 6020 | 522110 | 0.97 |
| 80 | CBOE | C B O E GLOBAL MARKETS INC | 6200 | 523210 | ICE | INTERCONTINENTALEXCHANGE GRP INC | 6200 | 523999 | 0.93 |
| 132 | DIS | DISNEY WALT CO | 4888 | 516120 | VIAC | VIACOMCBS INC | 4833 | 516120 | 0.96 |
| 174 | FB | FACEBOOK INC | 7370 | 519290 | EA | ELECTRONIC ARTS INC | 7372 | 513210 | 0.91 |
| 199 | GM | GENERAL MOTORS CO | 3711 | 336110 | F | FORD MOTOR CO DEL | 3711 | 336110 | 0.97 |
| 252 | JNJ | JOHNSON & JOHNSON | 2834 | 325412 | SYK | STRYKER CORP | 3841 | 339112 | 0.94 |
| 306 | MMM | 3M CO | 9997 | 999977 | ITW | ILLINOIS TOOL WORKS INC | 3560 | 33399 | 0.96 |
| 315 | MSFT | MICROSOFT CORP | 7372 | 513210 | CSCO | CISCO SYSTEMS INC | 3576 | 334118 | 0.94 |
| 321 | NCLH | NORWEGIAN CRUISE LINE HLDGS LTD | 4400 | 483112 | CCL | CARNIVAL CORP | 4400 | 483112 | 0.94 |
| 449 | UAL | UNITED AIRLINES HOLDINGS INC | 4512 | 481111 | BKNG | BOOKING HOLDINGS INC | 4700 | 561510 | 0.94 |
| 459 | V | VISA INC | 6099 | 522320 | MA | MASTERCARD INC | 7389 | 522320 | 0.96 |
| 480 | WFC | WELLS FARGO & CO NEW | 6020 | 522110 | KEY | KEYCORP NEW | 6020 | 522110 | 0.98 |
| 485 | WMT | WALMART INC | 5331 | 455219 | NKE | NIKE INC | 3021 | 316210 | 0.94 |
| 494 | XOM | EXXON MOBIL CORP | 2911 | 324110 | CVX | CHEVRON CORP NEW | 2911 | 324110 | 0.96 |
And finally, let’s put the BOW and Doc2Vec results side by side for comparison. For some instances, both approaches yield the same result for the focal company (e.g. ABT vs BDX, GM vs F, NCLH vs CCL, V vs MA), others return different candidates but generally belong to the same industry.
# BOW and Doc2Vec results side by side
compare = pd.merge(sample_bow[['comp1', 'issuernm1',
'comp_bow', 'bow_score']],
sample_d2v[['comp1', 'comp_d2v', 'd2v_score']],
how='inner', on='comp1')
compare.style.format({'bow_score': '{:.2f}', 'd2v_score': '{:.2f}'})| comp1 | issuernm1 | comp_bow | bow_score | comp_d2v | d2v_score | |
|---|---|---|---|---|---|---|
| 0 | ABT | ABBOTT LABORATORIES | BDX | 0.45 | BDX | 0.95 |
| 1 | AMZN | AMAZON COM INC | ADBE | 0.39 | ADBE | 0.94 |
| 2 | BMY | BRISTOL MYERS SQUIBB CO | AMGN | 0.48 | ABBV | 0.96 |
| 3 | C | CITIGROUP INC | USB | 0.64 | JPM | 0.97 |
| 4 | CBOE | C B O E GLOBAL MARKETS INC | CME | 0.42 | ICE | 0.93 |
| 5 | DIS | DISNEY WALT CO | FOXA | 0.38 | VIAC | 0.96 |
| 6 | FB | FACEBOOK INC | TWTR | 0.25 | EA | 0.91 |
| 7 | GM | GENERAL MOTORS CO | F | 0.52 | F | 0.97 |
| 8 | JNJ | JOHNSON & JOHNSON | ZBH | 0.41 | SYK | 0.94 |
| 9 | MMM | 3M CO | NWL | 0.46 | ITW | 0.96 |
| 10 | MSFT | MICROSOFT CORP | FFIV | 0.45 | CSCO | 0.94 |
| 11 | NCLH | NORWEGIAN CRUISE LINE HLDGS LTD | CCL | 0.54 | CCL | 0.94 |
| 12 | UAL | UNITED AIRLINES HOLDINGS INC | EXPE | 0.37 | BKNG | 0.94 |
| 13 | V | VISA INC | MA | 0.45 | MA | 0.96 |
| 14 | WFC | WELLS FARGO & CO NEW | USB | 0.66 | KEY | 0.98 |
| 15 | WMT | WALMART INC | TGT | 0.35 | NKE | 0.94 |
| 16 | XOM | EXXON MOBIL CORP | PSX | 0.52 | CVX | 0.96 |