#!/usr/bin/python3 # # Author: Marc Bevand — @zorinaq import sys import matplotlib.pyplot as plt import matplotlib.ticker as ticker import numpy as np import scipy.stats from scipy.optimize import curve_fit maxage = 100 # Age-stratified IFR estimates for COVID-19 ifrs_covid = [ # Calculated from Spanish ENE-COVID study # (see calc_ifr.py) ('ENE-COVID', { (0,9): 0.003, (10,19): 0.004, (20,29): 0.015, (30,39): 0.030, (40,49): 0.064, (50,59): 0.213, (60,69): 0.718, (70,79): 2.384, (80,89): 8.466, (90,maxage): 12.497, }), # US CDC estimate as of 10 Sep 2020 # https://www.cdc.gov/coronavirus/2019-ncov/hcp/planning-scenarios.html # (table 1) ('US CDC', { (0,19): 0.003, (20,49): 0.02, (50,69): 0.5, (70,maxage): 5.4, }), # Verity et al. # https://www.thelancet.com/journals/laninf/article/PIIS1473-3099(20)30243-7/fulltext # (table 1) ('Verity', { (0,9): 0.00161, (10,19): 0.00695, (20,29): 0.0309, (30,39): 0.0844, (40,49): 0.161, (50,59): 0.595, (60,69): 1.93, (70,79): 4.28, (80,maxage): 7.80, }), # Levin et al. # https://www.medrxiv.org/content/10.1101/2020.07.23.20160895v7 # (table 3) ('Levin', { (0,34): 0.004, (35,44): 0.068, (45,54): 0.23, (55,64): 0.75, (65,74): 2.5, (75,84): 8.5, (85,maxage): 28.3, }), # Salje et al.: Estimating the burden of SARS-CoV-2 in France # https://science.sciencemag.org/content/369/6500/208 # Supplementary Materials: # https://science.sciencemag.org/content/sci/suppl/2020/05/12/science.abc3517.DC1/abc3517_Salje_SM_rev2.pdf # (table S2) ('Salje', { (0,19): 0.001, (20,29): 0.005, (30,39): 0.02, (40,49): 0.05, (50,59): 0.2, (60,69): 0.7, (70,79): 1.9, (80,maxage): 8.3, }), # Perez-Saez et al. # https://www.thelancet.com/journals/laninf/article/PIIS1473-3099(20)30584-3/fulltext ('Perez-Saez', { (5,9): 0.0016, (10,19): 0.00032, (20,49): 0.0092, (50,64): 0.14, (65,maxage): 5.6, }), # Picon et al. # https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7493765/ # (table 2) ('Picon', { (20,39): 0.08, (40,59): 0.24, (60,maxage): 4.63, }), # Poletti et al. # https://www.eurosurveillance.org/content/10.2807/1560-7917.ES.2020.25.31.2001383 # (table 1, column "Any time") ('Poletti', { (0,19): 0, (20,49): 0, (50,59): 0.46, (60,69): 1.42, (70,79): 6.87, (80,maxage): 18.35, }), # Gudbjartsson et al.: Humoral Immune Response to SARS-CoV-2 in Iceland # https://www.nejm.org/doi/full/10.1056/NEJMoa2026116 # Supplementary Appendix 1 # https://www.nejm.org/doi/suppl/10.1056/NEJMoa2026116/suppl_file/nejmoa2026116_appendix_1.pdf # (table S7) ('Gudbjartsson', { (0,70): 0.1, (71,80): 2.4, (81,maxage): 11.2, }), # Public Health Agency of Sweden # https://www.folkhalsomyndigheten.se/contentassets/53c0dc391be54f5d959ead9131edb771/infection-fatality-rate-covid-19-stockholm-technical-report.pdf # (table B.1) ('PHAS', { (0,49): 0.01, (50,59): 0.27, (60,69): 0.45, (70,79): 1.92, (80,89): 7.20, (90,maxage): 16.21, }), # O’Driscoll et al.: Age-specific mortality and immunity patterns of SARS-CoV-2 # https://www.nature.com/articles/s41586-020-2918-0 # Supplementary information # https://static-content.springer.com/esm/art%3A10.1038%2Fs41586-020-2918-0/MediaObjects/41586_2020_2918_MOESM1_ESM.pdf # (table S3) ('O’Driscoll', { (0,4): 0.003, (5,9): 0.001, (10,14): 0.001, (15,19): 0.003, (20,24): 0.006, (25,29): 0.013, (30,34): 0.024, (35,39): 0.040, (40,44): 0.075, (45,49): 0.121, (50,54): 0.207, (55,59): 0.323, (60,64): 0.456, (65,69): 1.075, (70,74): 1.674, (75,79): 3.203, (80,maxage): 8.292, }), # Ward et al.: Antibody prevalence for SARS-CoV-2 in England following first peak of the pandemic: REACT2 study in 100,000 adults # https://www.medrxiv.org/content/10.1101/2020.08.12.20173690v2 # Supplementary Appendix # https://www.medrxiv.org/highwire/filestream/93745/field_highwire_adjunct_files/0/2020.08.12.20173690-1.docx # (table S2a, column "Based on confirmed COVID-19 deaths") ('REACT2', { (15,44): 0.03, (45,64): 0.52, (65,74): 3.87, (75,maxage): 18.71, }), # Yang et al.: Estimating the infection fatality risk of COVID-19 in New York City during the spring 2020 pandemic wave # https://www.medrxiv.org/content/10.1101/2020.06.27.20141689v2 # (table 1) ('Yang', { (0,24): 0.0097, (25,44): 0.12, (45,64): 0.94, (65,74): 4.87, (75,maxage): 14.17, }), # Molenberghs et al.: Belgian Covid-19 Mortality, Excess Deaths, Number of Deaths per Million, and Infection Fatality Rates # https://www.medrxiv.org/content/10.1101/2020.06.20.20136234v1 # (table 6) ('Molenberghs', { (0,24): 0.0005, (25,44): 0.017, (45,64): 0.21, (65,74): 2.24, (75,84): 4.29, (85,maxage): 11.77, }), ] # In the CDC influenza burden pages (eg. table 1 in # https://www.cdc.gov/flu/about/burden/2018-2019.html), only symptomatic # illnesses are estimated. We must account for asymptomatic ones as well. # # In https://www.cdc.gov/flu/about/keyfacts.htm the CDC implies 55-60% of # illnesses are symptomatic: # # «on average, about 8% of the U.S. population gets sick from flu each season, # with a range of between 3% and 11%, depending on the season. # [...] # The commonly cited 5% to 20% estimate was based on a study that examined both # symptomatic and asymptomatic influenza illness, which means it also looked at # people who may have had the flu but never knew it because they didn’t have # any symptoms. The 3% to 11% range is an estimate of the proportion of people # who have symptomatic flu illness.» # # The CDC thus acknowledges that 55-60% of illnesses are symptomatic: # 3/5 = 60% # 11/20 = 55% # # We use the mid-point, 57.5%, as an estimate to account for both symptomatic # and asymptomatic illnesses. cdc_sympt = .575 # Age-stratified IFR estimates for seasonal influenza ifrs_flu = [ # US CDC 2019-2020 influenza burden # https://www.cdc.gov/flu/about/burden/2019-2020.html ('US CDC 2019-2020', { (0,4): 254/4_291_677 * 100 * cdc_sympt, (5,17): 180/8_214_257 * 100 * cdc_sympt, (18,49): 2_669/15_325_708 * 100 * cdc_sympt, (50,64): 5_133/8_416_702 * 100 * cdc_sympt, (65,maxage): 13_673/1_946_161 * 100 * cdc_sympt, }), # US CDC 2018-2019 influenza burden # https://www.cdc.gov/flu/about/burden/2018-2019.html ('US CDC 2018-2019', { (0,4): 266/3_633_104 * 100 * cdc_sympt, (5,17): 211/7_663_310 * 100 * cdc_sympt, (18,49): 2_450/11_913_203 * 100 * cdc_sympt, (50,64): 5_676/9_238_038 * 100 * cdc_sympt, (65,maxage): 25_555/3_073_227 * 100 * cdc_sympt, }), # US CDC 2017-2018 influenza burden # https://www.cdc.gov/flu/about/burden/2017-2018.htm ('US CDC 2017-2018', { (0,4): 115/3_678_342 * 100 * cdc_sympt, (5,17): 528/7_512_601 * 100 * cdc_sympt, (18,49): 2_803/14_428_065 * 100 * cdc_sympt, (50,64): 6_751/13_237_932 * 100 * cdc_sympt, (65,maxage): 50_903/5_945_690 * 100 * cdc_sympt, }), # US CDC 2016-2017 influenza burden # https://www.cdc.gov/flu/about/burden/2016-2017.html ('US CDC 2016-2017', { (0,4): 126/2_381_218 * 100 * cdc_sympt, (5,17): 125/6_452_110 * 100 * cdc_sympt, (18,49): 1_365/9_292_804 * 100 * cdc_sympt, (50,64): 3_780/7_448_184 * 100 * cdc_sympt, (65,maxage): 32_833/3_646_206 * 100 * cdc_sympt, }), # US CDC 2015-2016 influenza burden # https://www.cdc.gov/flu/about/burden/2015-2016.html ('US CDC 2015-2016', { (0,4): 180/2_195_276 * 100 * cdc_sympt, (5,17): 88/4_140_269 * 100 * cdc_sympt, (18,49): 1_703/9_121_242 * 100 * cdc_sympt, (50,64): 3_277/6_640_358 * 100 * cdc_sympt, (65,maxage): 17_458/1_407_174 * 100 * cdc_sympt, }), # US CDC 2014-2015 influenza burden # https://www.cdc.gov/flu/about/burden/2014-2015.html ('US CDC 2014-2015', { (0,4): 396/3_207_314 * 100 * cdc_sympt, (5,17): 407/6_388_401 * 100 * cdc_sympt, (18,49): 985/8_606_083 * 100 * cdc_sympt, (50,64): 4_780/7_283_766 * 100 * cdc_sympt, (65,maxage): 44_808/4_679_888 * 100 * cdc_sympt, }), ] def col(is_covid, i): if is_covid: return plt.cm.bwr(255 - i * 7) else: return plt.cm.bwr_r(255 - i * 20) def plot(ax, ifrs, is_covid): lstyles = ('solid', 'dashed', 'dotted', 'dashdot') markers = ('o', 's', 'v', '^', '<', '>', 'P', '*', 'X', 'D', 'p') i = 0 for ifr in ifrs: name, ifr_by_age = ifr x, y = [], [] for age_group, ifr_val in sorted(ifr_by_age.items()): # place the marker at the middle (mean) of the age group x.append(np.mean(age_group)) y.append(ifr_val) ax.plot(x, y, color=col(is_covid, i), label=name, lw=1, alpha=.8, marker=markers[i % len(markers)], ms=4, ls=lstyles[i % len(lstyles)]) i += 1 def interpolate(age, x1, y1, x2, y2): def func_exp(x, a, b): return a * (b ** x) popt, pcov = curve_fit(func_exp, [x1, x2], [y1, y2]) return func_exp(age, *popt) def ifr_for_model(age, ifr_model): # calculate IFR for age m_prev = ifr_prev = None # iterate over the age groups in order for age_group, ifr in sorted(ifr_model[1].items()): m = np.mean(age_group) if m == age: return ifr if m > age: if ifr_prev == None: sys.stderr.write(f'{ifr_model[0]}: no data, age {age} too young\n') return None if ifr_prev == 0 or ifr == 0: sys.stderr.write(f'{ifr_model[0]}: ignoring IFR zero for age {age}\n') return None return interpolate(age, m_prev, ifr_prev, m, ifr) m_prev, ifr_prev = m, ifr sys.stderr.write(f'{ifr_model[0]}: no data, age {age} too old\n') return None def mean_ifr(age, ifr_models): # calculate the geometric mean of IFR estimates in for age values = [] for ifr_model in ifr_models: ifr = ifr_for_model(age, ifr_model) if ifr != None: values.append(ifr) return scipy.stats.gmean(values) def plot_comp(ax): for age in np.arange(30, 90, 10): y1 = mean_ifr(age, ifrs_flu) y2 = mean_ifr(age, ifrs_covid) assert not np.isnan(y1) and not np.isnan(y2) ax.annotate(s='', xy=(age, y1), xytext=(age, y2), arrowprops=dict(arrowstyle='|-|', shrinkA=0, shrinkB=0, alpha=.7)) ax.text(age, y1 * .6, f'{y2/y1:.1f}×', ha='center', va='top', weight='bold', size=12, alpha=.7) def main(): (fig, ax) = plt.subplots(dpi=300, figsize=(8,6)) # plot ifrs_covid plot(ax, ifrs_covid, True) ax.text(3, 45, 'COVID-19:') handles, labels = fig.gca().get_legend_handles_labels() first_legend = ax.legend(handles=handles, labels=labels, loc='upper left', frameon=False, fontsize='x-small', handlelength=5) fig.gca().add_artist(first_legend) # plot ifrs_flu plot(ax, ifrs_flu, False) # plot vertical comparison bars plot_comp(ax) ax.semilogy() ax.grid(True, which='minor', linewidth=0.1) ax.grid(True, which='major', linewidth=0.3) ax.spines['top'].set_visible(False) ax.spines['bottom'].set_visible(True) ax.spines['left'].set_visible(True) ax.spines['right'].set_visible(False) ax.set_ylabel('IFR (%)') ax.set_xlabel('Age') ax.set_xlim(left=0) ax.xaxis.set_minor_locator(ticker.MultipleLocator(base=5)) ax.xaxis.set_major_locator(ticker.MultipleLocator(base=10)) ax.yaxis.set_major_formatter(ticker.FormatStrFormatter('%g')) ax.text(75, .0025, 'Seasonal Influenza:') handles, labels = fig.gca().get_legend_handles_labels() x = len(ifrs_flu) ax.legend(handles=handles[-x:], labels=labels[-x:], loc='lower right', frameon=False, fontsize='x-small', handlelength=5) fig.suptitle('Infection Fatality Ratio of COVID-19 vs. Seasonal Influenza') ax.text(0, -0.11, 'Source: https://github.com/mbevand/covid19-age-stratified-ifr\n' 'Note: the vertical lines on one COVID-19 IFR curve (Poletti) are caused by the IFR being\n' 'estimated to be zero for age groups 0-19 and 20-49.\n', transform=ax.transAxes, fontsize='small', verticalalignment='top', ) ax.text(1, 1, 'Created by: Marc Bevand — @zorinaq', transform=ax.transAxes, fontsize='xx-small', va='top', ha='right') fig.savefig('covid_vs_flu.png', bbox_inches='tight') plt.close() if __name__ == '__main__': main()