# -*- coding: utf-8 -*- """ Functionality to create an HTML report using a fitted GLM & contrasts. Functions --------- make_glm_report(model, contrasts): Creates an HTMLDocument Object which can be viewed or saved as a report. """ import io import os import string import warnings from collections import OrderedDict try: from collections.abc import Iterable from html import escape from urllib.parse import quote except ImportError: # Python2 from collections import Iterable from cgi import escape from urllib import quote import numpy as np import pandas as pd from matplotlib import pyplot as plt from nilearn.plotting import (plot_glass_brain, plot_roi, plot_stat_map, ) from nilearn.plotting.img_plotting import MNI152TEMPLATE from nilearn.plotting.js_plotting_utils import HTMLDocument import nistats from nistats.reporting import (plot_contrast_matrix, plot_design_matrix, get_clusters_table, ) from nistats.thresholding import map_threshold HTML_TEMPLATE_ROOT_PATH = os.path.join(os.path.dirname(__file__), 'glm_reporter_templates') def make_glm_report(model, contrasts, title=None, bg_img=MNI152TEMPLATE, threshold=3.09, alpha=0.001, cluster_threshold=0, height_control='fpr', min_distance=8., plot_type='slice', display_mode=None, report_dims=(1600, 800), ): """ Returns HTMLDocument object for a report which shows all important aspects of a fitted GLM. The object can be opened in a browser, displayed in a notebook, or saved to disk as a standalone HTML file. Examples: report = make_glm_report(model, contrasts) report.open_in_browser() report.save_as_html(destination_path) Parameters ---------- model: FirstLevelModel or SecondLevelModel object A fitted first or second level model object. Must have the computed design matrix(ces). contrasts: Dict[string, ndarray] or String or List[String] or ndarray or List[ndarray] Contrasts information for a first or second level model. Example: Dict of contrast names and coefficients, or list of contrast names or list of contrast coefficients or contrast name or contrast coefficient Each contrast name must be a string. Each contrast coefficient must be a list or numpy array of ints. Contrasts are passed to ``contrast_def`` for FirstLevelModel [1]_ & second_level_contrast for SecondLevelModel [2]_ . title: String, optional If string, represents the web page's title and primary heading, model type is sub-heading. If None, page titles and headings are autogenerated using contrast names. bg_img: Niimg-like object Default is the MNI152 template See http://nilearn.github.io/manipulating_images/input_output.html The background image for mask and stat maps to be plotted on upon. To turn off background image, just pass "bg_img=None". threshold: float Default is 3.09 Cluster forming threshold in same scale as `stat_img` (either a t-scale or z-scale value). Used only if height_control is None. alpha: float Default is 0.001 Number controlling the thresholding (either a p-value or q-value). Its actual meaning depends on the height_control parameter. This function translates alpha to a z-scale threshold. cluster_threshold: int, optional Default is 0 Cluster size threshold, in voxels. height_control: string or None false positive control meaning of cluster forming threshold: 'fpr' (default) or 'fdr' or 'bonferroni' or None min_distance: `float` For display purposes only. Minimum distance between subpeaks in mm. Default is 8 mm. plot_type: String. ['slice' (default) or 'glass'] Specifies the type of plot to be drawn for the statistical maps. display_mode: string Default is 'z' if plot_type is 'slice'; ' ortho' if plot_type is 'glass'. Choose the direction of the cuts: 'x' - sagittal, 'y' - coronal, 'z' - axial, 'l' - sagittal left hemisphere only, 'r' - sagittal right hemisphere only, 'ortho' - three cuts are performed in orthogonal directions. Possible values are: 'ortho', 'x', 'y', 'z', 'xz', 'yx', 'yz', 'l', 'r', 'lr', 'lzr', 'lyr', 'lzry', 'lyrz'. report_dims: Sequence[int, int] Default is (1600, 800) pixels. Specifies width, height (in pixels) of report window within a notebook. Only applicable when inserting the report into a Jupyter notebook. Can be set after report creation using report.width, report.height. Returns ------- report_text: HTMLDocument Object Contains the HTML code for the GLM Report. References ---------- .. [1] :func:`nistats.first_level_model.FirstLevelModel.compute_contrast` .. [2] :func:`nistats.second_level_model.SecondLevelModel.compute_contrast` """ ''' Bug in Pandas 0.18 : https://github.com/pandas-dev/pandas/issues/13257 pd.set_option('display.precision', 2) limits number of digits shown instead of precision. Hence pd.option_context('display.precision', 2) has been used. ''' display_mode_selector = {'slice': 'z', 'glass': 'lzry'} if not display_mode: display_mode = display_mode_selector[plot_type] try: design_matrices = model.design_matrices_ except AttributeError: design_matrices = [model.design_matrix_] html_template_path = os.path.join(HTML_TEMPLATE_ROOT_PATH, 'report_template.html') with open(html_template_path) as html_file_obj: html_template_text = html_file_obj.read() report_template = string.Template(html_template_text) contrasts = _coerce_to_dict(contrasts) contrast_plots = _plot_contrasts(contrasts, design_matrices) page_title, page_heading_1, page_heading_2 = _make_headings( contrasts, title, model, ) with pd.option_context('display.max_colwidth', 100): model_attributes = _model_attributes_to_dataframe(model) model_attributes_html = _dataframe_to_html(model_attributes, precision=2, header=False, sparsify=False, ) statistical_maps = make_stat_maps(model, contrasts) html_design_matrices = _dmtx_to_svg_url(design_matrices) mask_img = model.mask_img or model.masker_.mask_img_ mask_plot_html_code = _mask_to_svg(mask_img=mask_img, bg_img=bg_img, ) all_components = _make_stat_maps_contrast_clusters( stat_img=statistical_maps, contrasts_plots=contrast_plots, threshold=threshold, alpha=alpha, cluster_threshold=cluster_threshold, height_control=height_control, min_distance=min_distance, bg_img=bg_img, display_mode=display_mode, plot_type=plot_type, ) all_components_text = '\n'.join(all_components) report_values = {'page_title': escape(page_title), 'page_heading_1': page_heading_1, 'page_heading_2': page_heading_2, 'model_attributes': model_attributes_html, 'all_contrasts_with_plots': ''.join( contrast_plots.values()), 'design_matrices': html_design_matrices, 'mask_plot': mask_plot_html_code, 'component': all_components_text, } report_text = report_template.safe_substitute(**report_values) report_text = HTMLDocument(report_text) # setting report size for better visual experience in Jupyter Notebooks. report_text.width, report_text.height = _check_report_dims(report_dims) return report_text def _check_report_dims(report_size): """ Warns the user & reverts to default if report dimensions are non-numerical. Parameters ---------- report_size: Tuple[int, int] Report width, height in jupyter notebook. Returns ------- report_size: Tuple[int, int] Valid values for report width, height in jupyter notebook. """ width, height = report_size try: width = int(width) height = int(height) except ValueError: warnings.warn('Report size has invalid values. Using default 1600x800') width, height = (1600, 800) return width, height def _coerce_to_dict(input_arg): """ Constructs a dict from the provided arg. If input_arg is: dict then returns it unchanged. string or collection of Strings or Sequence[int], returns a dict {str(value): value, ...} Parameters ---------- input_arg: String or Collection[str or Int or Sequence[Int]] or Dict[str, str or np.array] Can be of the form: 'string' ['string_1', 'string_2', ...] list/array [list/array_1, list/array_2, ...] {'string_1': list/array1, ...} Returns ------- Dict[str, np.array or str] """ if not isinstance(input_arg, dict): if isinstance(input_arg, Iterable): if not isinstance(input_arg[0], Iterable): input_arg = [input_arg] input_arg = [input_arg] if isinstance(input_arg, str) else input_arg input_arg = {str(contrast_): contrast_ for contrast_ in input_arg} return input_arg def plot_to_svg(plot): """ Creates an SVG image as a data URL from a Matplotlib Axes or Figure object. Parameters ---------- plot: Matplotlib Axes or Figure object Contains the plot information. Returns ------- url_plot_svg: String SVG Image Data URL """ with io.BytesIO() as buffer: try: plot.figure.savefig(buffer, format='svg') except AttributeError: plot.savefig(buffer, format='svg') svg_plot = buffer.getvalue() try: url_svg_plot = quote(svg_plot.decode('utf8')) except KeyError: # Fails on Python2. url_svg_plot = quote(svg_plot) return url_svg_plot def _plot_contrasts(contrasts, design_matrices): """ Accepts dict of contrasts and list of design matrices and generates a dict of contrast titles & HTML for SVG Image data url for corresponding contrast plot. Parameters ---------- contrasts: Dict[str, np.array or str] Contrast information, as a dict {'contrast_title_1, contrast_info_1/title_1, ...} design_matrices: List[pd.Dataframe] Design matrices computed in the model. Returns ------- contrast_plots: Dict[str, svg img] Dict of contrast title and svg image data url for corresponding contrast plot. """ all_contrasts_plots = {} contrast_template_path = os.path.join(HTML_TEMPLATE_ROOT_PATH, 'contrast_template.html' ) with open(contrast_template_path) as html_template_obj: contrast_template_text = html_template_obj.read() for design_matrix in design_matrices: for contrast_name, contrast_data in contrasts.items(): contrast_text_ = string.Template(contrast_template_text) contrast_plot = plot_contrast_matrix(contrast_data, design_matrix, colorbar=True) contrast_plot.set_xlabel(contrast_name) contrast_plot.figure.set_figheight(2) contrast_plot.figure.set_tight_layout(True) url_contrast_plot_svg = plot_to_svg(contrast_plot) # prevents sphinx-gallery & jupyter from scraping & inserting plots plt.close() contrasts_for_subsitution = { 'contrast_plot': url_contrast_plot_svg, 'contrast_name': contrast_name, } contrast_text_ = contrast_text_.safe_substitute( contrasts_for_subsitution ) all_contrasts_plots[contrast_name] = contrast_text_ return all_contrasts_plots def _make_headings(contrasts, title, model): """ Creates report page title, heading & sub-heading using title text or contrast names. Accepts contrasts and user supplied title string or contrasts and user supplied 3 element list or tuple. If title is not in (None, 'auto'), page title == heading, model type == sub-heading Parameters ---------- contrasts: Dict[str, np.array or str] Contrast information, as a dict in the form {'contrast_title_1': contrast_info_1/title_1, ...} Contrast titles are used in page title and secondary heading if `title` is not 'auto' or None. title: String or List/Tuple with 3 elements User supplied text for HTML Page title and primary heading. Or 3 element List/Tuple for Title Heading, sub-heading resp. Overrides title auto-generation. model: FirstLevelModel or SecondLevelModel The model, passed in to determine its type to be used in page title & headings. Returns ------- (HTML page title, heading, sub-heading): Tuple[str, str, str] If title is user-supplied, then subheading is empty string. """ if type(model) == nistats.first_level_model.FirstLevelModel: model_type = 'First Level Model' elif type(model) == nistats.second_level_model.SecondLevelModel: model_type = 'Second Level Model' if title: return title, title, model_type contrasts_names = sorted(list(contrasts.keys())) contrasts_text = ', '.join(contrasts_names) page_title = 'Report: {} for {}'.format(model_type, contrasts_text) page_heading_1 = 'Statistical Report for {}'.format(contrasts_text) page_heading_2 = model_type return page_title, page_heading_1, page_heading_2 def _model_attributes_to_dataframe(model): """ Returns an HTML table with pertinent model attributes & information. Parameters ---------- model: FirstLevelModel or SecondLevelModel object. Returns ------- HTML Table: String HTML table with the pertinent attributes of the model. """ selected_attributes = [ 'subject_label', 'drift_model', 'hrf_model', 'standardize', 'noise_model', 't_r', 'high_pass', 'target_shape', 'signal_scaling', 'drift_order', 'scaling_axis', 'smoothing_fwhm', 'target_affine', 'slice_time_ref', 'fir_delays', ] attribute_units = { 't_r': 's', 'high_pass': 'Hz', } selected_attributes.sort() display_attributes = OrderedDict( (attr_name, getattr(model, attr_name)) for attr_name in selected_attributes if hasattr(model, attr_name) ) model_attributes = pd.DataFrame.from_dict(display_attributes, orient='index', ) attribute_names_with_units = { attribute_name_: attribute_name_ + ' ({})'.format(attribute_unit_) for attribute_name_, attribute_unit_ in attribute_units.items() } model_attributes.rename(index=attribute_names_with_units, inplace=True) return model_attributes def make_stat_maps(model, contrasts): """ Given a model and contrasts, return the corresponding z-maps Parameters ---------- model: FirstLevelModel or SecondLevelModel object Must have a fitted design matrix(ces). contrasts: Dict[str, ndarray or str] Dict of contrasts for a first or second level model. Corresponds to the contrast_def for the FirstLevelModel [1]_ & second_level_contrast for a SecondLevelModel [2]_ . Returns ------- statistical_maps: Dict[str, niimg] Dict of statistical z-maps keyed to contrast names/titles. See Also -------- .. [1] nistats.first_level_model.FirstLevelModel.compute_contrast .. [2] nistats.second_level_model.SecondLevelModel.compute_contrast """ statistical_maps = {contrast_id: model.compute_contrast(contrast_val) for contrast_id, contrast_val in contrasts.items() } return statistical_maps def _dmtx_to_svg_url(design_matrices): """ Accepts a FirstLevelModel or SecondLevelModel object with fitted design matrices & generates SVG Image URL, which can be inserted into an HTML template. Parameters ---------- design_matrices: List[pd.Dataframe] Design matrices computed in the model. Returns ------- svg_url_design_matrices: String SVG Image URL for the plotted design matrices, """ html_design_matrices = [] dmtx_template_path = os.path.join(HTML_TEMPLATE_ROOT_PATH, 'design_matrix_template.html' ) with open(dmtx_template_path) as html_template_obj: dmtx_template_text = html_template_obj.read() for dmtx_count, design_matrix in enumerate(design_matrices, start=1): dmtx_text_ = string.Template(dmtx_template_text) dmtx_plot = plot_design_matrix(design_matrix) dmtx_title = 'Session {}'.format(dmtx_count) plt.title(dmtx_title, y=0.987) dmtx_plot = _resize_plot_inches(dmtx_plot, height_change=.3) url_design_matrix_svg = plot_to_svg(dmtx_plot) # prevents sphinx-gallery & jupyter from scraping & inserting plots plt.close() dmtx_text_ = dmtx_text_.safe_substitute( {'design_matrix': url_design_matrix_svg, 'dmtx_title': dmtx_title, } ) html_design_matrices.append(dmtx_text_) svg_url_design_matrices = ''.join(html_design_matrices) return svg_url_design_matrices def _resize_plot_inches(plot, width_change=0, height_change=0): """ Accepts a matplotlib figure or axes object and resizes it (in inches). Returns the original object. Parameters ---------- plot: matplotlib.Figure() or matplotlib.Axes() The matplotlib Figure/Axes object to be resized. width_change: `float` The amount of change to be added on to original width. Use negative values for reducing figure dimensions. height_change `float` The amount of change to be added on to original height. Use negative values for reducing figure dimensions. Returns ------- plot: matplotlib.Figure() or matplotlib.Axes() The matplotlib Figure/Axes object after being resized. """ try: orig_size = plot.figure.get_size_inches() except AttributeError: orig_size = plot.get_size_inches() new_size = (orig_size[0] + width_change, orig_size[1] + height_change, ) try: plot.figure.set_size_inches(new_size, forward=True) except AttributeError: plot.set_size_inches(new_size) return plot def _mask_to_svg(mask_img, bg_img): """ Plot cuts of an mask image and creates SVG code of it. Parameters ---------- mask_img : Niimg-like object See http://nilearn.github.io/manipulating_images/input_output.html The mask image; it could be binary mask or an atlas or ROIs with integer values. bg_img : Niimg-like object See http://nilearn.github.io/manipulating_images/input_output.html The background image that the mask will be plotted on top of. To turn off background image, just pass "bg_img=None". Returns ------- mask_plot_svg: str SVG Image Data URL for the mask plot. """ if mask_img: mask_plot = plot_roi(roi_img=mask_img, bg_img=bg_img, display_mode='z', cmap='Set1', ) mask_plot # So flake8 doesn't complain about not using variable (F841) mask_plot_svg = plot_to_svg(plt.gcf()) # prevents sphinx-gallery & jupyter from scraping & inserting plots plt.close() else: mask_plot_svg = None # HTML image tag's alt attribute is used. return mask_plot_svg def _make_stat_maps_contrast_clusters(stat_img, contrasts_plots, threshold, alpha, cluster_threshold, height_control, min_distance, bg_img, display_mode, plot_type): """ Populates a smaller HTML sub-template with the proper values, make a list containing one or more of such components & returns the list to be inserted into the HTML Report Template. Each component contains the HTML code for a contrast & its corresponding statistical maps & cluster table; Parameters ---------- stat_img : Niimg-like object or None statistical image (presumably in z scale) whenever height_control is 'fpr' or None, stat_img=None is acceptable. If it is 'fdr' or 'bonferroni', an error is raised if stat_img is None. contrasts_plots: Dict[str, str] Contains the contrast names & the HTML code of the contrast's SVG plot. threshold: float desired threshold in z-scale. This is used only if height_control is None alpha: float number controlling the thresholding (either a p-value or q-value). Its actual meaning depends on the height_control parameter. This function translates alpha to a z-scale threshold. cluster_threshold : float cluster size threshold. In the returned thresholded map, sets of connected voxels (`clusters`) with size smaller than this number will be removed. height_control: string false positive control meaning of cluster forming threshold: 'fpr' or 'fdr' or 'bonferroni' or None min_distance: `float` For display purposes only. Minimum distance between subpeaks in mm. Default is 8 mm. bg_img : Niimg-like object Only used when plot_type is 'slice'. See http://nilearn.github.io/manipulating_images/input_output.html The background image for stat maps to be plotted on upon. If nothing is specified, the MNI152 template will be used. To turn off background image, just pass "bg_img=False". display_mode: string Choose the direction of the cuts: 'x' - sagittal, 'y' - coronal, 'z' - axial, 'l' - sagittal left hemisphere only, 'r' - sagittal right hemisphere only, 'ortho' - three cuts are performed in orthogonal directions. Possible values are: 'ortho', 'x', 'y', 'z', 'xz', 'yx', 'yz', 'l', 'r', 'lr', 'lzr', 'lyr', 'lzry', 'lyrz'. plot_type: string ['slice', 'glass'] The type of plot to be drawn. Returns ------- all_components: List[String] Each element is a set of HTML code for contrast name, contrast plot, statistical map, cluster table. """ all_components = [] components_template_path = os.path.join( HTML_TEMPLATE_ROOT_PATH, 'stat_maps_contrast_clusters_template.html' ) with open(components_template_path) as html_template_obj: components_template_text = html_template_obj.read() for contrast_name, stat_map_img in stat_img.items(): component_text_ = string.Template(components_template_text) thresholded_stat_map, threshold = map_threshold( stat_img=stat_map_img, threshold=threshold, alpha=alpha, cluster_threshold=cluster_threshold, height_control=height_control, ) table_details = _clustering_params_to_dataframe(threshold, cluster_threshold, min_distance, height_control, alpha, ) stat_map_svg = _stat_map_to_svg( stat_img=thresholded_stat_map, bg_img=bg_img, display_mode=display_mode, plot_type=plot_type, table_details=table_details, ) cluster_table = get_clusters_table(stat_map_img, stat_threshold=threshold, cluster_threshold=cluster_threshold, min_distance=min_distance, ) cluster_table_html = _dataframe_to_html(cluster_table, precision=2, index=False, classes='cluster-table', ) table_details_html = _dataframe_to_html( table_details, precision=2, header=False, classes='cluster-details-table', ) components_values = { 'contrast_name': escape(contrast_name), 'contrast_plot': contrasts_plots[contrast_name], 'stat_map_img': stat_map_svg, 'cluster_table_details': table_details_html, 'cluster_table': cluster_table_html, } component_text_ = component_text_.safe_substitute(**components_values) all_components.append(component_text_) return all_components def _clustering_params_to_dataframe(threshold, cluster_threshold, min_distance, height_control, alpha, ): """ Creates a Pandas DataFrame from the supplied arguments. For use as part of the Cluster Table. Parameters ---------- threshold: `float` Cluster forming threshold in same scale as `stat_img` (either a p-value or z-scale value). cluster_threshold : `int` or `None`, optional Cluster size threshold, in voxels. min_distance: `float` For display purposes only. Minimum distance between subpeaks in mm. Default is 8 mm. height_control: string or None false positive control meaning of cluster forming threshold: 'fpr' (default) or 'fdr' or 'bonferroni' or None alpha: float Default is 0.001 Number controlling the thresholding (either a p-value or q-value). Its actual meaning depends on the height_control parameter. This function translates alpha to a z-scale threshold. Returns ------- Pandas.DataFrame Dataframe with clustering parameters """ table_details = OrderedDict() threshold = np.around(threshold, 3) if height_control: table_details.update({'Height control': height_control}) ''' HTMLDocument.get_iframe() invoked in Python2 Jupyter Notebooks mishandles certain unicode characters & raises error due to greek alpha symbol. This is simpler than overloading the class using inheritance, especially given limited Python2 use at time of release. ''' if os.sys.version_info.major == 2: table_details.update({'alpha': alpha}) else: table_details.update({u'\u03B1': alpha}) table_details.update({'Threshold (computed)': threshold}) else: table_details.update({'Height control': 'None'}) table_details.update({'Threshold Z': threshold}) table_details.update( {'Cluster size threshold (voxels)': cluster_threshold} ) table_details.update({'Minimum distance (mm)': min_distance}) table_details = pd.DataFrame.from_dict(table_details, orient='index', ) return table_details def _stat_map_to_svg(stat_img, bg_img, display_mode, plot_type, table_details, ): """ Generates SVG code for a statistical map, including its clustering parameters. Parameters ---------- stat_img : Niimg-like object or None Statistical image (presumably in z scale), to be plotted as slices or glass brain. Does not perform any thresholding. bg_img : Niimg-like object Only used when plot_type is 'slice'. See http://nilearn.github.io/manipulating_images/input_output.html The background image for stat maps to be plotted on upon. If nothing is specified, the MNI152 template will be used. To turn off background image, just pass "bg_img=False". display_mode: string Choose the direction of the cuts: 'x' - sagittal, 'y' - coronal, 'z' - axial, 'l' - sagittal left hemisphere only, 'r' - sagittal right hemisphere only, 'ortho' - three cuts are performed in orthogonal directions. Possible values are: 'ortho', 'x', 'y', 'z', 'xz', 'yx', 'yz', 'l', 'r', 'lr', 'lzr', 'lyr', 'lzry', 'lyrz'. plot_type: string ['slice', 'glass'] The type of plot to be drawn. table_details: pandas.Dataframe Dataframe listing the parameters used for clustering, to be included in the plot. Returns ------- stat_map_svg: string SVG Image Data URL representing a statistical map. """ if plot_type == 'slice': stat_map_plot = plot_stat_map(stat_img, bg_img=bg_img, display_mode=display_mode, ) elif plot_type == 'glass': stat_map_plot = plot_glass_brain(stat_img, display_mode=display_mode, colorbar=True, plot_abs=False, ) else: raise ValueError('Invalid plot type provided. Acceptable options are' "'slice' or 'glass'.") with pd.option_context('display.precision', 2): stat_map_plot = _add_params_to_plot(table_details, stat_map_plot) fig = plt.gcf() stat_map_svg = plot_to_svg(fig) # prevents sphinx-gallery & jupyter from scraping & inserting plots plt.close() return stat_map_svg def _add_params_to_plot(table_details, stat_map_plot): """ Inserts thresholding parameters into the stat map plot as figure suptitle. Parameters ---------- table_details: Dict[String, Any] Dict of parameters and values used in thresholding. stat_map_plot: matplotlib.Axes Axes object of the stat map plot. Returns ------- stat_map_plot: matplotlib.Axes Axes object of the stat map plot, with the added suptitle . """ thresholding_params = [':'.join([name, str(val)]) for name, val in table_details[0].items()] thresholding_params = ' '.join(thresholding_params) suptitle_text = plt.suptitle(thresholding_params, fontsize=11, x=.45, wrap=True, ) fig = list(stat_map_plot.axes.values())[0].ax.figure fig = _resize_plot_inches(plot=fig, width_change=.2, height_change=1, ) if stat_map_plot._black_bg: suptitle_text.set_color('w') return stat_map_plot def _dataframe_to_html(df, precision, **kwargs): """ Makes HTML table from provided dataframe. Removes HTML5 non-compliant attributes (ex: `border`). Parameters ---------- df: pandas.Dataframe Dataframe to be converted into HTML table. precision: int The display precision for float values in the table. **kwargs: keyworded arguments Supplies keyworded arguments for func: pandas.Dataframe.to_html() Returns ------- html_table: String Code for HTML table. """ with pd.option_context('display.precision', precision): html_table = df.to_html(**kwargs) html_table = html_table.replace('border="1" ', '') return html_table